Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
'Invoke-WorktreeMapLocked'
'Resolve-DotbotBaseBranch'
'Resolve-MainBranch'
'Test-RepositoryHasCommits'
'Initialize-UnbornRepositoryForWorktree'
'Assert-OnBaseBranch'
'Stop-WorktreeProcesses'
'Invoke-Git'
Expand Down
58 changes: 57 additions & 1 deletion src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task> <path> <base>` —
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 = <bool>; branch = <string>; message = <string> }.
On failure, returns @{ created = $false; error = <string> } — 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
}
Expand Down Expand Up @@ -2202,6 +2256,8 @@ Export-ModuleMember -Function @(
'Invoke-WorktreeMapLocked'
'Resolve-DotbotBaseBranch'
'Resolve-MainBranch'
'Test-RepositoryHasCommits'
'Initialize-UnbornRepositoryForWorktree'
'Assert-OnBaseBranch'
'Stop-WorktreeProcesses'
'Invoke-Git'
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/Scripts/Invoke-WorkflowProcess.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions tests/Test-Worktree.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ═══════════════════════════════════════════════════════════════════
Expand Down
Loading