diff --git a/DESIGN/06-history-undo.md b/DESIGN/06-history-undo.md index 3f6173e..5d7194c 100644 --- a/DESIGN/06-history-undo.md +++ b/DESIGN/06-history-undo.md @@ -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 `** — 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 `@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. diff --git a/Kanban/Git/GitIdentity.swift b/Kanban/Git/GitIdentity.swift index ca23a25..cb88150 100644 --- a/Kanban/Git/GitIdentity.swift +++ b/Kanban/Git/GitIdentity.swift @@ -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.. [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, 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.. [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..