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
153 lines
6.4 KiB
Swift
153 lines
6.4 KiB
Swift
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]")
|
|
}
|
|
}
|