Compare commits
6
Commits
v2.12.0
...
cf093e3178
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf093e3178 | ||
|
|
e0748ba351 | ||
|
|
de44196b9a | ||
|
|
c259254921 | ||
|
|
fea8236e8a | ||
|
|
1d6cd49beb |
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## v2.12.0 — 2026-08-28
|
||||
|
||||
### Features
|
||||
|
||||
- wählbares Listen-Icon und aufgeräumter Task-Detail-Header (c31f79d3)
|
||||
- Mission Control als Session-Hub (22e70ffc)
|
||||
|
||||
### Fixes
|
||||
|
||||
- make re-approve after revert_merge do a real merge, not a silent no-op (8d597df4)
|
||||
- survive a worker restart when cancelling a mid-merge unit-merge parent (1affb397)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.11.0 (bbdb0223)
|
||||
|
||||
## v2.11.0 — 2026-08-27
|
||||
|
||||
### Features
|
||||
|
||||
@@ -76,6 +76,13 @@ zugleich der erste Test dieser Annahme: schlägt einer davon mit einem Deseriali
|
||||
|
||||
## Offene Verifikation (2026-07-27)
|
||||
|
||||
- **Empty-Repo-Hardening (2026-07-30)** — Worker/App neu starten (laufende Instanz hat den
|
||||
Fix noch nicht), dann auf dem Task „HEy this is a Test" (Test List → `C:\Dev\TestRepo`,
|
||||
aktuell 0 Commits) „Open ConPTY session" klicken: muss jetzt starten und in `C:\Dev\TestRepo`
|
||||
genau einen leeren Commit „chore: initialize repository (ClaudeDo)" hinterlassen. Idealerweise
|
||||
auch einmal auf dem Zweit-PC gegentesten (dort zusätzlich: Task-Run auf einem PC ohne
|
||||
`git config user.name/email` — Auto-Commit darf nicht mehr scheitern).
|
||||
|
||||
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
|
||||
the context-menu item appears only on lists with a working dir, and the selection dialog has no
|
||||
LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber
|
||||
|
||||
@@ -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).
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.418",
|
||||
"rollForward": "latestFeature"
|
||||
"rollForward": "latestMajor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +80,15 @@ dir(s) via an optional `AttachmentStore` ctor param (defaults to the production
|
||||
- Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files
|
||||
- Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`)
|
||||
- `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
|
||||
- Empty repos: `HasHeadCommitAsync` (HEAD resolves to a real commit) and `EnsureHeadCommitAsync` — a fresh `git init` repo (unborn HEAD) gets an empty bootstrap commit via plumbing only (mktree/commit-tree/update-ref), so the user's index and working tree stay untouched; identity is passed inline, so it also works with no git config, and a broken HEAD throws instead of guessing
|
||||
- Identity fallback: `CommitAsync` retries once as the ClaudeDo identity when the machine has none configured
|
||||
|
||||
⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working
|
||||
directory it operates on is shared with other concurrent sessions.
|
||||
|
||||
⚠️ **Git stdin is BOM-less UTF-8** — `Encoding.UTF8` writes a BOM preamble, which git rejects
|
||||
as input (`mktree` fails with "input format error", and commit messages would carry it).
|
||||
|
||||
## Schema
|
||||
|
||||
Tables (one per line so parallel migrations don't collide on the same line):
|
||||
|
||||
@@ -25,7 +25,16 @@ public sealed class GitService
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
// Unborn HEAD (fresh `git init`, no commits) — the raw git stderr ("ambiguous
|
||||
// argument 'HEAD'") is useless to a user; call sites should have run
|
||||
// EnsureHeadCommitAsync, this message is the safety net for any that didn't.
|
||||
if (stderr.Contains("ambiguous argument 'HEAD'", StringComparison.Ordinal) ||
|
||||
stderr.Contains("unknown revision", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException(
|
||||
$"The repository at {dir} has no commits yet (unborn or broken HEAD) — ClaudeDo needs at least one commit to work in it.");
|
||||
throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
@@ -55,6 +64,67 @@ public sealed class GitService
|
||||
return exitCode == 0 ? stdout.Trim() : null;
|
||||
}
|
||||
|
||||
/// <summary>HEAD resolves to a commit (false for a fresh `git init` repo or a broken HEAD).</summary>
|
||||
public async Task<bool> HasHeadCommitAsync(string dir, CancellationToken ct = default)
|
||||
{
|
||||
// ^{commit} forces the object to actually exist and be a commit — a bare
|
||||
// `--verify HEAD` happily accepts a detached HEAD pointing at a missing object.
|
||||
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], ct);
|
||||
return exitCode == 0;
|
||||
}
|
||||
|
||||
private const string FallbackUserName = "ClaudeDo";
|
||||
private const string FallbackUserEmail = "claudedo@local";
|
||||
|
||||
// Two parallel task starts on the same empty repo must create exactly one initial commit.
|
||||
private static readonly SemaphoreSlim BootstrapGate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Guarantees HEAD resolves to a commit so worktrees can be created. A repo with an
|
||||
/// unborn HEAD (fresh `git init`) gets an empty initial commit built from plumbing only
|
||||
/// (hash-object/commit-tree/update-ref) — the user's index and working tree are never
|
||||
/// touched, so staged and untracked files stay exactly as they were. Identity is passed
|
||||
/// inline, so this also works on machines with no git identity configured. A repo whose
|
||||
/// HEAD is broken (not a symbolic ref and not resolvable) throws instead of guessing.
|
||||
/// </summary>
|
||||
/// <returns>true if a bootstrap commit was created; false if HEAD already resolved.</returns>
|
||||
public async Task<bool> EnsureHeadCommitAsync(string dir, CancellationToken ct = default)
|
||||
{
|
||||
if (await HasHeadCommitAsync(dir, ct)) return false;
|
||||
|
||||
var (symExit, symRef, _) = await RunGitAsync(dir, ["symbolic-ref", "--quiet", "HEAD"], ct);
|
||||
if (symExit != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"The repository at {dir} has a broken HEAD (detached onto a missing commit, or a corrupt .git/HEAD). Repair the repository manually.");
|
||||
|
||||
await BootstrapGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (await HasHeadCommitAsync(dir, ct)) return false;
|
||||
|
||||
var (treeExit, treeSha, treeErr) = await RunGitAsync(dir, ["mktree"], ct, stdinData: "");
|
||||
if (treeExit != 0)
|
||||
throw new InvalidOperationException($"git mktree failed (exit {treeExit}): {treeErr}");
|
||||
|
||||
var (commitExit, commitSha, commitErr) = await RunGitAsync(dir,
|
||||
["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
|
||||
"commit-tree", treeSha.Trim(), "-m", "chore: initialize repository (ClaudeDo)"], ct);
|
||||
if (commitExit != 0)
|
||||
throw new InvalidOperationException($"git commit-tree failed (exit {commitExit}): {commitErr}");
|
||||
|
||||
var (refExit, _, refErr) = await RunGitAsync(dir,
|
||||
["update-ref", symRef.Trim(), commitSha.Trim()], ct);
|
||||
if (refExit != 0)
|
||||
throw new InvalidOperationException($"git update-ref failed (exit {refExit}): {refErr}");
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
BootstrapGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||
{
|
||||
await WorktreeAddGate.WaitAsync(ct);
|
||||
@@ -135,10 +205,24 @@ public sealed class GitService
|
||||
{
|
||||
// Use -F - (read message from stdin) to handle multi-line messages safely.
|
||||
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message);
|
||||
if (exitCode != 0 && IsMissingIdentityError(stderr))
|
||||
{
|
||||
// A machine without git identity configured must not fail every auto-commit —
|
||||
// retry once as ClaudeDo. The happy path pays no preflight cost.
|
||||
(exitCode, _, stderr) = await RunGitAsync(worktreePath,
|
||||
["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
|
||||
"commit", "-F", "-"], ct, stdinData: message);
|
||||
}
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
private static bool IsMissingIdentityError(string stderr) =>
|
||||
stderr.Contains("tell me who you are", StringComparison.OrdinalIgnoreCase) ||
|
||||
stderr.Contains("empty ident", StringComparison.OrdinalIgnoreCase) ||
|
||||
stderr.Contains("no email was given", StringComparison.OrdinalIgnoreCase) ||
|
||||
stderr.Contains("unable to auto-detect email address", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<string> GetDiffAsync(
|
||||
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
||||
{
|
||||
@@ -584,7 +668,9 @@ public sealed class GitService
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null,
|
||||
// BOM-less: Encoding.UTF8 writes a BOM preamble, which git rejects as input
|
||||
// (mktree "input format error") and would prepend to stdin commit messages.
|
||||
StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null,
|
||||
};
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
|
||||
@@ -98,6 +98,9 @@ public sealed class PlanningSessionManager
|
||||
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
|
||||
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
|
||||
|
||||
// A fresh `git init` repo has no HEAD commit to base the planning worktree on.
|
||||
await _git.EnsureHeadCommitAsync(listWorkingDir, ct);
|
||||
|
||||
var appSettings = await settings.GetAsync(ct);
|
||||
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||
var branchName = BranchNameFor(taskId);
|
||||
|
||||
@@ -46,6 +46,10 @@ public sealed class WorktreeManager
|
||||
if (!await _git.IsGitRepoAsync(workingDir, ct))
|
||||
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
|
||||
|
||||
// A fresh `git init` repo has no HEAD commit to base a worktree on — bootstrap one.
|
||||
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
|
||||
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
||||
|
||||
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
|
||||
// Use the full task id (dashes stripped) in the branch name so
|
||||
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Runner;
|
||||
|
||||
// Covers the empty-repo (unborn HEAD) hardening: HasHeadCommitAsync, EnsureHeadCommitAsync
|
||||
// (plumbing-only bootstrap commit), the friendly RevParseHeadAsync unborn message, and the
|
||||
// CommitAsync git-identity fallback. All tests run real git against throwaway repos.
|
||||
public class GitServiceBootstrapTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _tempDirs = new();
|
||||
private readonly GitService _git = new();
|
||||
|
||||
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
||||
|
||||
// A repo straight out of `git init` — no commits, and deliberately NO identity
|
||||
// configured: the bootstrap must not depend on machine-level git config.
|
||||
private string InitEmptyRepo()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"claudedo_emptyrepo_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
GitRepoFixture.RunGit(dir, "init", "-b", "main");
|
||||
_tempDirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHeadCommit_FalseOnEmptyRepo_TrueAfterCommit()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var dir = InitEmptyRepo();
|
||||
Assert.False(await _git.HasHeadCommitAsync(dir));
|
||||
|
||||
GitRepoFixture.RunGit(dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "x");
|
||||
Assert.True(await _git.HasHeadCommitAsync(dir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureHeadCommit_BootstrapsEmptyRepo_Once()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var dir = InitEmptyRepo();
|
||||
|
||||
Assert.True(await _git.EnsureHeadCommitAsync(dir));
|
||||
|
||||
// HEAD now resolves and RevParseHeadAsync works — the original failure mode is gone.
|
||||
var head = await _git.RevParseHeadAsync(dir);
|
||||
Assert.Matches("^[0-9a-f]{40,64}$", head);
|
||||
|
||||
// The bootstrap commit is empty: nothing of the user's ends up in it.
|
||||
var tree = GitRepoFixture.RunGit(dir, "ls-tree", "HEAD").Trim();
|
||||
Assert.Equal(string.Empty, tree);
|
||||
|
||||
// Idempotent: a repo with a HEAD commit is left alone.
|
||||
Assert.False(await _git.EnsureHeadCommitAsync(dir));
|
||||
Assert.Equal(head, await _git.RevParseHeadAsync(dir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureHeadCommit_LeavesStagedFilesStaged()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var dir = InitEmptyRepo();
|
||||
File.WriteAllText(Path.Combine(dir, "mine.txt"), "user work\n");
|
||||
GitRepoFixture.RunGit(dir, "add", "mine.txt");
|
||||
|
||||
Assert.True(await _git.EnsureHeadCommitAsync(dir));
|
||||
|
||||
// Still staged (index vs HEAD shows it as pending), NOT committed.
|
||||
var staged = GitRepoFixture.RunGit(dir, "diff", "--cached", "--name-only").Trim();
|
||||
Assert.Equal("mine.txt", staged);
|
||||
var tree = GitRepoFixture.RunGit(dir, "ls-tree", "HEAD").Trim();
|
||||
Assert.Equal(string.Empty, tree);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureHeadCommit_CorruptHead_ThrowsClearError()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var dir = InitEmptyRepo();
|
||||
// Detach HEAD onto a commit that doesn't exist — not an unborn branch, a broken repo.
|
||||
File.WriteAllText(Path.Combine(dir, ".git", "HEAD"), new string('f', 40) + "\n");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _git.EnsureHeadCommitAsync(dir));
|
||||
Assert.Contains(dir, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevParseHead_EmptyRepo_ReportsNoCommitsInsteadOfRawGitError()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var dir = InitEmptyRepo();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _git.RevParseHeadAsync(dir));
|
||||
Assert.Contains("no commits yet", ex.Message);
|
||||
Assert.Contains(dir, ex.Message);
|
||||
Assert.DoesNotContain("ambiguous argument", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitAsync_MissingIdentity_FallsBackToClaudeDoIdentity()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var dir = InitEmptyRepo();
|
||||
GitRepoFixture.RunGit(dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "seed");
|
||||
// Empty local identity overrides any machine-level config, so the first commit
|
||||
// attempt fails with git's identity error no matter where this test runs.
|
||||
GitRepoFixture.RunGit(dir, "config", "user.name", "");
|
||||
GitRepoFixture.RunGit(dir, "config", "user.email", "");
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "work.txt"), "content\n");
|
||||
await _git.AddAllAsync(dir);
|
||||
|
||||
await _git.CommitAsync(dir, "feat(test): identity fallback");
|
||||
|
||||
var author = GitRepoFixture.RunGit(dir, "log", "-1", "--format=%an <%ae>").Trim();
|
||||
Assert.Equal("ClaudeDo <claudedo@local>", author);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var dir in _tempDirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
File.SetAttributes(file, FileAttributes.Normal);
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,34 @@ public class WorktreeManagerTests : IDisposable
|
||||
Assert.Contains("hello.txt", row.DiffStat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_EmptyRepo_BootstrapsInitialCommit()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
// Fresh `git init`, zero commits, no identity configured — the cross-PC failure
|
||||
// that surfaced as "git rev-parse HEAD failed: ambiguous argument 'HEAD'".
|
||||
var repoDir = Path.Combine(Path.GetTempPath(), $"claudedo_emptyrepo_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(repoDir);
|
||||
GitRepoFixture.RunGit(repoDir, "init", "-b", "main");
|
||||
_tempDirs.Add(repoDir);
|
||||
|
||||
var (task, list) = MakeEntities(repoDir);
|
||||
var (mgr, db) = await CreateManagerAsync(task, list);
|
||||
|
||||
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repoDir, ctx.WorktreePath));
|
||||
|
||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||
// The base commit is the auto-created bootstrap commit.
|
||||
var head = GitRepoFixture.RunGit(repoDir, "rev-parse", "HEAD").Trim();
|
||||
Assert.Equal(head, ctx.BaseCommit);
|
||||
|
||||
using var readCtx = db.CreateContext();
|
||||
var row = await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id);
|
||||
Assert.Equal(WorktreeState.Active, row!.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_NonGitDir_Throws_NoRow()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user