From 8257976a26e53ede74e4a941e92099ee7d073cc1 Mon Sep 17 00:00:00 2001 From: Tarik Cosovic Date: Tue, 4 Aug 2026 11:39:55 +0200 Subject: [PATCH] fix(worktree): seed empty initial commit on unborn HEAD before worktree setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh `git init` + `dotbot init` + workflow run used to produce a scary `Cannot find base branch` warning on every Git version, and on Git <2.42 also crashed the first `git worktree add` with `fatal: invalid reference` (because `worktree add --orphan` didn't exist yet, and the fallback tried to attach to a branch that wasn't there). Solve both symptoms without a Git version floor — Ubuntu 22.04 LTS ships Git 2.34.1 through May 2027, so raising the floor as tried in the earlier attempt (#664) locks that platform out. - Add `Initialize-UnbornRepositoryForWorktree` in Dotbot.Worktree: creates an empty commit with `dotbot@localhost` identity overrides on unborn HEAD, idempotent, uses the branch git already selected via `init.defaultBranch`. Called from `Initialize-DotbotTaskWorktreeForProcess` right before `Assert-OnBaseBranch`, so downstream base-branch resolution and `git worktree add -b ` just work on any Git. - `Assert-OnBaseBranch` now returns `$null` early on unborn HEAD as defense-in-depth for callers that reach it directly. In the normal flow the seed above means it never sees an unborn repo. Closes #659 Co-Authored-By: Claude Opus 4.7 --- .../Dotbot.Worktree/Dotbot.Worktree.psd1 | 2 + .../Dotbot.Worktree/Dotbot.Worktree.psm1 | 58 ++++++++++++++++++- .../Scripts/Invoke-WorkflowProcess.ps1 | 10 ++++ tests/Test-Worktree.ps1 | 58 +++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1 index b40f5666..ee127c21 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1 @@ -21,6 +21,8 @@ 'Invoke-WorktreeMapLocked' 'Resolve-DotbotBaseBranch' 'Resolve-MainBranch' + 'Test-RepositoryHasCommits' + 'Initialize-UnbornRepositoryForWorktree' 'Assert-OnBaseBranch' 'Stop-WorktreeProcesses' 'Invoke-Git' diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 71fdb24e..8bd70a94 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -214,18 +214,72 @@ function Resolve-UnbornBaseBranch { return 'main' } +function Initialize-UnbornRepositoryForWorktree { + <# + .SYNOPSIS + Create an empty initial commit when the project repository is unborn. + + .DESCRIPTION + A brand-new `git init` leaves HEAD pointing at a branch with no commits. + All the downstream worktree machinery — base-branch resolution, + integration-branch creation, `git worktree add -b ` — + needs a real commit to attach to; without one the first task of a fresh + project either crashes or falls back to code paths that don't exist on + older git (see issue #659). + + Runs at the start of worktree setup for the first task of a new project. + Idempotent: if the repo already has commits, returns without touching git. + Uses `--allow-empty` so it doesn't stage the user's files, and pins the + committer identity so it works on machines where `user.name`/`user.email` + have never been configured. The generated commit reuses git's active branch + (whatever `init.defaultBranch` picked — usually `main` or `master`) so + dotbot doesn't force a naming choice on the user. + + .OUTPUTS + Hashtable @{ created = ; branch = ; message = }. + On failure, returns @{ created = $false; error = } — callers + decide whether to hard-fail or continue. + #> + param([Parameter(Mandatory)][string]$ProjectRoot) + + if (Test-RepositoryHasCommits -ProjectRoot $ProjectRoot) { + return @{ created = $false; branch = $null; message = 'Repository already has commits' } + } + + $branch = (git -C $ProjectRoot symbolic-ref --quiet --short HEAD 2>$null) -as [string] + $branch = if ($branch) { $branch.Trim() } else { 'main' } + + $commitMessage = 'chore: initial commit' + $out = git -C $ProjectRoot ` + -c user.name=dotbot ` + -c user.email=dotbot@localhost ` + commit --allow-empty --quiet -m $commitMessage 2>&1 + if ($LASTEXITCODE -ne 0) { + return @{ created = $false; error = ($out -join ' ') } + } + return @{ created = $true; branch = $branch; message = $commitMessage } +} + function Assert-OnBaseBranch { <# .SYNOPSIS Ensure the main repo is checked out on the specified branch (or the canonical main/master if none is specified). Checks out the branch if not already on it. Throws if the branch cannot be found or checked out. - Returns the confirmed base branch name. + Returns the confirmed base branch name, or $null when the repo is unborn. + + Unborn repos have no base branch to switch to. In the normal workflow-run + flow Initialize-UnbornRepositoryForWorktree seeds a commit before this is + called; the silent no-op here is defense-in-depth for callers that reach + Assert-OnBaseBranch on an unborn repo directly (see issue #659). #> param( [Parameter(Mandatory)][string]$ProjectRoot, [string]$BranchName ) + if (-not (Test-RepositoryHasCommits -ProjectRoot $ProjectRoot)) { + return $null + } if (-not $BranchName) { $BranchName = Resolve-MainBranch -ProjectRoot $ProjectRoot } @@ -2202,6 +2256,8 @@ Export-ModuleMember -Function @( 'Invoke-WorktreeMapLocked' 'Resolve-DotbotBaseBranch' 'Resolve-MainBranch' + 'Test-RepositoryHasCommits' + 'Initialize-UnbornRepositoryForWorktree' 'Assert-OnBaseBranch' 'Stop-WorktreeProcesses' 'Invoke-Git' diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index b6935ad8..1cf5ecd3 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -138,6 +138,16 @@ function Initialize-DotbotTaskWorktreeForProcess { } } + # Fresh `git init` leaves HEAD unborn, which breaks base-branch resolution + # and the first `git worktree add` (see issue #659). Seed an empty commit + # so downstream git operations have a real branch to attach to. Idempotent + # — no-op when the repo already has commits. + $seed = Initialize-UnbornRepositoryForWorktree -ProjectRoot $ProjectRoot + if ($seed.created) { + Write-Status "Seeded initial commit on '$($seed.branch)' — required by worktree setup on a fresh repo." -Type Info + } elseif ($seed.error) { + Write-Status "Could not seed initial commit on unborn repo: $($seed.error)" -Type Warn + } $guardArgs = @{ ProjectRoot = $ProjectRoot } if (-not [string]::IsNullOrWhiteSpace($BaseBranch)) { $guardArgs.BranchName = $BaseBranch } try { Assert-OnBaseBranch @guardArgs | Out-Null } catch { diff --git a/tests/Test-Worktree.ps1 b/tests/Test-Worktree.ps1 index df1783de..9b47ab53 100644 --- a/tests/Test-Worktree.ps1 +++ b/tests/Test-Worktree.ps1 @@ -425,6 +425,64 @@ try { Remove-Item -Path $emptyRepo -Recurse -Force -ErrorAction SilentlyContinue } +# ═══════════════════════════════════════════════════════════════════ +# Unborn HEAD handling (regression: #659) +# ═══════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " Unborn HEAD — auto initial commit + silent Assert" -ForegroundColor Cyan +Write-Host " ────────────────────────────────────────────" -ForegroundColor DarkGray + +# Initialize-UnbornRepositoryForWorktree — creates an empty first commit on +# fresh `git init`, so worktree setup doesn't hit the "no base branch" trap. +$unbornSeed = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-unborn-seed-$([System.Guid]::NewGuid().ToString().Substring(0,8))" +New-Item -ItemType Directory -Path $unbornSeed -Force | Out-Null +try { + & git -C $unbornSeed init --quiet 2>$null | Out-Null + $r = Initialize-UnbornRepositoryForWorktree -ProjectRoot $unbornSeed + Assert-True -Name "Initialize-UnbornRepositoryForWorktree — created=true on unborn HEAD" -Condition ([bool]$r.created) + Assert-True -Name "Initialize-UnbornRepositoryForWorktree — reports branch name" -Condition ([bool]$r.branch) + + & git -C $unbornSeed rev-parse --verify HEAD 2>$null | Out-Null + Assert-True -Name "Initialize-UnbornRepositoryForWorktree — HEAD is now valid" -Condition ($LASTEXITCODE -eq 0) + + $subject = (& git -C $unbornSeed log -1 --pretty=%s 2>$null).Trim() + Assert-Equal -Name "Initialize-UnbornRepositoryForWorktree — commit message" -Expected 'chore: initial commit' -Actual $subject + + $count = [int]((& git -C $unbornSeed rev-list --count HEAD).Trim()) + Assert-Equal -Name "Initialize-UnbornRepositoryForWorktree — one commit" -Expected 1 -Actual $count + + # Second call must be idempotent — no extra commits. + $r2 = Initialize-UnbornRepositoryForWorktree -ProjectRoot $unbornSeed + Assert-True -Name "Initialize-UnbornRepositoryForWorktree — idempotent (created=false)" -Condition (-not [bool]$r2.created) + $countAfter = [int]((& git -C $unbornSeed rev-list --count HEAD).Trim()) + Assert-Equal -Name "Initialize-UnbornRepositoryForWorktree — no extra commit on repeat call" -Expected 1 -Actual $countAfter +} finally { + Remove-Item -Path $unbornSeed -Recurse -Force -ErrorAction SilentlyContinue +} + +# Assert-OnBaseBranch — defense-in-depth: silent no-op on unborn HEAD. +# Callers that don't run Initialize-UnbornRepositoryForWorktree first must +# still avoid the misleading "Cannot find base branch" warning. +$unbornAssert = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-unborn-assert-$([System.Guid]::NewGuid().ToString().Substring(0,8))" +New-Item -ItemType Directory -Path $unbornAssert -Force | Out-Null +try { + & git -C $unbornAssert init --quiet 2>$null | Out-Null + $threw = $false + $result = $null + try { + $result = Assert-OnBaseBranch -ProjectRoot $unbornAssert + } catch { + $threw = $true + } + Assert-True -Name "Assert-OnBaseBranch — does not throw on unborn HEAD" -Condition (-not $threw) + Assert-True -Name "Assert-OnBaseBranch — returns null on unborn HEAD" -Condition ($null -eq $result) + & git -C $unbornAssert rev-parse --verify HEAD 2>$null | Out-Null + Assert-True -Name "Assert-OnBaseBranch — does not create a commit" -Condition ($LASTEXITCODE -ne 0) +} finally { + Remove-Item -Path $unbornAssert -Recurse -Force -ErrorAction SilentlyContinue +} + # ═══════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════