The identity writer appends as 06 promised — clearing is the one sanctioned edit

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-06 16:57:20 -04:00
parent c60553f17f
commit a60d97689e
4 changed files with 310 additions and 90 deletions
+119 -85
View File
@@ -200,13 +200,9 @@ enum GitConfigFile {
/// own global `~/.gitconfig` for their terminal commits in that board. What lands here is what the
/// user typed and nothing else.
///
/// **Empty clears the key** rather than writing an empty value the fields show the derived
/// default as a *placeholder*, so an empty field means "no repo-local opinion", which in this file
/// is spelled by the key's absence. A `[user]` section left with nothing in it is removed too, so
/// clearing both fields leaves a config indistinguishable from one the user never edited.
///
/// Everything else in the file survives verbatim: other sections, comments, indentation, and any
/// `[user]` key this app has no opinion about (`signingkey`, say).
/// Skips the disk write entirely when `applying` reports no change: the settings sheet re-reads
/// this file every 2 s while it is visible (06's visibility-scoped poll), and a write that would
/// not change a byte must not churn the mtime that poll is watching.
static func writeIdentity(
name: String?,
email: String?,
@@ -215,69 +211,62 @@ enum GitConfigFile {
let configURL = gitDirectory.appendingPathComponent("config")
let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
let updated = applying(name: name, email: email, to: existing)
guard updated != existing else { return }
try Data(updated.utf8).write(to: configURL, options: .atomic)
}
/// The edit, over text the pure half, which is where every rule above is decided and the only
/// The edit, over text the pure half, which is where every rule below is decided and the only
/// half a test needs.
///
/// **"Writes append, reads take the last"** (06-history-undo.md Interaction with external
/// writers, blessed 2026-07-31): a *set* never edits or deletes an existing line. It appends one
/// new plain `[user]` section at the very end of the file `name` before `email` even when
/// plain `[user]` sections already exist; last-wins reading is exactly what makes the appended
/// value win, "without the writer ever reformatting what it didn't create". "A write whose keys
/// already read back at their target values is skipped whole, so revisiting the sheet never grows
/// the file": resolution runs first, through the same `identity(inConfigText:)` this file's reads
/// use, and a key set to its already-current value or cleared when already absent drops out
/// of the pending work before anything is touched. If nothing remains pending, the input comes
/// back byte-identical.
///
/// **"Clearing a key is the one sanctioned in-place edit"** (ruled 2026-08-06): "the config
/// format spells absence one way only the key not being there", so a clear deletes every
/// plain-section line for that key, in every plain `[user]` section "deleting fewer than all of
/// them changes nothing under last-wins" and then drops any plain `[user]` header left with no
/// real key lines under it (only blanks/comments); a section that keeps another key (`signingkey`,
/// say) keeps its header. **"Subsections stay untouchable in both directions"**: `[user "work"]`
/// is never edited by a set or a clear, whatever its keys. A combined set-and-clear call does the
/// clears in place, then appends the set section.
static func applying(name: String?, email: String?, to text: String) -> String {
func cleaned(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else { return nil }
return trimmed
}
// `nil` is "clear this key"; a key absent from the dictionary has already been dealt with.
var pending: [String: String?] = ["name": cleaned(name), "email": cleaned(email)]
// No-op skip: resolve what the file currently says (last-wins, same reader the rest of the
// app uses) and drop any key whose target already matches set-to-current and
// clear-when-absent both mean "nothing to do" for that key.
let current = identity(inConfigText: text)
let targets: [(key: String, target: String?, current: String?)] = [
("name", cleaned(name), current.name),
("email", cleaned(email), current.email)
]
// `nil` is "clear this key"; a key with no pending work is simply absent from the dictionary.
var pending: [String: String?] = [:]
for entry in targets where entry.target != entry.current {
pending[entry.key] = entry.target
}
guard !pending.isEmpty else { return text }
// Split on `\n` and rejoin, so the file's own trailing-newline shape survives the round trip
// (`components(separatedBy:)` renders a trailing newline as a final empty element).
var output: [String] = []
/// Whether the lines being read belong to the **plain** `[user]` section. A subsectioned
/// `[user "work"]` is a different scope in git's own model (`user.work.name`, not
/// `user.name`), and editing keys inside one would be this app rewriting a setting the user
/// aimed somewhere else much the worse error, whatever the read side does with it.
///
/// (The read side, `identity(inConfigText:)`, scopes itself to plain sections for the same
/// reason and takes the last one's value, so the two halves agree by construction rather than
/// by coincidence.)
var isPlainUserSection = false
/// Where a key the file does not yet have would be inserted: just after the last line of the
/// plain `[user]` section, or `nil` while there is no such section.
var insertionPoint: Int?
var lines = text.isEmpty ? [] : text.components(separatedBy: "\n")
for line in text.isEmpty ? [] : text.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") {
let header = trimmed.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
let section = header
.split(separator: " ", maxSplits: 1)
.first
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
isPlainUserSection = section == "user" && !header.contains("\"")
output.append(line)
if isPlainUserSection { insertionPoint = output.count }
continue
}
let isUserSection = isPlainUserSection
if isUserSection, let separator = trimmed.firstIndex(of: "=") {
let key = trimmed[trimmed.startIndex..<separator]
.trimmingCharacters(in: .whitespaces)
.lowercased()
if let replacement = pending[key] {
pending.removeValue(forKey: key)
if let replacement {
output.append("\t\(key) = \(replacement)")
insertionPoint = output.count
}
// A cleared key simply does not join the output.
continue
}
}
output.append(line)
if isUserSection, insertionPoint != nil, !trimmed.isEmpty { insertionPoint = output.count }
let clearedKeys = Set(pending.compactMap { key, value in value == nil ? key : nil })
if !clearedKeys.isEmpty {
lines = removingKeys(clearedKeys, fromPlainUserSectionsIn: lines)
lines = removingEmptyPlainUserSections(from: lines)
}
// Name before email, always a file this app wrote reads the same whichever field was
@@ -286,40 +275,85 @@ enum GitConfigFile {
guard let value = pending[key] ?? nil else { return nil }
return "\t\(key) = \(value)"
}
if !additions.isEmpty {
if let insertionPoint {
output.insert(contentsOf: additions, at: insertionPoint)
} else {
if let last = output.last, !last.trimmingCharacters(in: .whitespaces).isEmpty {
output.append("")
}
output.append("[user]")
output.append(contentsOf: additions)
output.append("")
}
}
guard !additions.isEmpty else { return lines.joined(separator: "\n") }
return removingEmptyUserSection(from: output).joined(separator: "\n")
if let last = lines.last, !last.trimmingCharacters(in: .whitespaces).isEmpty {
lines.append("")
}
lines.append("[user]")
lines.append(contentsOf: additions)
lines.append("")
return lines.joined(separator: "\n")
}
/// Drops a `[user]` header with no keys under it what clearing both fields leaves behind, and
/// what a config the user never touched does not have.
private static func removingEmptyUserSection(from lines: [String]) -> [String] {
guard let header = lines.firstIndex(where: {
let trimmed = $0.trimmingCharacters(in: .whitespaces)
return trimmed.lowercased().hasPrefix("[user]")
}) else { return lines }
/// Whether a trimmed line opens the **plain** `[user]` section the load-bearing distinction
/// throughout this file. A subsectioned `[user "work"]` is a different key in git's own model
/// (`user.work.name`, not `user.name`), so it must never match here: matching it would let a set
/// or a clear reach into a scope the user filed under a name this app never asked about.
private static func isPlainUserHeader(_ trimmedLine: String) -> Bool {
guard trimmedLine.hasPrefix("[") else { return false }
let header = trimmedLine.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
let section = header
.split(separator: " ", maxSplits: 1)
.first
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
return section == "user" && !header.contains("\"")
}
var end = header + 1
while end < lines.count {
let trimmed = lines[end].trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") { break }
if !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix(";") { return lines }
end += 1
/// The clear's in-place edit: deletes every line, in every plain `[user]` section, whose key
/// (trimmed, lowercased, before `=`) is in `keys`. Deleting fewer than all of them would change
/// nothing under last-wins reading, so this walks the whole file rather than stopping at the
/// first match. Lines outside a plain `[user]` section including everything inside a `[user
/// ""]` subsection are never inspected for deletion.
private static func removingKeys(_ keys: Set<String>, fromPlainUserSectionsIn lines: [String]) -> [String] {
var result: [String] = []
var isPlainUserSection = false
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") {
isPlainUserSection = isPlainUserHeader(trimmed)
result.append(line)
continue
}
if isPlainUserSection, let separator = trimmed.firstIndex(of: "=") {
let key = trimmed[trimmed.startIndex..<separator]
.trimmingCharacters(in: .whitespaces)
.lowercased()
if keys.contains(key) { continue }
}
result.append(line)
}
var kept = lines
kept.removeSubrange(header..<end)
return kept
return result
}
/// Drops every plain `[user]` header left with no real key lines under it (only blanks/comments)
/// what a clear leaves behind, and what a config the user never touched does not have. Walks
/// the whole file rather than the first section alone: a clear can empty out more than one plain
/// `[user]` section in the same call, and a section that still carries another key (`signingkey`,
/// say) keeps its header.
private static func removingEmptyPlainUserSections(from lines: [String]) -> [String] {
var result: [String] = []
var index = 0
while index < lines.count {
guard isPlainUserHeader(lines[index].trimmingCharacters(in: .whitespaces)) else {
result.append(lines[index])
index += 1
continue
}
var end = index + 1
var hasRealKey = false
while end < lines.count {
let trimmed = lines[end].trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") { break }
if !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix(";") { hasRealKey = true }
end += 1
}
if hasRealKey { result.append(contentsOf: lines[index..<end]) }
index = end
}
return result
}
/// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside