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 ` and `modified-by`-stamped windows as /// `@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 `"" ` 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.. 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.. 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.. String { if value.hasPrefix("\"") { let body = value.dropFirst() guard let closing = body.firstIndex(of: "\"") else { return String(body) } return String(body[body.startIndex..