From fea8236e8a1aecdc2474982a0f55de0131341ebb Mon Sep 17 00:00:00 2001 From: CubeGameLP <126233386+CubeGameLP@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:53:40 +0200 Subject: [PATCH] docs(git): spec + plan for empty-repo (unborn HEAD) hardening Co-Authored-By: Claude Fable 5 (cherry picked from commit 5f4b7a9e26af0635cfaecfbc55bfe928dd3466d2) --- .../2026-07-30-empty-repo-hardening-plan.md | 58 +++++++++++ .../2026-07-30-empty-repo-hardening-design.md | 98 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-empty-repo-hardening-plan.md create mode 100644 docs/superpowers/specs/2026-07-30-empty-repo-hardening-design.md diff --git a/docs/superpowers/plans/2026-07-30-empty-repo-hardening-plan.md b/docs/superpowers/plans/2026-07-30-empty-repo-hardening-plan.md new file mode 100644 index 00000000..68e0c66a --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-empty-repo-hardening-plan.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-30-empty-repo-hardening-design.md b/docs/superpowers/specs/2026-07-30-empty-repo-hardening-design.md new file mode 100644 index 00000000..d6f8ce58 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-empty-repo-hardening-design.md @@ -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 -m + "chore: initialize repository (ClaudeDo)"` — inline identity so this works on a PC + with no git identity configured. + 3. `git update-ref `. +- 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).