From 2cbb776f65d03574b47144c63c049e7daaadb4e0 Mon Sep 17 00:00:00 2001 From: Tarik Cosovic Date: Tue, 21 Jul 2026 17:20:00 +0200 Subject: [PATCH] fix(worktree): enforce git >=2.42 floor and silence unborn-HEAD guard warning Fresh `git init` + `dotbot init` + workflow run used to produce a scary "Branch guard warning: Cannot find base branch" line on every Git version, and on Git <2.42 also failed the first worktree add with a cryptic `fatal: invalid reference` (because `worktree add --orphan` didn't exist yet and the fallback tries to attach to a branch that isn't there). Enforce Git 2.42 as the operating floor in `Test-GitReadyForWorktree` (and its private twin) with a new `git_too_old` refusal, and make `Assert-OnBaseBranch` silently no-op on an unborn HEAD so the first-run flow is quiet instead of misleading. Closes #659 Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- .../Dotbot.Workflow/Dotbot.Workflow.psd1 | 3 + .../Dotbot.Workflow/Dotbot.Workflow.psm1 | 65 +++++++++++++++++-- .../Dotbot.Worktree/Dotbot.Worktree.psm1 | 10 ++- .../Dotbot.Worktree/Private/Worktree.psm1 | 16 ++++- tests/Test-WorkflowManifest.ps1 | 45 +++++++++++++ tests/Test-Worktree.ps1 | 31 +++++++++ 7 files changed, 162 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6fcdad19..ca67246c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ dotbot wraps AI-assisted coding in a managed, transparent workflow where every s **Required:** - **PowerShell 7+** - [Download](https://aka.ms/powershell) -- **Git** - [Download](https://git-scm.com/downloads) +- **Git 2.42+** - [Download](https://git-scm.com/downloads) (required for `git worktree add --orphan`, used on the first task of a fresh project) - **AI CLI** (at least one) - [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli), [Codex CLI](https://github.com/openai/codex), or [Antigravity](https://antigravity.google/) **Recommended MCP servers:** diff --git a/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psd1 b/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psd1 index d65e4cb4..35c4a0cf 100644 --- a/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psd1 +++ b/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psd1 @@ -42,6 +42,9 @@ 'Test-ManifestCondition' 'Test-CanStartRun' 'Test-GitReadyForWorktree' + 'ConvertTo-DotbotGitVersion' + 'Get-DotbotGitVersion' + 'Get-MinDotbotGitVersion' # TaskDefinition 'Get-TaskDefinitionFields' diff --git a/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psm1 b/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psm1 index ad5f7a6d..2c863c05 100644 --- a/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psm1 +++ b/src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psm1 @@ -1452,16 +1452,56 @@ function Test-CanStartRun { return @{ ok = $true } } +# Minimum git version dotbot operates on. `git worktree add --orphan` (used +# by the first task in a brand-new project) requires 2.42; supporting older +# releases previously meant a fallback that failed with a cryptic +# `fatal: invalid reference`. See issue #659. +$script:MinDotbotGitVersion = [System.Version]::new(2, 42, 0) + +function Get-MinDotbotGitVersion { + return $script:MinDotbotGitVersion +} + +function ConvertTo-DotbotGitVersion { + <# + .SYNOPSIS + Parse the output of `git --version` into a [System.Version]. + Returns $null when the input can't be parsed. + #> + param([string]$VersionOutput) + if ([string]::IsNullOrWhiteSpace($VersionOutput)) { return $null } + if ($VersionOutput -notmatch '(\d+)\.(\d+)(?:\.(\d+))?') { return $null } + $maj = [int]$Matches[1] + $min = [int]$Matches[2] + $patch = if ($Matches[3]) { [int]$Matches[3] } else { 0 } + return [System.Version]::new($maj, $min, $patch) +} + +function Get-DotbotGitVersion { + <# + .SYNOPSIS + Return the installed git's [System.Version], or $null if git is not on PATH + or its version output cannot be parsed. + #> + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $null } + $out = & git --version 2>$null + if ($LASTEXITCODE -ne 0 -or -not $out) { return $null } + return ConvertTo-DotbotGitVersion -VersionOutput ([string]$out) +} + function Test-GitReadyForWorktree { <# .SYNOPSIS Check whether a project directory satisfies the workflow worktree preconditions. .DESCRIPTION - Starting a WorkflowRun requires that the project directory is a git repo. - Repositories with no commits are allowed; task worktrees use git's orphan - worktree mode until the first task commit establishes the base branch. + Starting a WorkflowRun requires that the project directory is a git repo + and that git itself is new enough to support the orphan-worktree path used + for the first task in a fresh project. Repositories with no commits are + allowed; task worktrees use `git worktree add --orphan` until the first + task commit establishes the base branch. Concretely: + - git ≥ $script:MinDotbotGitVersion must be on PATH. - /.git must exist (directory or gitlink file — gitlink covers the worktree case where .git is a small file pointing to the real gitdir). @@ -1470,10 +1510,8 @@ function Test-GitReadyForWorktree { unborn git worktree. On success returns @{ ok = $true }. On failure returns @{ ok = $false; - reason = 'no_git'|'git_unavailable'; message = '' } - where is the user-facing refusal message: - - "Workflow runs require a git repo. Initialise git first, then retry." + reason = 'no_git'|'git_unavailable'|'git_too_old'|'invalid_git_repo'; + message = '' } where is the user-facing refusal message. This is a pure check — it neither modifies anything nor talks to a network. Dotbot.Worktree's create call also invokes the check before @@ -1507,6 +1545,16 @@ function Test-GitReadyForWorktree { } } + $installedGitVersion = Get-DotbotGitVersion + if (-not $installedGitVersion -or $installedGitVersion -lt $script:MinDotbotGitVersion) { + $found = if ($installedGitVersion) { "$installedGitVersion" } else { 'unknown' } + return @{ + ok = $false + reason = 'git_too_old' + message = "dotbot requires git $($script:MinDotbotGitVersion) or newer (found: $found). Upgrade git and retry." + } + } + $count = $null try { # -C so we do not have to push/pop CWD; capture stderr to keep it @@ -1635,6 +1683,9 @@ Export-ModuleMember -Function @( 'Test-ManifestCondition' 'Test-CanStartRun' 'Test-GitReadyForWorktree' + 'ConvertTo-DotbotGitVersion' + 'Get-DotbotGitVersion' + 'Get-MinDotbotGitVersion' # Defined in nested modules under Private/, re-exported here so the # manifest sees them. diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 71fdb24e..9d9714e6 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -220,12 +220,20 @@ function Assert-OnBaseBranch { 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 — the first task runs on an + orphan worktree, and its squash-merge creates the base branch. Callers + already handle this path; silently return $null instead of throwing a + misleading "Cannot find base branch" error. #> param( [Parameter(Mandatory)][string]$ProjectRoot, [string]$BranchName ) + if (-not (Test-RepositoryHasCommits -ProjectRoot $ProjectRoot)) { + return $null + } if (-not $BranchName) { $BranchName = Resolve-MainBranch -ProjectRoot $ProjectRoot } diff --git a/src/runtime/Modules/Dotbot.Worktree/Private/Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Private/Worktree.psm1 index 35ea0c4e..7daba738 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Private/Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Private/Worktree.psm1 @@ -150,9 +150,12 @@ function Resolve-WorkflowMainBranch { return $null } +$script:_MinGitVersion = [System.Version]::new(2, 42, 0) + function _Test-GitReadyForWorktree { # Local copy of Test-GitReadyForWorktree's check so this module doesn't - # have to drag in Dotbot.Workflow. + # have to drag in Dotbot.Workflow. Keep these two implementations in + # lockstep (see Dotbot.Workflow.psm1). param([Parameter(Mandatory)][string]$ProjectRoot) $refusal = @( @@ -168,6 +171,17 @@ function _Test-GitReadyForWorktree { return @{ ok = $false; reason = 'git_unavailable'; message = "git CLI is not available on PATH; cannot verify the worktree precondition.`n$refusal" } } + $installedGitVersion = $null + $verOut = & git --version 2>$null + if ($LASTEXITCODE -eq 0 -and $verOut -and ([string]$verOut) -match '(\d+)\.(\d+)(?:\.(\d+))?') { + $vp = if ($Matches[3]) { [int]$Matches[3] } else { 0 } + $installedGitVersion = [System.Version]::new([int]$Matches[1], [int]$Matches[2], $vp) + } + if (-not $installedGitVersion -or $installedGitVersion -lt $script:_MinGitVersion) { + $found = if ($installedGitVersion) { "$installedGitVersion" } else { 'unknown' } + return @{ ok = $false; reason = 'git_too_old'; message = "dotbot requires git $($script:_MinGitVersion) or newer (found: $found). Upgrade git and retry." } + } + $count = $null try { $stdout = & git -C $ProjectRoot rev-list --count HEAD 2>$null diff --git a/tests/Test-WorkflowManifest.ps1 b/tests/Test-WorkflowManifest.ps1 index d2eacb81..65176ea5 100644 --- a/tests/Test-WorkflowManifest.ps1 +++ b/tests/Test-WorkflowManifest.ps1 @@ -2003,6 +2003,51 @@ try { Write-Host "" +# ═══════════════════════════════════════════════════════════════════ +# Git version parsing + floor (regression: #659) +# ═══════════════════════════════════════════════════════════════════ + +Write-Host " Git version parsing" -ForegroundColor Cyan +Write-Host " ────────────────────────────────────────────" -ForegroundColor DarkGray + +# Standard Windows form. +$v = ConvertTo-DotbotGitVersion -VersionOutput 'git version 2.53.0.windows.3' +Assert-True -Name "parse windows form -> major" -Condition ($v.Major -eq 2) +Assert-True -Name "parse windows form -> minor" -Condition ($v.Minor -eq 53) +Assert-True -Name "parse windows form -> build" -Condition ($v.Build -eq 0) + +# Two-component version (patch defaults to 0). +$v = ConvertTo-DotbotGitVersion -VersionOutput 'git version 2.42' +Assert-True -Name "parse two-component -> minor" -Condition ($v.Minor -eq 42) +Assert-True -Name "parse two-component -> patch defaults to 0" -Condition ($v.Build -eq 0) + +# Old form the ticket reporter had. +$v = ConvertTo-DotbotGitVersion -VersionOutput 'git version 2.31.1.windows.1' +Assert-True -Name "parse legacy windows form -> minor" -Condition ($v.Minor -eq 31) + +# Nonsense / empty input. +Assert-True -Name "parse empty -> null" -Condition ($null -eq (ConvertTo-DotbotGitVersion -VersionOutput '')) +Assert-True -Name "parse garbage -> null" -Condition ($null -eq (ConvertTo-DotbotGitVersion -VersionOutput 'not a version')) + +# Floor is 2.42 (see Dotbot.Workflow.psm1 script scope). +$minVersion = Get-MinDotbotGitVersion +Assert-True -Name "min version is 2.42.0" ` + -Condition ($minVersion -eq ([System.Version]::new(2, 42, 0))) + +# Version-comparison sanity: 2.31 is below the floor; 2.42 is at the floor. +$tooOld = ConvertTo-DotbotGitVersion -VersionOutput 'git version 2.31.1.windows.1' +$atFloor = ConvertTo-DotbotGitVersion -VersionOutput 'git version 2.42.0' +Assert-True -Name "2.31 is below the min version" -Condition ($tooOld -lt $minVersion) +Assert-True -Name "2.42 meets the min version" -Condition ($atFloor -ge $minVersion) + +# Get-DotbotGitVersion should return the installed git's version when git is on PATH. +if (Get-Command git -ErrorAction SilentlyContinue) { + $installed = Get-DotbotGitVersion + Assert-True -Name "Get-DotbotGitVersion returns a Version" -Condition ($installed -is [System.Version]) +} + +Write-Host "" + # ═══════════════════════════════════════════════════════════════════ # workflow manifest removed isolation fields + skip_worktree lint # ═══════════════════════════════════════════════════════════════════ diff --git a/tests/Test-Worktree.ps1 b/tests/Test-Worktree.ps1 index df1783de..1e0fc82a 100644 --- a/tests/Test-Worktree.ps1 +++ b/tests/Test-Worktree.ps1 @@ -425,6 +425,37 @@ try { Remove-Item -Path $emptyRepo -Recurse -Force -ErrorAction SilentlyContinue } +# ═══════════════════════════════════════════════════════════════════ +# Assert-OnBaseBranch on unborn HEAD (regression: #659) +# ═══════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " Assert-OnBaseBranch — unborn repo" -ForegroundColor Cyan +Write-Host " ────────────────────────────────────────────" -ForegroundColor DarkGray + +# Fresh `git init` with no commits used to throw "Cannot find base branch", +# surfaced as a scary "Branch guard warning" on the very first workflow run +# of a new project. The check should silently no-op instead — the first task +# creates the base branch via its orphan worktree squash-merge. +$unbornRepo = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-unborn-$([System.Guid]::NewGuid().ToString().Substring(0,8))" +New-Item -ItemType Directory -Path $unbornRepo -Force | Out-Null +try { + & git -C $unbornRepo init --quiet 2>$null | Out-Null + $threw = $false + $result = $null + try { + $result = Assert-OnBaseBranch -ProjectRoot $unbornRepo + } 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 $unbornRepo 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 $unbornRepo -Recurse -Force -ErrorAction SilentlyContinue +} + # ═══════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════