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:
@@ -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