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:
2026-08-06 18:44:12 -04:00
parent 52df210284
commit 0d8ecdb78b
20 changed files with 938 additions and 42 deletions
+166
View File
@@ -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