Build HistoryStore — opt-in git and mode detection

The pro-m1 foundation card. SwiftGitX 0.4.0 (bundled libgit2, the
pathfinder's pin) joins the one target; new Kanban/Git/ holds
BoardGitMode (pure nearest-.git-wins detection, .git-as-file counts,
NSString ancestor walk), HistoryStore (@MainActor @Observable;
compose() is the tier gate — free tier gets no object, no detection,
no stat), GitRepository (scope-confined SwiftGitX handles: create =
init + HEAD forced to main + whole-tree "Initial board state" commit;
branch reads incl. unborn/detached; path-history ranks), GitIdentity
(derived default as a pure function + repo-local config reader — not
libgit2's merged ladder), and GitPathHistory (Mutex-guarded lazy
ranker). beginSession composes the git state beside the tier and
feeds BoardStore.makeIdentityHistoryRanker; git-mode loads pass the
git-backed IdentityHistoryRanker to BoardLoader. The popover's git
slot resolves a pure five-way matrix: free tier unchanged (absent /
BoardGitNote), Pro mode-aware — Add Git on mode none, honest prose on
repo-nested, read-only branch line on git. Provider binding
unchanged: both tiers still bind native until the undo/redo card.

42 new tests across 8 suites, all repositories built through bundled
libgit2; InertGitTests untouched and green. 2194 tests / 375 suites.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-31 13:18:07 -04:00
parent 9f8eebe23b
commit 189af238a1
15 changed files with 1943 additions and 17 deletions
+152
View File
@@ -0,0 +1,152 @@
import Foundation
import Testing
@testable import Kanban
/// **Where the app's commits get their author from** (06-history-undo.md Interaction with external
/// writers "Where the user's git identity comes from"): repo-local `.git/config` when it names
/// one, the derived `Full Name <shortname@hostname>` default when it doesn't.
///
/// Both halves are pure functions here on purpose. The derivation takes its three strings as
/// arguments rather than reading the machine, so the *shape* is provable on any machine including
/// one whose account has no full name, which is the case the fallbacks exist for. And the config
/// read is a parse over text, so the format's edges (comments, quoting, subsections, a `[user]`
/// section that never appears) are pinned without a repository.
@Suite("Git identity ▸ the derived default")
struct GitIdentityDerivationTests {
@Test("The shape is the account's full name plus shortname@hostname")
func theDerivedShape() {
let identity = GitIdentity.derived(fullName: "Ada Lovelace", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("An account with no full name falls back to its short name rather than committing as \"\"")
func anEmptyFullNameFallsBack() {
let identity = GitIdentity.derived(fullName: " ", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "ada")
#expect(identity.email == "[email protected]")
}
@Test("Characters an address may not carry are collapsed, not passed to libgit2")
func addressComponentsAreSanitized() {
// libgit2 refuses a signature carrying a space or an angle bracket outright the commit
// fails rather than looking odd so this is a correctness fallback, not cosmetics.
let identity = GitIdentity.derived(
fullName: "Ada Lovelace",
accountName: "ada lovelace",
hostName: "Ada's <Mac>.local"
)
#expect(!identity.email.contains(" "))
#expect(!identity.email.contains("<"))
#expect(!identity.email.contains(">"))
#expect(identity.email == "[email protected]")
}
@Test("A machine with no name reads localhost, and an account with none reads user")
func emptyComponentsHaveHonestFallbacks() {
let identity = GitIdentity.derived(fullName: "", accountName: "", hostName: "")
#expect(identity.name == "Lanework")
#expect(identity.email == "user@localhost")
}
@Test("A trailing dot on a fully-qualified host name is dropped")
func aTrailingDotIsDropped() {
let identity = GitIdentity.derived(fullName: "Ada", accountName: "ada", hostName: "host.example.com.")
#expect(identity.email == "[email protected]")
}
@Test("This machine's derived default is well-formed, whatever this machine is called")
func theMachineDefaultIsWellFormed() {
let identity = GitIdentity.derivedDefault()
#expect(!identity.name.isEmpty)
#expect(identity.email.contains("@"))
#expect(!identity.email.contains(" "))
}
}
@Suite("Git identity ▸ repo-local config wins")
struct GitConfigFileTests {
@Test("A `[user]` section supplies both halves")
func bothKeysAreRead() {
let text = """
[core]
\trepositoryformatversion = 0
[user]
\tname = Ada Lovelace
\temail = [email protected]
"""
let identity = GitConfigFile.identity(inConfigText: text)
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("Config wins over the derived default, key by key")
func resolutionPrefersConfigPerKey() {
let derived = GitIdentity(name: "Machine Owner", email: "[email protected]")
let both = GitIdentity.resolve(repoLocal: (name: "Ada", email: "[email protected]"), derived: derived)
#expect(both == GitIdentity(name: "Ada", email: "[email protected]"))
// Half-configured is a real state it is what a `git config user.email` typo leaves and
// git resolves each key on its own.
let nameOnly = GitIdentity.resolve(repoLocal: (name: "Ada", email: nil), derived: derived)
#expect(nameOnly == GitIdentity(name: "Ada", email: "[email protected]"))
let neither = GitIdentity.resolve(repoLocal: (name: nil, email: " "), derived: derived)
#expect(neither == derived, "a blank value is not a value")
}
@Test("Comments, quoting and subsections are read the way git reads them")
func theParseHandlesTheFormatsEdges() {
let text = """
# a comment
; another
[user "work"]
\tname = Wrong Section
[user]
\tname = "Ada # Lovelace"
\temail = [email protected] # trailing comment
"""
let identity = GitConfigFile.identity(inConfigText: text)
// `[user "work"]` is a subsection but still the `user` section git reads its keys as
// `user.name` under a subsection name, and this parse deliberately takes the last value it
// meets rather than inventing subsection scoping for a file that has none in practice.
#expect(identity.name == "Ada # Lovelace", "a `#` inside quotes is content")
#expect(identity.email == "[email protected]", "an unquoted trailing comment is not")
}
@Test("A config with no `[user]` section, or no config at all, names nobody")
func absentConfigNamesNobody() throws {
let empty = GitConfigFile.identity(inConfigText: "[core]\n\tbare = false\n")
#expect(empty.name == nil)
#expect(empty.email == nil)
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let missing = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(missing.name == nil)
#expect(missing.email == nil)
}
@Test("The file on disk is what is read — the board root's own `.git/config`")
func theFileIsRead() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.file(".git/config", Data("[user]\n\tname = Ada\n\temail = [email protected]\n".utf8))
let identity = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(identity.name == "Ada")
#expect(identity.email == "[email protected]")
}
}