docs(git): spec + plan for empty-repo (unborn HEAD) hardening
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 5f4b7a9e26af0635cfaecfbc55bfe928dd3466d2)
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Empty-repo hardening — implementation plan
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-07-30-empty-repo-hardening-design.md`
|
||||
|
||||
## Task 1 — GitService: HasHeadCommitAsync + EnsureHeadCommitAsync + friendly unborn error
|
||||
|
||||
**Files:** `src/ClaudeDo.Data/Git/GitService.cs`,
|
||||
`tests/ClaudeDo.Worker.Tests/Runner/GitServiceBootstrapTests.cs` (new)
|
||||
|
||||
1. Tests first (skip when git unavailable, mirroring `WorktreeManagerTests`):
|
||||
- fresh `git init` repo (no commit): `HasHeadCommitAsync` false; after a commit true.
|
||||
- `EnsureHeadCommitAsync` on empty repo → true; `RevParseHeadAsync` then succeeds;
|
||||
`git ls-tree HEAD` empty; second call → false.
|
||||
- staged file in empty repo survives bootstrap: still staged, not in the commit.
|
||||
- corrupt HEAD (write a garbage SHA into `.git/HEAD` as a detached head):
|
||||
`EnsureHeadCommitAsync` throws `InvalidOperationException` mentioning the dir.
|
||||
- `RevParseHeadAsync` on empty repo: message says "has no commits yet" (no raw
|
||||
"ambiguous argument").
|
||||
2. Implement: `HasHeadCommitAsync` (`rev-parse --verify --quiet HEAD`),
|
||||
`EnsureHeadCommitAsync` (symbolic-ref guard → hash-object/commit-tree/update-ref
|
||||
plumbing behind a static `SemaphoreSlim` with post-acquire re-check), unborn-HEAD
|
||||
message in `RevParseHeadAsync`.
|
||||
3. Build Data + run new tests. Commit `fix(git): bootstrap empty repos with an initial commit`.
|
||||
|
||||
## Task 2 — CommitAsync identity fallback
|
||||
|
||||
**Files:** same two files.
|
||||
|
||||
1. Test: repo with local `user.name`/`user.email` set to empty strings → `CommitAsync`
|
||||
succeeds (retry path), commit exists.
|
||||
2. Implement: on identity-signature failure, retry once with
|
||||
`-c user.name=ClaudeDo -c user.email=claudedo@local`.
|
||||
3. Commit `fix(git): fall back to a ClaudeDo identity when git has none`.
|
||||
|
||||
## Task 3 — call sites
|
||||
|
||||
**Files:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`,
|
||||
`src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`,
|
||||
`tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`
|
||||
|
||||
1. Test: `CreateAsync` on a commit-less repo succeeds; worktree on disk; DB row Active.
|
||||
2. Wire `EnsureHeadCommitAsync` into `WorktreeManager.CreateAsync` (log info on
|
||||
bootstrap) and `PlanningSessionManager.StartAsync`.
|
||||
3. Full Worker.Tests run. Commit `fix(worktree): auto-bootstrap commit-less repos before creating worktrees`.
|
||||
|
||||
## Task 4 — docs
|
||||
|
||||
- `src/ClaudeDo.Data/CLAUDE.md`: GitService bullet gains bootstrap/identity-fallback.
|
||||
- `docs/open.md`: verification item — open a ConPTY session on a fresh `git init` repo
|
||||
on a second PC.
|
||||
- Commit `docs: empty-repo hardening notes`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet test tests/ClaudeDo.Worker.Tests -c Release` green.
|
||||
- Manual: task 61609f79… in "Test List" (`C:\Dev\TestRepo`, currently commit-less) —
|
||||
opening the ConPTY session must now succeed and leave `C:\Dev\TestRepo` with exactly
|
||||
one empty commit by ClaudeDo.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Empty-repo hardening (unborn HEAD) — design
|
||||
|
||||
**Date:** 2026-07-30
|
||||
**Status:** approved (option "Auto-Bootstrap" chosen by Mika)
|
||||
|
||||
## Problem
|
||||
|
||||
`GetInteractiveLaunchSpec` (embedded ConPTY session) fails with a raw git error when the
|
||||
task's list points at a git repository that has **no commits yet** (fresh `git init`,
|
||||
unborn HEAD):
|
||||
|
||||
```
|
||||
HubException: git rev-parse HEAD failed (exit 128): fatal: ambiguous argument 'HEAD': unknown revision ...
|
||||
```
|
||||
|
||||
Root cause chain: `InteractiveLaunchSpecService.BuildForTaskAsync` → `WorktreeManager.CreateAsync`
|
||||
→ `IsGitRepoAsync` (passes — `rev-parse --git-dir` works on an empty repo) →
|
||||
`ResolveBaseCommitAsync` → `RevParseHeadAsync` (fatals — HEAD is unborn). The same gap
|
||||
exists in `PlanningSessionManager.StartAsync` and hits autonomous runs (`TaskRunner` →
|
||||
`CreateAsync` → task `Failed` with the same cryptic message).
|
||||
|
||||
This is a "works on my laptop" class of bug: on a fresh PC the first thing a user tries is
|
||||
a freshly init-ed test repo, which breaks instantly. A second member of the same class:
|
||||
a PC without `git config user.name/email` breaks every auto-commit
|
||||
("Please tell me who you are").
|
||||
|
||||
## Decision
|
||||
|
||||
**Auto-bootstrap:** when a worktree (task run / ConPTY / planning) needs a base commit and
|
||||
the repo has zero commits, ClaudeDo creates an empty initial commit automatically and
|
||||
proceeds. Nothing can be lost — the repo is empty. Genuinely broken states (HEAD not a
|
||||
symbolic ref and not resolvable) still produce a clear, actionable error instead of raw
|
||||
git stderr.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. `GitService.HasHeadCommitAsync(dir)`
|
||||
|
||||
`git rev-parse --verify --quiet HEAD` → exit 0 means HEAD resolves to a commit.
|
||||
|
||||
### 2. `GitService.EnsureHeadCommitAsync(dir)` → `bool` (true = bootstrapped)
|
||||
|
||||
- HEAD resolves → return `false` (no-op).
|
||||
- `git symbolic-ref -q HEAD` fails → repo is corrupt (detached HEAD onto a missing
|
||||
commit, mangled `.git/HEAD`) → throw `InvalidOperationException` with a clear message
|
||||
naming the directory. Auto-fixing a corrupt repo is out of scope by design.
|
||||
- Otherwise HEAD is an unborn branch → bootstrap **via plumbing only** (never touches the
|
||||
user's index or working tree — staged/untracked files stay exactly as they were):
|
||||
1. `git hash-object -w -t tree --stdin` with empty stdin → empty tree object.
|
||||
2. `git -c user.name=ClaudeDo -c user.email=claudedo@local commit-tree <tree> -m
|
||||
"chore: initialize repository (ClaudeDo)"` — inline identity so this works on a PC
|
||||
with no git identity configured.
|
||||
3. `git update-ref <unborn-branch-ref> <commit>`.
|
||||
- Serialized behind a process-wide gate with a re-check after acquiring, so two parallel
|
||||
task starts on the same empty repo bootstrap exactly once.
|
||||
|
||||
### 3. Defense-in-depth: friendly `RevParseHeadAsync` error
|
||||
|
||||
When `rev-parse HEAD` fails with the unborn-HEAD signature ("ambiguous argument 'HEAD'"),
|
||||
throw "The repository at {dir} has no commits yet" instead of raw stderr — covers any
|
||||
future call site that forgets the preflight.
|
||||
|
||||
### 4. Call sites
|
||||
|
||||
- `WorktreeManager.CreateAsync` — after the `IsGitRepoAsync` guard, call
|
||||
`EnsureHeadCommitAsync`; log when a bootstrap happened. Covers autonomous runs,
|
||||
interactive ConPTY sessions, improvement/planning children.
|
||||
- `PlanningSessionManager.StartAsync` — same, before its own `RevParseHeadAsync`.
|
||||
|
||||
### 5. Git identity fallback for auto-commits
|
||||
|
||||
`GitService.CommitAsync`: if the commit fails with the missing/empty-identity signature,
|
||||
retry once with inline `-c user.name="ClaudeDo" -c user.email="claudedo@local"`.
|
||||
Happy path costs nothing (no preflight call); a fresh PC without git identity no longer
|
||||
fails every task auto-commit.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- UI validation when configuring a list's WorkingDir (nice-to-have; the worktree paths now
|
||||
either self-heal or produce clear errors).
|
||||
- Repairing corrupt repos.
|
||||
|
||||
## Test plan (real git, Worker.Tests)
|
||||
|
||||
- `HasHeadCommitAsync`: false on fresh `git init`, true after a commit.
|
||||
- `EnsureHeadCommitAsync` on an empty repo: returns true, `rev-parse HEAD` then works,
|
||||
tree of the bootstrap commit is empty, second call returns false.
|
||||
- Staged-file preservation: file staged in empty repo → after bootstrap it is still
|
||||
staged and NOT part of the bootstrap commit.
|
||||
- Corrupt HEAD (point `.git/HEAD` at a bogus ref? no — detach onto a garbage SHA by
|
||||
writing `.git/HEAD` directly): clear error, no bootstrap.
|
||||
- `CommitAsync` identity fallback: local `user.name`/`user.email` set to empty strings
|
||||
(forces the identity failure deterministically regardless of global config) → commit
|
||||
succeeds via fallback, author is ClaudeDo.
|
||||
- `WorktreeManager.CreateAsync` on an empty repo: succeeds; worktree exists; base commit
|
||||
= bootstrap commit.
|
||||
- `PlanningSessionManager.StartAsync` on an empty repo: succeeds (if existing test infra
|
||||
makes this cheap; otherwise covered by the shared code path).
|
||||
Reference in New Issue
Block a user