An unopenable repository fails loudly — the standing row, the paused surface, the honest heal
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -732,6 +732,66 @@ struct AutoCommitContentionTests {
|
||||
#expect(committer.commitCount == 0)
|
||||
}
|
||||
|
||||
/// **The corrupt-`.git` loud failure's engine half** (06-history-undo.md ▸ Rules, ruled
|
||||
/// 2026-07-31): "the board itself loads and edits normally — files are the board — but the
|
||||
/// failure is loud … with the whole git surface paused", and "the banner clears when a later
|
||||
/// open or reload finds the repo readable".
|
||||
@Test("An unreadable repository holds the engine, and heals when it opens again")
|
||||
func anUnreadableRepositoryHolds() async throws {
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
var transitions: [Bool] = []
|
||||
committer.reportRepositoryUnreadable = { transitions.append($0) }
|
||||
|
||||
// What a half-copied or half-deleted `.git` looks like to libgit2: the layout no longer
|
||||
// validates, so the repository will not open at all. Reversible, which is what makes the
|
||||
// heal half of this test the real thing rather than a second fixture.
|
||||
let head = fixture.root.appendingPathComponent(".git/HEAD")
|
||||
let savedHead = try Data(contentsOf: head)
|
||||
try FileManager.default.removeItem(at: head)
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
await committer.flushNow()
|
||||
|
||||
#expect(committer.pause == .unreadable)
|
||||
#expect(committer.commitCount == 0, "nothing is attempted against a repository the app can't open")
|
||||
#expect(committer.lastFailure == nil, "a pause is not a failure")
|
||||
#expect(transitions == [true], "the banner is raised once, on the transition")
|
||||
|
||||
// "Edits keep landing on disk — files are the board — and commit as one settled batch when
|
||||
// the state clears." Mid-session, the re-read that notices is the paused engine's own.
|
||||
try fixture.item("\(Ident.lane1)/card-3", plain(order: "3072", title: "Third"))
|
||||
try savedHead.write(to: head)
|
||||
await committer.flushNow()
|
||||
|
||||
#expect(committer.pause == nil)
|
||||
#expect(committer.commitCount == 1, "one settled batch, exactly as any other pause")
|
||||
#expect(transitions == [true, false], "and the banner heals — it is a condition, not an event")
|
||||
#expect(isClean(at: fixture.root))
|
||||
}
|
||||
|
||||
@Test("The popover's own re-read learns the state without attempting anything")
|
||||
func refreshPauseLearnsTheUnreadableRepository() async throws {
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
let head = fixture.root.appendingPathComponent(".git/HEAD")
|
||||
let savedHead = try Data(contentsOf: head)
|
||||
try FileManager.default.removeItem(at: head)
|
||||
|
||||
await committer.refreshPause()
|
||||
#expect(committer.pause == .unreadable)
|
||||
#expect(committer.commitCount == 0)
|
||||
|
||||
try savedHead.write(to: head)
|
||||
await committer.refreshPause()
|
||||
#expect(committer.pause == nil)
|
||||
}
|
||||
|
||||
@Test("An unborn HEAD is normal — the first settled change commits the whole tree")
|
||||
func anUnbornHeadIsNormal() async throws {
|
||||
let fixture = try makeBoard()
|
||||
|
||||
@@ -103,16 +103,20 @@ struct BannerCenterOrderingTests {
|
||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||
operations: [operation],
|
||||
signposts: [signpost],
|
||||
gitFailures: [restore]
|
||||
gitFailures: [restore],
|
||||
repositoryUnreadable: true
|
||||
)
|
||||
|
||||
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures, both shapes >
|
||||
// loss rows > commit and attachment failures > passive info rows. The two info classes
|
||||
// sit at opposite ends of the strip.
|
||||
// in-progress (pinned) > read-only lock > reload breakage > **the unreadable repository** >
|
||||
// one-shot failures, both shapes > loss rows > commit and attachment failures > passive info
|
||||
// rows. The two info classes sit at opposite ends of the strip, and the breakage class holds
|
||||
// two rows now (06-history-undo.md ▸ Rules, ruled 2026-07-31): the reload breakage first,
|
||||
// because it is the one saying the board on screen is not the board on disk.
|
||||
#expect(rows.map(\.id) == [
|
||||
"operation:\(operation.id.uuidString)",
|
||||
"read-only-lock",
|
||||
"reload-breakage",
|
||||
"repository-unreadable",
|
||||
"git-failure:\(restore.id.uuidString)",
|
||||
"one-shot:\(move.id.uuidString)",
|
||||
"loss:\(loss.id.uuidString)",
|
||||
@@ -120,11 +124,46 @@ struct BannerCenterOrderingTests {
|
||||
"one-shot:\(attachment.id.uuidString)",
|
||||
"signpost:\(signpost.id.uuidString)",
|
||||
])
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .warning, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false],
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .error, .warning, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false, false],
|
||||
"a spinner may never hide behind '+N more' — nothing else is pinned")
|
||||
}
|
||||
|
||||
/// **The corrupt-`.git` loud failure's row** (06-history-undo.md ▸ Rules, ruled 2026-07-31) —
|
||||
/// it stands with the breakage class and above every one-shot, which is what "breakage-class"
|
||||
/// buys it: a failed move posted a second ago never pushes it down the strip.
|
||||
@Test("The unreadable repository outranks every failure, and only the breakage class outranks it")
|
||||
func theUnreadableRepositoryStandsInTheBreakageClass() {
|
||||
let move = OneShotBanner(error: error(.move(title: "Fix login")))
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [move],
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: [],
|
||||
repositoryUnreadable: true
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id) == ["repository-unreadable", "one-shot:\(move.id.uuidString)"])
|
||||
#expect(rows.first?.tone == .error, "the ruling's word is breakage, and breakage is an error")
|
||||
}
|
||||
|
||||
@Test("A readable repository contributes no row at all")
|
||||
func aReadableRepositoryIsSilent() {
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [],
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: [],
|
||||
repositoryUnreadable: false
|
||||
)
|
||||
|
||||
#expect(rows.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Both failure shapes share one rank, interleaved by recency")
|
||||
func theFailureRankHoldsBothShapes() {
|
||||
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
|
||||
@@ -432,6 +471,51 @@ struct BannerCenterLifecycleTests {
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
/// **The corrupt-`.git` loud failure** (06-history-undo.md ▸ Rules, ruled 2026-07-31): the row
|
||||
/// is raised at detection, stands with no dismiss, and *heals* — "the banner clears when a later
|
||||
/// open or reload finds the repo readable".
|
||||
@Test("The unreadable repository is a standing condition that heals, never a dismissable row")
|
||||
func theUnreadableRepositoryIsAHealingCondition() throws {
|
||||
let center = BannerCenter()
|
||||
#expect(!center.isRepositoryUnreadable)
|
||||
|
||||
center.raiseRepositoryUnreadable()
|
||||
#expect(center.isRepositoryUnreadable)
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
|
||||
repositoryUnreadable: center.isRepositoryUnreadable
|
||||
)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .error)
|
||||
#expect(rows[0].dismissID == nil, "a condition is never dismissed while it is still true")
|
||||
|
||||
// 06's own sentence, verbatim — the three clauses being what is wrong, what it costs, and
|
||||
// the promise that makes waiting safe.
|
||||
#expect(rows[0].headline
|
||||
== "This board's git repository can't be read — history is paused; Lanework leaves the repository untouched")
|
||||
#expect(rows[0].headline == BannerCenter.repositoryUnreadableMessage)
|
||||
|
||||
center.clearRepositoryUnreadable()
|
||||
#expect(!center.isRepositoryUnreadable)
|
||||
#expect(BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
|
||||
repositoryUnreadable: center.isRepositoryUnreadable
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Raising and clearing are idempotent — a re-read that confirms the condition changes nothing")
|
||||
func raisingTheUnreadableRepositoryIsIdempotent() {
|
||||
let center = BannerCenter()
|
||||
center.raiseRepositoryUnreadable()
|
||||
center.raiseRepositoryUnreadable()
|
||||
#expect(center.isRepositoryUnreadable)
|
||||
|
||||
center.clearRepositoryUnreadable()
|
||||
center.clearRepositoryUnreadable()
|
||||
#expect(!center.isRepositoryUnreadable)
|
||||
}
|
||||
|
||||
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
|
||||
func resuspendingKeepsTheClock() throws {
|
||||
let center = BannerCenter()
|
||||
@@ -604,11 +688,13 @@ struct BannerRowControlsTests {
|
||||
let rows: [BannerRow] = [
|
||||
.readOnlyLock(.vanishedRoot),
|
||||
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
|
||||
.repositoryUnreadable,
|
||||
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
||||
]
|
||||
|
||||
for row in rows {
|
||||
#expect(row.controls.isEmpty, "\(row.id) is a condition — it heals, it is not waved away")
|
||||
#expect(row.dismissID == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,4 +1023,33 @@ struct BannerCenterStoreTests {
|
||||
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
||||
])
|
||||
}
|
||||
|
||||
/// **The row the git state raises, through the store** (06-history-undo.md ▸ Rules, the
|
||||
/// corrupt-`.git` loud failure, ruled 2026-07-31) — raised and healed by
|
||||
/// `noteRepositoryUnreadable(_:)`, which is the seam `AppModel.beginSession` wires the
|
||||
/// committer's pause transitions to, and **announced** both ways per 10-accessibility.md.
|
||||
@Test("The unreadable repository stands on the strip and is spoken when it appears and clears")
|
||||
func theUnreadableRepositoryRowIsRaisedAndSpoken() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
var spoken: [String] = []
|
||||
store.announce = { if let line = $0 { spoken.append(line) } }
|
||||
|
||||
store.noteRepositoryUnreadable(true)
|
||||
|
||||
#expect(store.bannerRows.map(\.id) == ["repository-unreadable"])
|
||||
#expect(store.bannerRows[0].headline == BannerCenter.repositoryUnreadableMessage)
|
||||
#expect(spoken == ["Error: \(BannerCenter.repositoryUnreadableMessage)"],
|
||||
"a standing banner is announced when it appears — the row's own sentence, tone first")
|
||||
|
||||
// The 15 s re-read confirming what is already standing must not say it again.
|
||||
store.noteRepositoryUnreadable(true)
|
||||
#expect(spoken.count == 1)
|
||||
|
||||
store.noteRepositoryUnreadable(false)
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
#expect(spoken.last == "History is recording again")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,50 @@ struct BoardAnnouncerSpeechTests {
|
||||
)
|
||||
}
|
||||
|
||||
/// **The corrupt-`.git` loud failure, spoken** (06-history-undo.md ▸ Rules, ruled 2026-07-31:
|
||||
/// "announced per 10-accessibility.md"). It ranks last of the raised conditions, matching the
|
||||
/// strip's own precedence: the two above it describe the board's files, this one the history
|
||||
/// over them.
|
||||
@Test("The unreadable repository announces on arrival, under the conditions about the files")
|
||||
func raisedRepositoryUnreadable() {
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.repositoryUnreadableAfter = true
|
||||
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== AccessibilityPhrases.bannerLabel(
|
||||
tone: .error,
|
||||
headline: BannerCenter.repositoryUnreadableMessage
|
||||
)
|
||||
)
|
||||
|
||||
// A breakage standing beside it leads: the board on screen not being the board on disk is
|
||||
// the more consequential of the two.
|
||||
facts.breakageAfter = breakage()
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts)
|
||||
== AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage()))
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A repository that heals is announced too, as the regained capability")
|
||||
func clearedRepositoryUnreadable() {
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.repositoryUnreadableBefore = true
|
||||
facts.repositoryUnreadableAfter = false
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "History is recording again")
|
||||
}
|
||||
|
||||
@Test("A standing unreadable repository is not repeated on every re-read")
|
||||
func standingRepositoryUnreadableIsNotRepeated() {
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.repositoryUnreadableBefore = true
|
||||
facts.repositoryUnreadableAfter = true
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil, "the 15 s re-read confirms; it does not narrate")
|
||||
}
|
||||
|
||||
@Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
|
||||
func clearedLock() {
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
|
||||
@@ -78,6 +78,22 @@ struct BoardGitSectionTests {
|
||||
#expect(section != .noRepository)
|
||||
}
|
||||
|
||||
/// **A board whose repository will not open keeps the git section it has** (06 ▸ Rules, the
|
||||
/// corrupt-`.git` loud failure, ruled 2026-07-31) — the *branch* section, held and explaining
|
||||
/// itself, never the mode-none posture that would imply a board with no history to have.
|
||||
///
|
||||
/// The section case is deliberately blind to readability: what changes on such a board is what
|
||||
/// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation
|
||||
/// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The
|
||||
/// posture matrix stays a function of the tier and the mode alone.
|
||||
@Test("An unreadable repository is still the branch section — never the no-repository posture")
|
||||
func anUnreadableRepositoryKeepsTheBranchSection() {
|
||||
let section = BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true)
|
||||
|
||||
#expect(section == .branch)
|
||||
#expect(section != .noRepository, "the board has a repository; it is unreadable, not absent")
|
||||
}
|
||||
|
||||
@Test("Every posture is reachable, and none of them is two postures")
|
||||
func theMatrixIsTotal() {
|
||||
let resolved = Set(
|
||||
|
||||
@@ -45,6 +45,19 @@ struct BoardSettingsSectionTests {
|
||||
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == [])
|
||||
}
|
||||
|
||||
@Test("Pro, git mode with an unreadable repository: nothing setup-shaped applies either")
|
||||
func anUnreadableRepositoryHoldsNothing() {
|
||||
// **The corrupt-`.git` loud failure** (06 ▸ Rules, ruled 2026-07-31): both sections here are
|
||||
// *writes* to a repository — a branch created in it, an identity written into its config —
|
||||
// and there is no repository the app can open to write either into. Repo-nested's emptiness,
|
||||
// reached one step further along.
|
||||
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true) == [])
|
||||
|
||||
// …and the mode is still `git` throughout: this is emptiness *within* git mode, never the
|
||||
// fall to mode none that would let add-git be offered against an existing `.git`.
|
||||
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity])
|
||||
}
|
||||
|
||||
@Test("The sections carry the headers VoiceOver navigates by")
|
||||
func headersAreNamed() {
|
||||
// 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver
|
||||
@@ -77,6 +90,11 @@ struct BoardSettingsAvailabilityTests {
|
||||
// denied ancestor check is never distinguishable from a repository actually being there.
|
||||
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable))
|
||||
|
||||
// **Pro, git mode over a repository that will not open**: no door either, so neither the
|
||||
// popover's Board Settings… row nor the menu command offers a surface with nothing on it
|
||||
// (06 ▸ Rules, the corrupt-`.git` loud failure).
|
||||
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true))
|
||||
|
||||
// **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and
|
||||
// `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is
|
||||
// swept for completeness rather than because it can vary.
|
||||
|
||||
@@ -583,6 +583,37 @@ struct BranchSwitchSequenceTests {
|
||||
#expect(GitRepository.branchName(at: fixture.root) == "main")
|
||||
}
|
||||
|
||||
/// The same refusal through the **real** pause rather than an injected one: an unreadable
|
||||
/// repository is a `GitRepositoryPause` like any other, so the switch's existing gate closes on
|
||||
/// it with nothing added here (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure, ruled
|
||||
/// 2026-07-31: "the whole git surface paused").
|
||||
@Test("A switch refuses against a repository the app cannot open, and changes nothing")
|
||||
@MainActor
|
||||
func anUnreadableRepositoryRefusesTheSwitch() async throws {
|
||||
let (fixture, git) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let switcher = try makeSwitcher(git)
|
||||
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
|
||||
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
|
||||
|
||||
var asked = 0
|
||||
switcher.settleSessions = { asked += 1; return .proceed }
|
||||
|
||||
let head = fixture.root.appendingPathComponent(".git/HEAD")
|
||||
let savedHead = try Data(contentsOf: head)
|
||||
try FileManager.default.removeItem(at: head)
|
||||
await git.committer?.refreshPause()
|
||||
#expect(git.isRepositoryUnreadable)
|
||||
|
||||
#expect(await switcher.switchTo("redesign") == false)
|
||||
#expect(asked == 0, "no modal, no stamp, no checkout — nothing is attempted at all")
|
||||
#expect(switcher.lastFailure == nil, "a pause is not a failure; the standing banner is the message")
|
||||
|
||||
// The repository is exactly as it was found, which is the promise the row makes.
|
||||
try savedHead.write(to: head)
|
||||
#expect(GitRepository.branchName(at: fixture.root) == "main")
|
||||
}
|
||||
|
||||
/// "Contention outlasting the brief retry surfaces as a *waiting* state in the operation's
|
||||
/// in-progress banner row … a wait that persists implausibly long names the lock path — never an
|
||||
/// error dialog, never a hammer."
|
||||
@@ -946,6 +977,45 @@ struct BoardGitBranchSurfaceTests {
|
||||
).controlsEnabled)
|
||||
}
|
||||
|
||||
/// **The corrupt-`.git` loud failure at this one control** (06-history-undo.md ▸ Rules, ruled
|
||||
/// 2026-07-31: "Never a silent placeholder discovered only in the popover"). The bug this pins
|
||||
/// against is the shipped one: `GitRepository.branchName` answers `nil` on a repository it cannot
|
||||
/// open, and the placeholder meant "still reading" — forever.
|
||||
@Test("An unreadable repository reads as broken, never as still loading")
|
||||
func anUnreadableSurface() {
|
||||
let surface = BoardGitBranchSurface.resolve(
|
||||
branch: nil,
|
||||
pause: .unreadable,
|
||||
isSwitching: false,
|
||||
isWritable: true
|
||||
)
|
||||
|
||||
#expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel)
|
||||
#expect(surface.branchLabel != BoardGitBranchSurface.placeholder)
|
||||
#expect(!surface.isReadingBranch, "there is nothing in flight to be waiting for")
|
||||
#expect(surface.isRepositoryUnreadable)
|
||||
#expect(!surface.controlsEnabled, "the whole git surface is paused")
|
||||
#expect(surface.accessibilityLabel == "Branch unavailable")
|
||||
|
||||
// The section's own sentence — a sibling of the popover's nested and unverifiable notes, and
|
||||
// not the pause note, whose second line would be advice about an operation nobody started.
|
||||
#expect(BoardGitBranchSurface.unreadableNote
|
||||
== "Lanework can't read this board's git repository, so history is paused; the repository is left untouched.")
|
||||
}
|
||||
|
||||
@Test("A branch name read before the repository broke does not survive the pause")
|
||||
func anUnreadableSurfaceDropsAStaleBranch() {
|
||||
let surface = BoardGitBranchSurface.resolve(
|
||||
branch: "main",
|
||||
pause: .unreadable,
|
||||
isSwitching: false,
|
||||
isWritable: true
|
||||
)
|
||||
|
||||
#expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel,
|
||||
"the line must not go on naming a branch nothing can read")
|
||||
}
|
||||
|
||||
@Test("Before the first read the line is a placeholder, not a guess at a branch name")
|
||||
func theReadingSurface() {
|
||||
let surface = BoardGitBranchSurface.resolve(
|
||||
|
||||
@@ -68,6 +68,37 @@ private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
|
||||
return entries.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
/// **A `.git` file aimed at nothing** — the worktree/submodule pointer shape (`gitdir: …`), which
|
||||
/// detection reads as a repository (presence is presence, 06 ▸ Rules ▸ Detection) and libgit2 cannot
|
||||
/// open, because the directory it names is not there.
|
||||
private func plantDanglingGitPointer(in fixture: WriterFixture) throws {
|
||||
let target = fixture.root.appendingPathComponent("nowhere/.git/worktrees/board").path
|
||||
try fixture.file(".git", Data("gitdir: \(target)\n".utf8))
|
||||
}
|
||||
|
||||
/// **A SHA-256 repository, by hand** — the layout libgit2 validates, plus the two config keys
|
||||
/// `git init --object-format=sha256` writes (06 ▸ Repository hygiene: "SHA-256 repositories are
|
||||
/// unsupported, safely … an adopted SHA-256 repo the engine cannot open takes the corrupt-repo
|
||||
/// loud-failure path").
|
||||
///
|
||||
/// Built by hand rather than by `git init --object-format=sha256` for the file's standing reason:
|
||||
/// there is no `/usr/bin/git` in this feature's promise, so there is none in its tests. What makes
|
||||
/// the fixture honest is that nothing here is a mock — the bytes are the ones git writes, and the
|
||||
/// refusal is libgit2's own.
|
||||
private func plantSHA256Repository(in fixture: WriterFixture) throws {
|
||||
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
|
||||
try fixture.file(".git/objects/info/.keep", Data())
|
||||
try fixture.file(".git/refs/heads/.keep", Data())
|
||||
try fixture.file(".git/config", Data("""
|
||||
[core]
|
||||
\trepositoryformatversion = 1
|
||||
\tbare = false
|
||||
[extensions]
|
||||
\tobjectformat = sha256
|
||||
|
||||
""".utf8))
|
||||
}
|
||||
|
||||
// MARK: - Composition
|
||||
|
||||
@MainActor
|
||||
@@ -157,6 +188,141 @@ struct HistoryStoreCompositionTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The unreadable repository
|
||||
|
||||
/// **A `.git` that isn't a valid repository still reads as git mode — and fails loudly**
|
||||
/// (06-history-undo.md ▸ Rules, ruled 2026-07-31).
|
||||
///
|
||||
/// The probe is `GitRepository.canOpen(at:)` — the same `Repository.open` every read in that file
|
||||
/// makes — run at composition, seeding the committer's pause so the whole git surface is held from
|
||||
/// the first moment rather than from the first debounce. Every fixture here is a real shape from the
|
||||
/// wild: a half-made `.git`, a worktree pointer aimed at nothing, and a SHA-256 repository this
|
||||
/// engine has no support for.
|
||||
@MainActor
|
||||
@Suite("HistoryStore ▸ the unreadable repository")
|
||||
struct HistoryStoreUnreadableRepositoryTests {
|
||||
|
||||
@Test("A repository that opens reads readable, and holds nothing")
|
||||
func aValidRepositoryIsReadable() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await first.addGit())
|
||||
|
||||
// The next open, which is where the probe actually runs.
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .git)
|
||||
#expect(!git.isRepositoryUnreadable)
|
||||
#expect(git.committer?.pause == nil)
|
||||
#expect(GitRepository.canOpen(at: fixture.root))
|
||||
}
|
||||
|
||||
@Test("A corrupt `.git` stays git mode, reads unreadable, and holds the surface from the first moment")
|
||||
func aCorruptGitDirectoryIsUnreadable() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
// A `.git` with nothing in it but a plausible HEAD: enough for detection, which asks the
|
||||
// filesystem one question, and not a repository at all to libgit2.
|
||||
try plantGitDirectory(in: fixture)
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
|
||||
// **Never a fall to mode none** — "detection is presence-shaped … a corrupt or unopenable
|
||||
// repo never falls to mode none", which is what keeps add-git from ever being offered
|
||||
// against an existing `.git` ("init into a repairable repo is exactly the never-mutate
|
||||
// hazard").
|
||||
#expect(git.mode == .git)
|
||||
#expect(git.isRepositoryUnreadable)
|
||||
#expect(!GitRepository.canOpen(at: fixture.root))
|
||||
|
||||
// The pause is seeded at *detection*, before anything has been attempted: the surface is
|
||||
// held and the banner is raised at the open rather than a debounce later.
|
||||
#expect(git.committer?.pause == .unreadable)
|
||||
#expect(git.committer?.lastFailure == nil, "a pause is not a failure")
|
||||
|
||||
// And the one operation that could make it worse is refused, whatever the mode read.
|
||||
#expect(await git.addGit() == false)
|
||||
}
|
||||
|
||||
@Test("A worktree pointer aimed at nothing reads unreadable — the file shape, not just the directory one")
|
||||
func aDanglingPointerIsUnreadable() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantDanglingGitPointer(in: fixture)
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence")
|
||||
#expect(git.isRepositoryUnreadable)
|
||||
}
|
||||
|
||||
@Test("A SHA-256 repository takes the same path, by construction")
|
||||
func aSHA256RepositoryIsUnreadable() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantSHA256Repository(in: fixture)
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .git)
|
||||
// 06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the
|
||||
// corrupt-repo loud-failure path — never a silent fall to mode-none".
|
||||
#expect(git.isRepositoryUnreadable)
|
||||
}
|
||||
|
||||
@Test("Probing an unreadable repository touches nothing")
|
||||
func theProbeIsARead() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantGitDirectory(in: fixture)
|
||||
let before = try snapshotGitDirectory(fixture.root)
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.isRepositoryUnreadable)
|
||||
|
||||
// "Lanework leaves the repository untouched" — the same bytes and the same mtimes, on the
|
||||
// one path where a repair instinct would be most tempting.
|
||||
#expect(try snapshotGitDirectory(fixture.root) == before)
|
||||
}
|
||||
|
||||
@Test("The identity write is refused against a repository the app cannot open")
|
||||
func identityWritesAreRefused() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantGitDirectory(in: fixture)
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
await git.writeIdentity(name: "Ada", email: "[email protected]")
|
||||
|
||||
#expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open")
|
||||
#expect(git.identityFailure == nil, "and nothing was attempted, so there is nothing to report")
|
||||
}
|
||||
|
||||
/// The seam every git operation consults before it runs (`GitCommitOperation.reading`), asked
|
||||
/// directly: one word is what holds the auto-commit flush, skips housekeeping, disables Undo/Redo
|
||||
/// and the branch controls, and defers the interrupted-operation recovery.
|
||||
@Test("The repository reading reports the pause every operation gates on")
|
||||
func theReadingReportsThePause() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantGitDirectory(in: fixture)
|
||||
|
||||
let reading = GitCommitOperation.reading(at: fixture.root)
|
||||
#expect(reading.pause == .unreadable)
|
||||
#expect(!reading.isUnborn)
|
||||
#expect(!reading.isIndexLocked)
|
||||
|
||||
// Optional work simply does not happen (06 ▸ Repository hygiene: skipped under a pause).
|
||||
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held))
|
||||
|
||||
// And the app never aborts its own leftover against a repository it cannot open — the stamp
|
||||
// is kept, not cleared, so the leftover stays recognizable as this app's.
|
||||
let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: nil)
|
||||
#expect(GitOperationRecovery.decide(stamp: stamp, pause: .unreadable) == .nothingToDo)
|
||||
#expect(GitOperationRecovery.decide(stamp: stamp, pause: .merge) == .abort(stamp),
|
||||
"every other pause still means the app's own leftover")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add git
|
||||
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user