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:
@@ -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]")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user