Build branch switching and the popover git surface
GitBranchSwitcher holds 06's sequence as one object: settle editors
explicitly (SessionSettleGate — Save All applies raw buffers with
validation and a refused buffer cancels the whole switch; Discard
reverts buffers AND reconciles the session folders against HEAD;
never silent), flush the pending auto-commit, stamp intent in the
per-board registry, bracketed safe checkout (git_checkout_tree
GIT_CHECKOUT_SAFE + set_head — no path passes FORCE, abort
included), one reload via the async wholesale bracket (failed final
reload engages the existing read-only lock), reseed undo/redo from
the new HEAD with redo empty, clear the stamp. Create-and-switch
keeps the full sequence — the tree-cannot-change proof fails under
concurrent writers. Lock contention shows the 02 in-progress row's
waiting state ("waiting for another writer's git lock"), bounded at
30s then failing cleanly naming the lock path.
GitOperationStamp + GitOperationRecovery: the own-leftovers rule as
a pure conjunction — pause state AND matching stamp = the app's own
interrupted operation, aborted to the pre-operation state with a
banner, stamp cleared on success only; either alone defers to the
pause-and-defer stance. Checked where the committer starts.
BoardGitControls replaces the read-only branch line: branch picker,
inline create-and-switch, the abnormal-state pause note in 06's own
words with controls dimmed, and commit-identity fields that read and
write repo-local .git/config (derived default as placeholder, never
value; unfocused resync, focused keystrokes kept; 2s poll while
visible — .git is watcher-filtered by design).
Also fixes a shipped bug from the undo card: plan(reconciling:)
matched card ids as path prefixes, so the reconcile branch was inert
on every board (<lane>/<card> never matches a bare id) — a session
file the restore diff couldn't name (attachment, comment, draft)
survived Discard and landed in the next flush's commit. One shared
component-exact folder-name resolver now serves both Discard paths;
noteDiscarded takes cardFolderName; regression test verified failing
against the pre-fix code.
41 branch tests + the regression; 2374 tests / 409 suites green;
InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -175,6 +175,142 @@ enum GitConfigFile {
|
||||
return (name, email)
|
||||
}
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
/// **The popover's identity fields, landing in the file** (06-history-undo.md ▸ Interaction with
|
||||
/// external writers: "The board popover's git section exposes name/email fields that **write that
|
||||
/// repo-local config** — the setting *is* the file, portable to any git client, per-board by
|
||||
/// nature").
|
||||
///
|
||||
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
|
||||
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
|
||||
/// never written into repo config", because a value the app wrote there would outrank the user's
|
||||
/// 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).
|
||||
static func writeIdentity(
|
||||
name: String?,
|
||||
email: String?,
|
||||
inGitDirectory gitDirectory: URL
|
||||
) throws {
|
||||
let configURL = gitDirectory.appendingPathComponent("config")
|
||||
let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
|
||||
let updated = applying(name: name, email: email, to: existing)
|
||||
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
|
||||
/// half a test needs.
|
||||
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)]
|
||||
|
||||
// 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:)`, deliberately takes the last matching value it
|
||||
/// meets whichever section it is in — its own recorded call. The two agree in practice for
|
||||
/// every file this writer has touched, because a plain section it *adds* goes at the end, so
|
||||
/// its keys are the last ones the reader meets.)
|
||||
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 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 }
|
||||
}
|
||||
|
||||
// Name before email, always — a file this app wrote reads the same whichever field was
|
||||
// filled first.
|
||||
let additions = ["name", "email"].compactMap { key -> String? in
|
||||
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("")
|
||||
}
|
||||
}
|
||||
|
||||
return removingEmptyUserSection(from: output).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 }
|
||||
|
||||
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
|
||||
}
|
||||
var kept = lines
|
||||
kept.removeSubrange(header..<end)
|
||||
return kept
|
||||
}
|
||||
|
||||
/// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside
|
||||
/// quotes is content — git's own rule, and the one place a naive strip would corrupt a name.
|
||||
private static func unquoted(_ value: String) -> String {
|
||||
|
||||
Reference in New Issue
Block a user