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:
@@ -59,7 +59,7 @@ Agent and hand edits arrive through the watcher like any change and get auto-com
|
|||||||
|
|
||||||
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery (the **EchoLedger** — 02-architecture.md ▸ Components, where its matching rule and race cases are settled) lets the auto-committer classify every observed change, per file, as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin. **The committer field is always the user's identity** (blessed 2026-07-31 — git's own `am`/cherry-pick convention: author = whose change, committer = who recorded it): every commit the app makes, foreign-authored included, records the user's app as its committer. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
|
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery (the **EchoLedger** — 02-architecture.md ▸ Components, where its matching rule and race cases are settled) lets the auto-committer classify every observed change, per file, as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin. **The committer field is always the user's identity** (blessed 2026-07-31 — git's own `am`/cherry-pick convention: author = whose change, committer = who recorded it): every commit the app makes, foreign-authored included, records the user's app as its committer. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
|
||||||
|
|
||||||
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The board settings sheet's identity section (03-board-ui.md — the 2026-07-31 split moved the fields out of the popover) exposes name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). **The fields re-read the config at 2 s while the sheet is visible** (blessed 2026-07-31): the watcher never delivers `.git`, so no board event can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused field keeps its keystrokes; dismissing the sheet stops the poll). **Writes append, reads take the last** (blessed 2026-07-31): the writer appends a plain `[user]` section and never edits existing sections or `[user "…"]` subsections (their semantics are tool-specific); the reader — like git itself — takes the last plain-section value, which is exactly what an append produces. The asymmetry lets the write always win without the writer ever reformatting what it didn't create — the frontmatter engine's never-reformat instinct applied to git config; the worst case is a slightly redundant file git reads correctly. Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the sheet's identity fields are the fix when that matters. **The derived default is passed as an explicit per-commit signature, never written into repo config** (ruled 2026-07-31 — the signature-capable commit path gates the pro-m1 ship): repo config is the record of the user's popover edits and of adopted repos' own state, and an app-written identity there would outrank the user's global `~/.gitconfig` for their *own terminal commits* in that board. The build-time interim that materializes identity into a fresh repo's `.git/config` (SwiftGitX 0.4.0's signatureless commit + the sandbox's unreadable global config) is tolerated in-tree during pro-m1 construction and must die before release — the attribution rules above (per-commit author variation) require explicit signatures anyway. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
|
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The board settings sheet's identity section (03-board-ui.md — the 2026-07-31 split moved the fields out of the popover) exposes name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). **The fields re-read the config at 2 s while the sheet is visible** (blessed 2026-07-31): the watcher never delivers `.git`, so no board event can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused field keeps its keystrokes; dismissing the sheet stops the poll). **Writes append, reads take the last** (blessed 2026-07-31): the writer appends a plain `[user]` section and never edits existing sections or `[user "…"]` subsections (their semantics are tool-specific); the reader — like git itself — takes the last plain-section value, which is exactly what an append produces. The asymmetry lets the write always win without the writer ever reformatting what it didn't create — the frontmatter engine's never-reformat instinct applied to git config; the worst case is a slightly redundant file git reads correctly. A write whose keys already read back at their target values is skipped whole, so revisiting the sheet never grows the file. **Clearing a key is the one sanctioned in-place edit** (ruled 2026-08-06): an empty field means "no repo-local opinion", and the config format spells absence one way only — the key not being there. The append-shaped alternative, an empty `email =` line, is an opinion in the wrong direction: a repo-level empty value *overrides* the user's global `~/.gitconfig` in their own terminal and fails their commits with git's empty-ident error. So a clear deletes every plain-section line for that key — deleting fewer than all of them changes nothing under last-wins — and drops any plain `[user]` header left with no keys under it; clearing both fields leaves the file with no plain-section identity at all, the state a never-configured repo is in. Subsections stay untouchable in both directions. Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the sheet's identity fields are the fix when that matters. **The derived default is passed as an explicit per-commit signature, never written into repo config** (ruled 2026-07-31 — the signature-capable commit path gates the pro-m1 ship): repo config is the record of the user's popover edits and of adopted repos' own state, and an app-written identity there would outrank the user's global `~/.gitconfig` for their *own terminal commits* in that board. The build-time interim that materializes identity into a fresh repo's `.git/config` (SwiftGitX 0.4.0's signatureless commit + the sandbox's unreadable global config) is tolerated in-tree during pro-m1 construction and must die before release — the attribution rules above (per-commit author variation) require explicit signatures anyway. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
|
||||||
|
|
||||||
**`modified-by` refines foreign attribution** (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same `modified-by: X`, that commit is authored as **X** with the synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to `Lanework External` — a deletion leaves no file to stamp. **A folder move is not a deletion**: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare `mv` rewrites nothing — the moved `index.md` still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.
|
**`modified-by` refines foreign attribution** (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same `modified-by: X`, that commit is authored as **X** with the synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to `Lanework External` — a deletion leaves no file to stamp. **A folder move is not a deletion**: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare `mv` rewrites nothing — the moved `index.md` still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.
|
||||||
|
|
||||||
|
|||||||
+119
-85
@@ -200,13 +200,9 @@ enum GitConfigFile {
|
|||||||
/// own global `~/.gitconfig` for their terminal commits in that board. What lands here is what the
|
/// own global `~/.gitconfig` for their terminal commits in that board. What lands here is what the
|
||||||
/// user typed and nothing else.
|
/// user typed and nothing else.
|
||||||
///
|
///
|
||||||
/// **Empty clears the key** rather than writing an empty value — the fields show the derived
|
/// Skips the disk write entirely when `applying` reports no change: the settings sheet re-reads
|
||||||
/// default as a *placeholder*, so an empty field means "no repo-local opinion", which in this file
|
/// this file every 2 s while it is visible (06's visibility-scoped poll), and a write that would
|
||||||
/// is spelled by the key's absence. A `[user]` section left with nothing in it is removed too, so
|
/// not change a byte must not churn the mtime that poll is watching.
|
||||||
/// 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).
|
|
||||||
static func writeIdentity(
|
static func writeIdentity(
|
||||||
name: String?,
|
name: String?,
|
||||||
email: String?,
|
email: String?,
|
||||||
@@ -215,69 +211,62 @@ enum GitConfigFile {
|
|||||||
let configURL = gitDirectory.appendingPathComponent("config")
|
let configURL = gitDirectory.appendingPathComponent("config")
|
||||||
let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
|
let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
|
||||||
let updated = applying(name: name, email: email, to: existing)
|
let updated = applying(name: name, email: email, to: existing)
|
||||||
|
guard updated != existing else { return }
|
||||||
try Data(updated.utf8).write(to: configURL, options: .atomic)
|
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.
|
/// 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 {
|
static func applying(name: String?, email: String?, to text: String) -> String {
|
||||||
func cleaned(_ value: String?) -> String? {
|
func cleaned(_ value: String?) -> String? {
|
||||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!trimmed.isEmpty else { return nil }
|
!trimmed.isEmpty else { return nil }
|
||||||
return trimmed
|
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
|
// 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).
|
// (`components(separatedBy:)` renders a trailing newline as a final empty element).
|
||||||
var output: [String] = []
|
var lines = text.isEmpty ? [] : text.components(separatedBy: "\n")
|
||||||
/// 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?
|
|
||||||
|
|
||||||
for line in text.isEmpty ? [] : text.components(separatedBy: "\n") {
|
let clearedKeys = Set(pending.compactMap { key, value in value == nil ? key : nil })
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
if !clearedKeys.isEmpty {
|
||||||
|
lines = removingKeys(clearedKeys, fromPlainUserSectionsIn: lines)
|
||||||
if trimmed.hasPrefix("[") {
|
lines = removingEmptyPlainUserSections(from: lines)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name before email, always — a file this app wrote reads the same whichever field was
|
// 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 }
|
guard let value = pending[key] ?? nil else { return nil }
|
||||||
return "\t\(key) = \(value)"
|
return "\t\(key) = \(value)"
|
||||||
}
|
}
|
||||||
if !additions.isEmpty {
|
guard !additions.isEmpty else { return lines.joined(separator: "\n") }
|
||||||
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("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
/// Whether a trimmed line opens the **plain** `[user]` section — the load-bearing distinction
|
||||||
/// what a config the user never touched does not have.
|
/// throughout this file. A subsectioned `[user "work"]` is a different key in git's own model
|
||||||
private static func removingEmptyUserSection(from lines: [String]) -> [String] {
|
/// (`user.work.name`, not `user.name`), so it must never match here: matching it would let a set
|
||||||
guard let header = lines.firstIndex(where: {
|
/// or a clear reach into a scope the user filed under a name this app never asked about.
|
||||||
let trimmed = $0.trimmingCharacters(in: .whitespaces)
|
private static func isPlainUserHeader(_ trimmedLine: String) -> Bool {
|
||||||
return trimmed.lowercased().hasPrefix("[user]")
|
guard trimmedLine.hasPrefix("[") else { return false }
|
||||||
}) else { return lines }
|
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
|
/// The clear's in-place edit: deletes every line, in every plain `[user]` section, whose key
|
||||||
while end < lines.count {
|
/// (trimmed, lowercased, before `=`) is in `keys`. Deleting fewer than all of them would change
|
||||||
let trimmed = lines[end].trimmingCharacters(in: .whitespaces)
|
/// nothing under last-wins reading, so this walks the whole file rather than stopping at the
|
||||||
if trimmed.hasPrefix("[") { break }
|
/// first match. Lines outside a plain `[user]` section — including everything inside a `[user
|
||||||
if !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix(";") { return lines }
|
/// "…"]` subsection — are never inspected for deletion.
|
||||||
end += 1
|
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
|
return result
|
||||||
kept.removeSubrange(header..<end)
|
}
|
||||||
return kept
|
|
||||||
|
/// 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
|
/// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside
|
||||||
|
|||||||
@@ -983,8 +983,8 @@ struct GitIdentityWriteTests {
|
|||||||
#expect(read.email == "[email protected]")
|
#expect(read.email == "[email protected]")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("An existing value is replaced in place and everything else survives verbatim")
|
@Test("A new value appends a fresh section that wins on read — existing lines survive verbatim")
|
||||||
func replacesInPlace() {
|
func appendsAndWins() {
|
||||||
let original = """
|
let original = """
|
||||||
[core]
|
[core]
|
||||||
\trepositoryformatversion = 0
|
\trepositoryformatversion = 0
|
||||||
@@ -1001,11 +1001,15 @@ struct GitIdentityWriteTests {
|
|||||||
|
|
||||||
#expect(written.contains("\tname = New Name"))
|
#expect(written.contains("\tname = New Name"))
|
||||||
#expect(written.contains("\temail = [email protected]"))
|
#expect(written.contains("\temail = [email protected]"))
|
||||||
#expect(!written.contains("Old Name"))
|
#expect(written.contains("\tname = Old Name"), "sets never edit existing sections — 06's append-only rule")
|
||||||
#expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives")
|
#expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives")
|
||||||
#expect(written.contains("[remote \"origin\"]"))
|
#expect(written.contains("[remote \"origin\"]"))
|
||||||
#expect(written.contains("\turl = [email protected]:board.git"))
|
#expect(written.contains("\turl = [email protected]:board.git"))
|
||||||
#expect(written.contains("\tbare = false"))
|
#expect(written.contains("\tbare = false"))
|
||||||
|
|
||||||
|
let read = GitConfigFile.identity(inConfigText: written)
|
||||||
|
#expect(read.name == "New Name", "the appended section is last, and reads take the last")
|
||||||
|
#expect(read.email == "[email protected]")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("An empty field clears its key, and clearing both removes the section entirely")
|
@Test("An empty field clears its key, and clearing both removes the section entirely")
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import Testing
|
|||||||
/// arguments rather than reading the machine, so the *shape* is provable on any machine — including
|
/// arguments rather than reading the machine, so the *shape* is provable on any machine — including
|
||||||
/// one whose account has no full name, which is the case the fallbacks exist for. And the config
|
/// one whose account has no full name, which is the case the fallbacks exist for. And the config
|
||||||
/// read is a parse over text, so the format's edges (comments, quoting, subsections, a `[user]`
|
/// read is a parse over text, so the format's edges (comments, quoting, subsections, a `[user]`
|
||||||
/// section that never appears) are pinned without a repository.
|
/// section that never appears) are pinned without a repository. The write side (`GitConfigFile
|
||||||
|
/// .applying`) is pinned the same way: a pure function over text, so every rule in 06's "Writes
|
||||||
|
/// append, reads take the last" paragraph is provable without a repository either.
|
||||||
|
|
||||||
@Suite("Git identity ▸ the derived default")
|
@Suite("Git identity ▸ the derived default")
|
||||||
struct GitIdentityDerivationTests {
|
struct GitIdentityDerivationTests {
|
||||||
@@ -181,3 +183,183 @@ struct GitConfigFileTests {
|
|||||||
#expect(identity.email == "[email protected]")
|
#expect(identity.email == "[email protected]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The write side** (06-history-undo.md ▸ Interaction with external writers, "Where the user's
|
||||||
|
/// git identity comes from"; the clear rule ruled 2026-08-06): a set is append-only — it never edits
|
||||||
|
/// or deletes an existing line, appending one new plain `[user]` section instead, even over an
|
||||||
|
/// already-populated file — and a clear is the one sanctioned in-place edit, deleting every matching
|
||||||
|
/// line in every plain `[user]` section and dropping any header left empty. Both halves resolve
|
||||||
|
/// against the current parse first, so a call that would change nothing is a true no-op.
|
||||||
|
@Suite("Git identity ▸ writing repo-local config")
|
||||||
|
struct GitConfigFileWriteTests {
|
||||||
|
|
||||||
|
@Test("A set never edits an existing line — it appends a new section, and the old line survives verbatim")
|
||||||
|
func setAppendsRatherThanEditing() {
|
||||||
|
// Weird indentation and an inline comment: exactly the shape a set must leave untouched.
|
||||||
|
let original = "[user]\n name = Old Name # keep me, weird spacing and all\n"
|
||||||
|
|
||||||
|
let written = GitConfigFile.applying(name: "New Name", email: "[email protected]", to: original)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
written.contains(" name = Old Name # keep me, weird spacing and all"),
|
||||||
|
"the original line survives byte-for-byte"
|
||||||
|
)
|
||||||
|
#expect(written.contains("\tname = New Name"), "the set lands in a freshly appended section")
|
||||||
|
#expect(written.contains("\temail = [email protected]"))
|
||||||
|
#expect(
|
||||||
|
written.components(separatedBy: "[user]").count - 1 == 2,
|
||||||
|
"a second `[user]` header was appended, not merged into the first"
|
||||||
|
)
|
||||||
|
|
||||||
|
let read = GitConfigFile.identity(inConfigText: written)
|
||||||
|
#expect(read.name == "New Name", "last-wins reading is what makes the appended value win")
|
||||||
|
#expect(read.email == "[email protected]")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Setting a key to its already-current value is a true no-op — byte-identical, no growth")
|
||||||
|
func settingTheCurrentValueIsANoOp() {
|
||||||
|
let text = "[user]\n\tname = Ada Lovelace\n\temail = [email protected]\n"
|
||||||
|
|
||||||
|
let written = GitConfigFile.applying(name: "Ada Lovelace", email: "[email protected]", to: text)
|
||||||
|
#expect(written == text)
|
||||||
|
|
||||||
|
// Whitespace around an unchanged value still resolves to the same target, so it is still a
|
||||||
|
// no-op — the comparison is on trimmed content, not on the caller's exact bytes.
|
||||||
|
let paddedTarget = GitConfigFile.applying(name: " Ada Lovelace ", email: " [email protected] ", to: text)
|
||||||
|
#expect(paddedTarget == text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Clearing an absent key returns byte-identical text")
|
||||||
|
func clearingAnAbsentKeyIsANoOp() {
|
||||||
|
let text = "[user]\n\tname = Ada\n"
|
||||||
|
|
||||||
|
let written = GitConfigFile.applying(name: "Ada", email: nil, to: text)
|
||||||
|
#expect(written == text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A clear deletes every occurrence across two plain `[user]` sections, and reads back nil")
|
||||||
|
func clearDeletesEveryOccurrence() {
|
||||||
|
let text = """
|
||||||
|
[user]
|
||||||
|
\temail = [email protected]
|
||||||
|
[core]
|
||||||
|
\tbare = false
|
||||||
|
[user]
|
||||||
|
\temail = [email protected]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
// `name` is already absent everywhere, so passing `nil` for it is a no-op; only `email` is
|
||||||
|
// genuine pending work, and it must be cleared from *both* plain sections, not just the last.
|
||||||
|
let written = GitConfigFile.applying(name: nil, email: "", to: text)
|
||||||
|
|
||||||
|
#expect(!written.contains("email"), "no occurrence survives, in either section")
|
||||||
|
#expect(written.contains("\tbare = false"), "an unrelated section is untouched")
|
||||||
|
#expect(GitConfigFile.identity(inConfigText: written).email == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Clearing both keys drops every emptied `[user]` header, but keeps one that still has signingkey")
|
||||||
|
func clearingDropsOnlyTrulyEmptyHeaders() {
|
||||||
|
let text = """
|
||||||
|
[user]
|
||||||
|
\tname = Ada
|
||||||
|
[user]
|
||||||
|
\temail = [email protected]
|
||||||
|
[user]
|
||||||
|
\tname = Ada C
|
||||||
|
\temail = [email protected]
|
||||||
|
\tsigningkey = ABC123
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
let written = GitConfigFile.applying(name: "", email: nil, to: text)
|
||||||
|
|
||||||
|
#expect(!written.contains("name ="), "no name line remains anywhere")
|
||||||
|
#expect(!written.contains("email ="), "no email line remains anywhere")
|
||||||
|
#expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives")
|
||||||
|
#expect(
|
||||||
|
written.components(separatedBy: "[user]").count - 1 == 1,
|
||||||
|
"the two now-empty headers are dropped; the section keeping signingkey keeps its header"
|
||||||
|
)
|
||||||
|
#expect(GitConfigFile.identity(inConfigText: written) == (nil, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A combined set-and-clear call clears in place, then appends the set section")
|
||||||
|
func combinedSetAndClear() {
|
||||||
|
let original = """
|
||||||
|
[user]
|
||||||
|
\tname = Old Name
|
||||||
|
\temail = [email protected]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
let written = GitConfigFile.applying(name: "New Name", email: "", to: original)
|
||||||
|
|
||||||
|
#expect(written.contains("\tname = Old Name"), "the set never deletes the line it is replacing")
|
||||||
|
#expect(written.contains("\tname = New Name"), "the set lands in an appended section")
|
||||||
|
#expect(!written.contains("email"), "the clear deletes the email line in place, nothing appended for it")
|
||||||
|
|
||||||
|
let read = GitConfigFile.identity(inConfigText: written)
|
||||||
|
#expect(read.name == "New Name")
|
||||||
|
#expect(read.email == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A `[user \"work\"]` subsection is untouched by a set or a clear, and never leaks into a read")
|
||||||
|
func subsectionsAreUntouchable() {
|
||||||
|
let original = """
|
||||||
|
[user "work"]
|
||||||
|
\tname = Work Ada
|
||||||
|
\temail = [email protected]
|
||||||
|
[user]
|
||||||
|
\tname = Home Ada
|
||||||
|
\temail = [email protected]
|
||||||
|
|
||||||
|
"""
|
||||||
|
#expect(GitConfigFile.identity(inConfigText: original).name == "Home Ada", "the subsection is not read")
|
||||||
|
|
||||||
|
let cleared = GitConfigFile.applying(name: "", email: "", to: original)
|
||||||
|
#expect(cleared.contains("[user \"work\""), "the subsection header survives")
|
||||||
|
#expect(cleared.contains("\tname = Work Ada"), "the subsection's own keys are untouched by a clear")
|
||||||
|
#expect(cleared.contains("\temail = [email protected]"))
|
||||||
|
#expect(!cleared.contains("[user]"), "the plain section is what a clear may empty out")
|
||||||
|
#expect(GitConfigFile.identity(inConfigText: cleared) == (nil, nil), "the subsection never leaks into a read")
|
||||||
|
|
||||||
|
let written = GitConfigFile.applying(name: "New Home Ada", email: "[email protected]", to: original)
|
||||||
|
#expect(written.contains("[user \"work\""), "the subsection header survives a set too")
|
||||||
|
#expect(written.contains("\tname = Work Ada"), "the subsection's own keys are untouched by a set")
|
||||||
|
#expect(written.contains("\tname = Home Ada"), "the old plain section survives verbatim — sets never edit")
|
||||||
|
let read = GitConfigFile.identity(inConfigText: written)
|
||||||
|
#expect(read.name == "New Home Ada", "the appended section wins by last-wins, never the subsection")
|
||||||
|
#expect(read.email == "[email protected]")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Writing into empty text creates just the new `[user]` section")
|
||||||
|
func writesIntoAnEmptyConfig() {
|
||||||
|
let written = GitConfigFile.applying(name: "Ada Lovelace", email: "[email protected]", to: "")
|
||||||
|
|
||||||
|
#expect(written == "[user]\n\tname = Ada Lovelace\n\temail = [email protected]\n")
|
||||||
|
let read = GitConfigFile.identity(inConfigText: written)
|
||||||
|
#expect(read.name == "Ada Lovelace")
|
||||||
|
#expect(read.email == "[email protected]")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Trailing-newline shape: a clear preserves it, a set's append normalizes it")
|
||||||
|
func trailingNewlineRoundTrip() {
|
||||||
|
// Clearing is a pure line deletion — it must not add a trailing newline that was never there.
|
||||||
|
let withoutTrailingNewline = "[user]\n\tname = Ada\n\temail = [email protected]"
|
||||||
|
let clearedNoTrailingNewline = GitConfigFile.applying(name: "Ada", email: nil, to: withoutTrailingNewline)
|
||||||
|
#expect(clearedNoTrailingNewline == "[user]\n\tname = Ada", "no trailing newline was introduced")
|
||||||
|
|
||||||
|
// ...and must not drop one that was.
|
||||||
|
let withTrailingNewline = "[user]\n\tname = Ada\n\temail = [email protected]\n[core]\n\tbare = false\n"
|
||||||
|
let clearedWithTrailingNewline = GitConfigFile.applying(name: "Ada", email: nil, to: withTrailingNewline)
|
||||||
|
#expect(clearedWithTrailingNewline.hasSuffix("\tbare = false\n"), "the file's own trailing newline survives")
|
||||||
|
|
||||||
|
// A set's append always lands the current code's shape (blank-line separator, one trailing
|
||||||
|
// newline) whether or not the original file ended in one.
|
||||||
|
let appendedNoTrailingNewline = GitConfigFile.applying(name: "Ada", email: "[email protected]", to: "[core]\n\tbare = false")
|
||||||
|
let appendedWithTrailingNewline = GitConfigFile.applying(name: "Ada", email: "[email protected]", to: "[core]\n\tbare = false\n")
|
||||||
|
#expect(appendedNoTrailingNewline == "[core]\n\tbare = false\n\n[user]\n\tname = Ada\n\temail = [email protected]\n")
|
||||||
|
#expect(appendedWithTrailingNewline == appendedNoTrailingNewline, "the trailing-newline state of the input doesn't change the appended shape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user