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 /// popover's name/email fields (a later card) write exactly that file: "the setting *is* the /// file, portable to any git client, per-board by nature". /// 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. static func identity(inConfigText text: String) -> (name: String?, email: String?) { var section: String? 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("[") { // `[user]`, and `[user "work"]` — a subsection is somebody else's scope, so the // header's first token is what names the section. let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" }) section = header .split(separator: " ", maxSplits: 1) .first .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } continue } guard section == "user", let separator = line.firstIndex(of: "=") else { continue } let key = line[line.startIndex.. String { if value.hasPrefix("\"") { let body = value.dropFirst() guard let closing = body.firstIndex(of: "\"") else { return String(body) } return String(body[body.startIndex..