Files
lanework/Kanban/Git/GitIdentity.swift
T
rzen 3c07c26fda Build the auto-commit engine
Every settled change on a git-mode board commits, debounced 2s past
drag/typing churn, staged whole-root with .gitignore respected.
GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2
pin SwiftGitX resolves — importable, not duplicated) for
signature-capable commits; add-git's config materialization is gone,
identity resolves at commit time (repo-local config, else derived
default) per the 2026-07-31 ruling in 06. CommitAttribution
classifies per file off EchoLedger receipts: user identity on
app-mediated windows, Lanework External <[email protected]>
on foreign, the modified-by refinement (<slug>@agents.lanework
.invalid) when every foreign file agrees, heal-marked receipts split
into their own commit — window split foreign → heal → user.
Edit-session granularity: ~700ms saves stay uncommitted, staging
excludes open session folders (closure-resolved so mid-session moves
stage around the new location), session end nudges the debounce so
each session lands exactly one body commit. Flush-before-overwrite
gates on known-foreign windows and commits synchronously ahead of
the write; close/quit flush the pipeline via CloseFlushCoordinator's
committerFlush. index.lock backs off briefly then re-debounces
silently; clean tree no-ops; genuine failures ride the standing
history-suspension banner and retry next debounce. Abnormal repo
states (detached HEAD, merge/rebase/cherry-pick in progress) hold
the engine with a 15s re-check; unborn HEAD commits "Initial board
state" whole-tree; dirty tree at open catches up through the same
engine. Message seam (CommitMessageComposing) ships interim — the
semantic composer is the next card.

Discovery diffs HEAD against an in-memory index with rename
detection (git status alone never pairs a bare mv), and a failed
survey reads as "could not look", never "nothing changed".

46 new tests / 8 suites, all real repositories via bundled libgit2.
2240 tests / 383 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 14:10:55 -04:00

196 lines
10 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
/// 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 <[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.
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..<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)
}
/// 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 }
}