fix(worktree): auto-bootstrap commit-less repos before creating worktrees

Covers autonomous runs, interactive ConPTY sessions (GetInteractiveLaunchSpec),
planning sessions, and improvement/planning children — every path that needs a
base commit now self-heals on a fresh 'git init' repo instead of surfacing
"ambiguous argument 'HEAD'" as a HubException.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ec16a85c495ceceaa22f922e077a27dcda54f035)
This commit is contained in:
CubeGameLP
2026-08-28 20:33:45 +02:00
parent de44196b9a
commit e0748ba351
3 changed files with 35 additions and 0 deletions
@@ -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.
@@ -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()
{