The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
343 lines
18 KiB
Swift
343 lines
18 KiB
Swift
import Foundation
|
|
|
|
// MARK: - GitIdentity
|
|
|
|
/// **Who the app's commits are authored by** (06-history-undo.md ▸ Interaction with external
|
|
/// writers ▸ "Where the user's git identity comes from").
|
|
///
|
|
/// Two sources, in the design's own order — and the order is git's own, which is the point:
|
|
///
|
|
/// 1. **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
|
|
/// identity fields write exactly that file: "the setting *is* the file, portable to any git
|
|
/// client, per-board by nature". Their home is the **board settings sheet** since the 2026-07-31
|
|
/// popover/sheet split (03-board-ui.md); they are hosted in the popover's git section until that
|
|
/// sheet is built, which changes nothing about this file.
|
|
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
|
|
/// `shortname@hostname` — git's own no-config fallback shape, zero ceremony."
|
|
///
|
|
/// What is deliberately *not* a source is `~/.gitconfig`: the app is sandboxed and cannot read it,
|
|
/// which 06 states as an honest limit rather than a bug. Nothing here consults libgit2's own config
|
|
/// ladder for the same reason — a global layer that is unreachable in the shipped app but readable
|
|
/// on a developer's machine would make the app's authorship depend on how it was launched.
|
|
///
|
|
/// The commits this type does *not* speak for are the synthetic ones: foreign changes commit as
|
|
/// `Lanework External <[email protected]>` and `modified-by`-stamped windows as
|
|
/// `<slug>@agents.lanework.invalid` (06). Those are the auto-commit card's, and they are pinned
|
|
/// strings rather than derivations — nothing about them belongs in a type about *the user's*
|
|
/// identity.
|
|
public struct GitIdentity: Sendable, Equatable {
|
|
|
|
public let name: String
|
|
public let email: String
|
|
|
|
public init(name: String, email: String) {
|
|
self.name = name
|
|
self.email = email
|
|
}
|
|
}
|
|
|
|
// MARK: - The derived default
|
|
|
|
public extension GitIdentity {
|
|
|
|
/// The derived default, as a **pure function of three strings** — so the shape 06 names can be
|
|
/// proven without asserting anything about the machine the tests run on.
|
|
///
|
|
/// `fullName` is the account's display name (`NSFullUserName()`), `accountName` its short name
|
|
/// (`NSUserName()`), `hostName` the machine's (`ProcessInfo.hostName`). Every one of them can
|
|
/// come back empty or shaped in a way git would reject, so each is defended:
|
|
///
|
|
/// - An empty full name falls back to the account name — git does the same when GECOS is blank,
|
|
/// and a commit authored by `"" <me@mac>` is a commit no client renders sensibly.
|
|
/// - The email's local part and host are sanitized to what an address may contain: a signature
|
|
/// with a space or an angle bracket in it is not merely ugly, libgit2 refuses it outright and
|
|
/// the commit fails.
|
|
/// - An empty host reads `localhost`, which is what a machine with no name is.
|
|
static func derived(fullName: String, accountName: String, hostName: String) -> GitIdentity {
|
|
let account = accountName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmedName = fullName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let name = trimmedName.isEmpty ? (account.isEmpty ? "Lanework" : account) : trimmedName
|
|
|
|
let localPart = addressComponent(account, fallback: "user")
|
|
// A trailing dot is legal in a fully-qualified name and useless in an address; `.local`
|
|
// hosts keep theirs, which is exactly what git's own fallback produces on a Mac.
|
|
let host = addressComponent(
|
|
hostName.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".")
|
|
? String(hostName.trimmingCharacters(in: .whitespacesAndNewlines).dropLast())
|
|
: hostName,
|
|
fallback: "localhost"
|
|
)
|
|
|
|
return GitIdentity(name: name, email: "\(localPart)@\(host)")
|
|
}
|
|
|
|
/// The derived default for *this* machine — the one impure call, kept to one line so everything
|
|
/// above it stays provable.
|
|
static func derivedDefault() -> GitIdentity {
|
|
derived(
|
|
fullName: NSFullUserName(),
|
|
accountName: NSUserName(),
|
|
hostName: ProcessInfo.processInfo.hostName
|
|
)
|
|
}
|
|
|
|
/// **The resolution 06 states**, per key rather than wholesale: a repo-local config naming only
|
|
/// `user.name` contributes exactly that and the email still derives — git resolves each key on
|
|
/// its own, and a half-configured repo is a real state (it is what a `git config user.email`
|
|
/// typo leaves behind).
|
|
static func resolve(repoLocal: (name: String?, email: String?), derived: GitIdentity) -> GitIdentity {
|
|
func configured(_ value: String?, or fallback: String) -> String {
|
|
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!trimmed.isEmpty else { return fallback }
|
|
return trimmed
|
|
}
|
|
return GitIdentity(
|
|
name: configured(repoLocal.name, or: derived.name),
|
|
email: configured(repoLocal.email, or: derived.email)
|
|
)
|
|
}
|
|
|
|
/// Characters an address part may carry, with everything else collapsed to `-`. Deliberately
|
|
/// conservative rather than RFC-complete: the input is a Mac account name and a Bonjour host
|
|
/// name, and the only job is that libgit2 accepts the signature and a git client renders it.
|
|
///
|
|
/// Shared with `CommitAttribution.agentIdentity(named:)` — a `modified-by` stamp is arbitrary
|
|
/// self-reported text and needs exactly this treatment to become an address local part
|
|
/// ("display name verbatim, email local part slugified", 06-history-undo.md). One slug rule for
|
|
/// both, so a name that is safe in a derived default cannot be unsafe in an agent's address.
|
|
static func addressComponent(_ raw: String, fallback: String) -> String {
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._"))
|
|
let mapped = String(
|
|
String.UnicodeScalarView(
|
|
raw.unicodeScalars.map { allowed.contains($0) ? $0 : Unicode.Scalar("-") }
|
|
)
|
|
)
|
|
let trimmed = mapped.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
|
|
return trimmed.isEmpty ? fallback : trimmed
|
|
}
|
|
}
|
|
|
|
// MARK: - Repo-local config
|
|
|
|
/// **The board's own `.git/config`, read as text** (06-history-undo.md: "repo-local `.git/config`
|
|
/// wins when present … readable in-sandbox because it lives under the board root").
|
|
///
|
|
/// Read by hand rather than through libgit2's config ladder, deliberately: `git_repository_config`
|
|
/// merges the repository, global and system layers, so a value read through it is not the answer to
|
|
/// "what does *this repository* say" — it is the answer to "what does this machine say", which is
|
|
/// the question the sandbox makes unanswerable and which 06 rules out of the identity story
|
|
/// entirely. Reading the file the design names gives the same answer in the shipped sandboxed app,
|
|
/// in a test, and on a developer's machine with a `~/.gitconfig` full of opinions.
|
|
///
|
|
/// The parse is tolerant by design: it is looking for two keys in one section of a format that
|
|
/// allows comments, indentation and quoting, and anything it fails to understand simply reads as
|
|
/// absent — which falls through to the derived default, the same place a missing file lands.
|
|
enum GitConfigFile {
|
|
|
|
/// `user.name` / `user.email` as the config file at `gitDirectory/config` states them; both
|
|
/// `nil` when the file does not exist, cannot be read, or names neither key.
|
|
static func identity(inGitDirectory gitDirectory: URL) -> (name: String?, email: String?) {
|
|
let configURL = gitDirectory.appendingPathComponent("config")
|
|
guard let text = try? String(contentsOf: configURL, encoding: .utf8) else { return (nil, nil) }
|
|
return identity(inConfigText: text)
|
|
}
|
|
|
|
/// The parse, over text — the pure half, and where the format's edges are decided.
|
|
///
|
|
/// **Reads take the last plain-section value** (06-history-undo.md ▸ Interaction with external
|
|
/// writers, blessed 2026-07-31): "the reader — like git itself — takes the last plain-section
|
|
/// value, which is exactly what an append produces."
|
|
///
|
|
/// *Plain* is load-bearing and is the whole of the subsection rule. `[user "work"]` is a different
|
|
/// key in git's own model — `user.work.name`, not `user.name` — so its values are not answers to
|
|
/// this question at all, and reading one would sign the user's commits with an identity they
|
|
/// filed under a name this app never asked about. Last-wins still holds inside the plain
|
|
/// sections: a later `[user]` overrides an earlier one, which is how an appended section wins
|
|
/// without the writer ever touching what came before it.
|
|
static func identity(inConfigText text: String) -> (name: String?, email: String?) {
|
|
var isPlainUserSection = false
|
|
var name: String?
|
|
var email: String?
|
|
|
|
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
|
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue }
|
|
|
|
if line.hasPrefix("[") {
|
|
let header = line.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("\"")
|
|
continue
|
|
}
|
|
|
|
guard isPlainUserSection, let separator = line.firstIndex(of: "=") else { continue }
|
|
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
|
|
let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
|
|
switch key {
|
|
case "name": name = value.nonEmpty
|
|
case "email": email = value.nonEmpty
|
|
default: continue
|
|
}
|
|
}
|
|
|
|
return (name, email)
|
|
}
|
|
|
|
// MARK: Writing
|
|
|
|
/// **The identity fields, landing in the file** (06-history-undo.md ▸ Interaction with external
|
|
/// writers: "The board settings sheet's identity 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"; the fields are popover-hosted until that sheet is built).
|
|
///
|
|
/// 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:)`, 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 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 {
|
|
if value.hasPrefix("\"") {
|
|
let body = value.dropFirst()
|
|
guard let closing = body.firstIndex(of: "\"") else { return String(body) }
|
|
return String(body[body.startIndex..<closing])
|
|
}
|
|
let uncommented = value.prefix { $0 != "#" && $0 != ";" }
|
|
return uncommented.trimmingCharacters(in: .whitespaces)
|
|
}
|
|
}
|
|
|
|
// MARK: - String conveniences
|
|
|
|
private extension String {
|
|
var nonEmpty: String? { isEmpty ? nil : self }
|
|
}
|