diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef457d92..cb234574 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,29 @@ All notable changes to dotbot are documented in this file. The format follows [K
### Fixed
+- **Task merges no longer target a stale `base_branch`.** `Complete-TaskWorktree` took its integration
+ target from the value `worktree-map.json` recorded when the worktree was created and never reconciled
+ it with the branch the main checkout was on. With a clean tree it silently squash-merged the task into
+ that recorded branch — usually the trunk — and reported plain success; with a dirty tracked file under
+ `.bot/workspace/decisions/` it failed with `Failed to checkout … (currently on: )` and
+ parked the run. The target is now resolved by precedence: an explicit `-BaseBranch` from the caller (a
+ workflow run passes its integration branch), then a configured `git.base_branch`, then the recorded
+ value when it matches the checkout, then the checked-out branch — adopted, reconciled back into the
+ map, and named in the result message. `task/*` branches and detached HEADs are never adopted.
+- **The pre-merge stash no longer excludes `.bot/workspace/decisions/`.** That tree is tracked and is
+ committed by the same function, but — unlike `.bot/workspace/tasks/` — it was never scrubbed or backed
+ up, so a dirty file there blocked the very checkout the stash exists to enable.
+- **The pre-merge stash is popped even when `git stash push` exits non-zero.** `git stash push -u` can
+ stash successfully and still exit 1 over an advisory (`The following paths are ignored by one of your
+ .gitignore files: .bot/workspace/tasks`), which made dotbot skip the pop and silently park the
+ operator's uncommitted work in a stash. Stash detection now compares `refs/stash` before and after.
+- **`Assert-OnBaseBranch` reports why a checkout failed and can honour `git.base_branch`.** It discarded
+ git's stderr, so every blocker — a dirty tracked file, a branch held by another linked worktree, a
+ file lock — produced the same unactionable message. It also had no `-BotRoot` parameter, so its
+ no-`-BranchName` fallback could only ever resolve `main`/`master`; the three cleanup call sites in
+ `Invoke-WorkflowProcess.ps1` used that fallback and yanked the working copy off a configured base
+ branch on any failed or skipped task.
+
### Removed
## [4.0.2] - 2026-07-09
diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1
index b40f5666..0800e796 100644
--- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1
+++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psd1
@@ -22,6 +22,7 @@
'Resolve-DotbotBaseBranch'
'Resolve-MainBranch'
'Assert-OnBaseBranch'
+ 'Update-TaskWorktreeBaseBranch'
'Stop-WorktreeProcesses'
'Invoke-Git'
'Remove-Junctions'
diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1
index de2630b3..2c84908a 100644
--- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1
+++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1
@@ -218,16 +218,22 @@ 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.
+ base branch 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.
+
+ .PARAMETER BotRoot
+ Optional. Forwarded to Resolve-MainBranch so the configured git.base_branch is
+ honoured when -BranchName is omitted. Without it the fallback can only ever
+ resolve main/master, which silently defeats a project's configured trunk.
#>
param(
[Parameter(Mandatory)][string]$ProjectRoot,
- [string]$BranchName
+ [string]$BranchName,
+ [string]$BotRoot
)
if (-not $BranchName) {
- $BranchName = Resolve-MainBranch -ProjectRoot $ProjectRoot
+ $BranchName = Resolve-MainBranch -ProjectRoot $ProjectRoot -BotRoot $BotRoot
}
if (-not $BranchName) {
throw "Cannot find base branch in $ProjectRoot"
@@ -240,14 +246,142 @@ function Assert-OnBaseBranch {
}
}
if ($currentBranch -ne $BranchName) {
- git -C $ProjectRoot checkout $BranchName 2>&1 | Out-Null
+ $checkoutOutput = git -C $ProjectRoot checkout $BranchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
- throw "Failed to checkout $BranchName in $ProjectRoot (currently on: $currentBranch)"
+ $reason = $checkoutOutput.Trim()
+ $detail = if ($reason) { ": $reason" } else { "" }
+ throw "Failed to checkout $BranchName in $ProjectRoot (currently on: $currentBranch)$detail"
}
}
return $BranchName
}
+function Resolve-TaskMergeTarget {
+ <#
+ .SYNOPSIS
+ Decide which branch a completed task should be integrated into.
+
+ .DESCRIPTION
+ The worktree-map records a base_branch when the task worktree is created. That
+ value used to be authoritative forever, which meant a run whose checkout had
+ moved on — or a stale entry reused across runs — silently integrated the task
+ into whatever was recorded, typically the trunk. Precedence, highest first:
+
+ 1. RequestedBaseBranch — the caller (a workflow run) knows its integration
+ branch. Most specific wins, and the stale record is reconciled.
+ 2. A configured git.base_branch — an explicit operator declaration. It must
+ outrank adoption, or pointing dotbot at a non-default trunk would be
+ silently undone whenever the checkout happened to sit elsewhere (#466).
+ Delegated to Resolve-DotbotBaseBranch so a configured-but-missing branch
+ still fails fast.
+ 3. The recorded base, when it already equals the checked-out branch.
+ 4. The checked-out branch, when it differs from the record: the operator's
+ branch is treated as the truth and the record is reconciled. Deliberate
+ precedence decision — a checkout the operator moved is a stronger signal
+ than a value written when the worktree was created.
+ 5. The recorded base, when HEAD is detached (no branch to adopt) or when the
+ checkout sits on a task/* branch (never a valid integration target).
+ 6. Resolve-MainBranch, when nothing was recorded.
+
+ Adoption lives here and never inside Resolve-MainBranch / Resolve-DotbotBaseBranch:
+ those must stay HEAD-blind so they remain safe to call while the main repo is on
+ a task branch.
+
+ .OUTPUTS
+ Hashtable with: branch, source ('requested'|'recorded'|'adopted'|'resolved'),
+ recorded (the map's value, or $null), reason (human-readable, for logging).
+ #>
+ param(
+ [Parameter(Mandatory)][string]$ProjectRoot,
+ [string]$BotRoot,
+ $Entry,
+ [string]$RequestedBaseBranch
+ )
+
+ $recorded = $null
+ if ($Entry -and $Entry.base_branch) { $recorded = [string]$Entry.base_branch }
+
+ if (-not [string]::IsNullOrWhiteSpace($RequestedBaseBranch)) {
+ $why = if ($recorded -and $recorded -ne $RequestedBaseBranch) {
+ "caller supplied '$RequestedBaseBranch'; worktree-map recorded '$recorded'"
+ } else {
+ "caller supplied '$RequestedBaseBranch'"
+ }
+ return @{ branch = $RequestedBaseBranch; source = 'requested'; recorded = $recorded; reason = $why }
+ }
+
+ # A configured git.base_branch is an operator declaration, not an inference, so it
+ # outranks the checkout. Resolve-DotbotBaseBranch validates it and throws when the
+ # configured branch does not exist, which is the #466 fail-fast contract.
+ $configuredBase = $null
+ if ($BotRoot -and (Get-Command Get-MergedSettings -ErrorAction SilentlyContinue)) {
+ $merged = Get-MergedSettings -BotRoot $BotRoot
+ if ($merged -and $merged.PSObject.Properties['git'] -and $merged.git -and $merged.git.PSObject.Properties['base_branch']) {
+ if (-not [string]::IsNullOrWhiteSpace([string]$merged.git.base_branch)) {
+ $configuredBase = Resolve-DotbotBaseBranch -ProjectRoot $ProjectRoot -BotRoot $BotRoot
+ }
+ }
+ }
+ if ($configuredBase) {
+ return @{ branch = $configuredBase; source = 'configured'; recorded = $recorded
+ reason = "git.base_branch is configured as '$configuredBase'" }
+ }
+
+ $rawCurrent = git -C $ProjectRoot rev-parse --abbrev-ref HEAD 2>$null
+ $currentBranch = if ($rawCurrent) { "$rawCurrent".Trim() } else { '' }
+ $isDetached = ($currentBranch -eq 'HEAD' -or [string]::IsNullOrWhiteSpace($currentBranch))
+
+ if ($recorded) {
+ if ($currentBranch -eq $recorded) {
+ return @{ branch = $recorded; source = 'recorded'; recorded = $recorded
+ reason = "worktree-map base '$recorded' matches the checkout" }
+ }
+ if ($isDetached) {
+ return @{ branch = $recorded; source = 'recorded'; recorded = $recorded
+ reason = "HEAD is detached; using the recorded base '$recorded'" }
+ }
+ if ($currentBranch -like 'task/*') {
+ return @{ branch = $recorded; source = 'recorded'; recorded = $recorded
+ reason = "checkout is on task branch '$currentBranch'; not a valid integration target, using recorded base '$recorded'" }
+ }
+ return @{ branch = $currentBranch; source = 'adopted'; recorded = $recorded
+ reason = "worktree-map recorded '$recorded' but the checkout is on '$currentBranch'; adopting the checked-out branch" }
+ }
+
+ $resolved = Resolve-MainBranch -ProjectRoot $ProjectRoot -BotRoot $BotRoot
+ return @{ branch = $resolved; source = 'resolved'; recorded = $null
+ reason = "no base recorded for this worktree; resolved '$resolved'" }
+}
+
+function Update-TaskWorktreeBaseBranch {
+ <#
+ .SYNOPSIS
+ Reconcile the base_branch recorded for a task worktree, under the map lock.
+
+ .DESCRIPTION
+ Keeps a stale record from surviving a retry. New-TaskWorktree only writes a map
+ entry when the task id is absent, so without this the recorded base is never
+ corrected once written.
+ #>
+ param(
+ [Parameter(Mandatory)][string]$TaskId,
+ [Parameter(Mandatory)][string]$BaseBranch,
+ [string]$BotRoot
+ )
+ Invoke-WorktreeMapLocked -BotRoot $BotRoot -Action {
+ $lockedMap = Read-WorktreeMap -BotRoot $BotRoot
+ if (-not $lockedMap.ContainsKey($TaskId)) { return }
+ $lockedEntry = $lockedMap[$TaskId]
+ if ($lockedEntry -is [hashtable]) {
+ $lockedEntry['base_branch'] = $BaseBranch
+ } else {
+ $lockedEntry | Add-Member -NotePropertyName 'base_branch' -NotePropertyValue $BaseBranch -Force
+ }
+ $lockedMap[$TaskId] = $lockedEntry
+ Write-WorktreeMap -Map $lockedMap -BotRoot $BotRoot
+ }
+}
+
# ── Cross-process mutual exclusion ───────────────────────────────────────────
# Run a script block under an OS-level named mutex. Acquisition blocks with NO
# timeout and NO poll loop; if the holding process dies, the kernel releases the
@@ -1631,7 +1765,8 @@ function Complete-TaskWorktree {
# that only needs to reach origin once — after the last task — instead
# of on every single task completion (each push fires the remote's
# full CI pipeline).
- [switch]$SkipRemotePush
+ [switch]$SkipRemotePush,
+ [string]$BaseBranch
)
$map = Read-WorktreeMap -BotRoot $BotRoot
@@ -1661,11 +1796,20 @@ function Complete-TaskWorktree {
$mergeLock = Enter-WorkspaceMergeLock -BotRoot $BotRoot
try {
try {
- # Determine target base branch — prefer the value recorded at worktree creation
- # (immune to HEAD drift on the main repo); fall back to explicit main/master lookup.
- $baseBranch = $entry.base_branch ?? (Resolve-MainBranch -ProjectRoot $ProjectRoot -BotRoot $BotRoot)
+ $mergeTarget = Resolve-TaskMergeTarget -ProjectRoot $ProjectRoot -BotRoot $BotRoot `
+ -Entry $entry -RequestedBaseBranch $BaseBranch
+ $baseBranch = $mergeTarget.branch
if (-not $baseBranch) { throw "Cannot determine base branch for task $TaskId" }
+ $targetReconciled = $false
+ if ($mergeTarget.recorded -and $mergeTarget.recorded -ne $baseBranch) {
+ $targetReconciled = $true
+ if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) {
+ Write-BotLog -Level Warn -Message "Task $TaskId integration target reconciled: $($mergeTarget.reason)"
+ }
+ Update-TaskWorktreeBaseBranch -TaskId $TaskId -BaseBranch $baseBranch -BotRoot $BotRoot
+ }
+
# Kill any processes still running in the worktree (dev servers, file watchers, etc.)
$killedCount = Stop-WorktreeProcesses -WorktreePath $worktreePath
if ($killedCount -gt 0) {
@@ -1741,11 +1885,17 @@ function Complete-TaskWorktree {
# Stash remaining dirty state EXCLUDING task files (task state is managed by backup-restore).
# Including task files in the stash causes stale state to be reintroduced after the state commit
# when git stash pop runs, contaminating the next task's backup.
+ $stashRefBefore = git -C $ProjectRoot rev-parse --verify --quiet refs/stash 2>$null
$stashOutput = git -C $ProjectRoot stash push -u -m "dotbot-pre-merge-$TaskId" -- `
'.' `
- ':!.bot/workspace/tasks/' `
- ':!.bot/workspace/decisions/' 2>&1
- $wasStashed = $LASTEXITCODE -eq 0 -and "$stashOutput" -notmatch 'No local changes'
+ ':!.bot/workspace/tasks/' 2>&1
+ $stashRefAfter = git -C $ProjectRoot rev-parse --verify --quiet refs/stash 2>$null
+ $wasStashed = [bool]$stashRefAfter -and ("$stashRefAfter".Trim() -ne "$stashRefBefore".Trim())
+ if (-not $wasStashed -and "$stashOutput" -notmatch 'No local changes') {
+ if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) {
+ Write-BotLog -Level Debug -Message "Pre-merge stash created nothing for task $TaskId : $(("$stashOutput").Trim())"
+ }
+ }
# Assert main repo is on the base branch after task state is backed up
# and non-task dirty state is stashed. This lets detached HEAD checkouts
@@ -1976,10 +2126,16 @@ function Complete-TaskWorktree {
Write-WorktreeMap -Map $lockedMap -BotRoot $BotRoot
}
+ $mergedMessage = if ($targetReconciled) {
+ "Squash-merged to $baseBranch and cleaned up (target reconciled: $($mergeTarget.reason))"
+ } else {
+ "Squash-merged to $baseBranch and cleaned up"
+ }
+
return @{
success = $true
merge_commit = $mergeCommit
- message = "Squash-merged to $baseBranch and cleaned up"
+ message = $mergedMessage
conflict_files = @()
failure_kind = $null
failure_detail = ""
@@ -2312,6 +2468,7 @@ Export-ModuleMember -Function @(
'Resolve-DotbotBaseBranch'
'Resolve-MainBranch'
'Assert-OnBaseBranch'
+ 'Update-TaskWorktreeBaseBranch'
'Stop-WorktreeProcesses'
'Invoke-Git'
'Remove-Junctions'
diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1
index 5b94bb06..2366861f 100644
--- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1
+++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1
@@ -130,6 +130,10 @@ function Initialize-DotbotTaskWorktreeForProcess {
$wtInfo = Get-TaskWorktreeInfo -TaskId $Task.id -BotRoot $BotRoot
if ($wtInfo -and (Test-Path $wtInfo.worktree_path)) {
Write-Status "Using worktree: $($wtInfo.worktree_path)" -Type Info
+ if (-not [string]::IsNullOrWhiteSpace($BaseBranch) -and $wtInfo.base_branch -ne $BaseBranch) {
+ Write-Status "Reconciling recorded base '$($wtInfo.base_branch)' to '$BaseBranch' for reused worktree" -Type Info
+ Update-TaskWorktreeBaseBranch -TaskId $Task.id -BaseBranch $BaseBranch -BotRoot $BotRoot
+ }
return @{
skipped = $false
worktree_path = $wtInfo.worktree_path
@@ -138,7 +142,7 @@ function Initialize-DotbotTaskWorktreeForProcess {
}
}
- $guardArgs = @{ ProjectRoot = $ProjectRoot }
+ $guardArgs = @{ ProjectRoot = $ProjectRoot; BotRoot = $BotRoot }
if (-not [string]::IsNullOrWhiteSpace($BaseBranch)) { $guardArgs.BranchName = $BaseBranch }
try { Assert-OnBaseBranch @guardArgs | Out-Null } catch {
Write-Status "Branch guard warning: $($_.Exception.Message)" -Type Warn
@@ -1266,6 +1270,11 @@ if ($RunId) {
}
}
+$baseBranchGuardArgs = @{ ProjectRoot = $projectRoot; BotRoot = $botRoot }
+if (-not [string]::IsNullOrWhiteSpace($integrationBranch)) {
+ $baseBranchGuardArgs.BranchName = $integrationBranch
+}
+
$loopIteration = 0
try {
while ($true) {
@@ -1708,7 +1717,8 @@ try {
if ($typeSuccess) {
$mergeTargetLabel = if ($integrationBranch) { $integrationBranch } else { 'main' }
Write-Status "Merging task branch to $mergeTargetLabel..." -Type Process
- $mergeResult = Complete-TaskWorktree -TaskId $task.id -ProjectRoot $projectRoot -BotRoot $botRoot -SkipRemotePush:($null -ne $integrationBranch)
+ $mergeResult = Complete-TaskWorktree -TaskId $task.id -ProjectRoot $projectRoot -BotRoot $botRoot `
+ -BaseBranch $integrationBranch -SkipRemotePush:($null -ne $integrationBranch)
if ($mergeResult.success) {
Write-Status "Merged: $($mergeResult.message)" -Type Complete
Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Squash-merged to ${mergeTargetLabel}: $($task.name)"
@@ -1771,7 +1781,7 @@ try {
$cleanupMap.Remove($task.id)
Write-WorktreeMap -Map $cleanupMap -BotRoot $botRoot
}
- try { Assert-OnBaseBranch -ProjectRoot $projectRoot | Out-Null } catch { Write-BotLog -Level Warn -Message "Task operation failed" -Exception $_ }
+ try { Assert-OnBaseBranch @baseBranchGuardArgs | Out-Null } catch { Write-BotLog -Level Warn -Message "Task operation failed" -Exception $_ }
}
}
@@ -2286,7 +2296,8 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status
if ($worktreePath) {
$mergeTargetLabel = if ($integrationBranch) { $integrationBranch } else { 'main' }
Write-Status "Merging task branch to $mergeTargetLabel..." -Type Process
- $mergeResult = Complete-TaskWorktree -TaskId $task.id -ProjectRoot $projectRoot -BotRoot $botRoot -SkipRemotePush:($null -ne $integrationBranch)
+ $mergeResult = Complete-TaskWorktree -TaskId $task.id -ProjectRoot $projectRoot -BotRoot $botRoot `
+ -BaseBranch $integrationBranch -SkipRemotePush:($null -ne $integrationBranch)
if ($mergeResult.success) {
Write-Status "Merged: $($mergeResult.message)" -Type Complete
Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Squash-merged to ${mergeTargetLabel}: $($task.name)"
@@ -2387,7 +2398,7 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status
$cleanupMap.Remove($task.id)
Write-WorktreeMap -Map $cleanupMap -BotRoot $botRoot
}
- try { Assert-OnBaseBranch -ProjectRoot $projectRoot | Out-Null } catch { Write-BotLog -Level Warn -Message "Task operation failed" -Exception $_ }
+ try { Assert-OnBaseBranch @baseBranchGuardArgs | Out-Null } catch { Write-BotLog -Level Warn -Message "Task operation failed" -Exception $_ }
}
}
$processData.heartbeat_status = "Terminal ($taskTerminalState): $($task.name)"
@@ -2410,7 +2421,7 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status
Write-WorktreeMap -Map $cleanupMap -BotRoot $botRoot
}
# Re-assert base branch after failed-task cleanup (Fix: wrong-branch merge)
- try { Assert-OnBaseBranch -ProjectRoot $projectRoot | Out-Null } catch { Write-BotLog -Level Warn -Message "Task operation failed" -Exception $_ }
+ try { Assert-OnBaseBranch @baseBranchGuardArgs | Out-Null } catch { Write-BotLog -Level Warn -Message "Task operation failed" -Exception $_ }
}
}
diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1
index 6e3d7c61..153baf2a 100644
--- a/tests/Test-Components.ps1
+++ b/tests/Test-Components.ps1
@@ -694,6 +694,251 @@ if (Test-Path $worktreeManagerModule) {
Remove-TestProject -Path $completeRoot
}
+ $adoptProj = New-TestProjectFromGolden -Flavor 'default' -Prefix 'dotbot-test-adopt-target'
+ $adoptRoot = $adoptProj.ProjectRoot
+ $adoptBot = $adoptProj.BotDir
+ $adoptResult = $null
+ try {
+ & git -C $adoptRoot branch -M main 2>&1 | Out-Null
+
+ $adoptTaskId = "t_adopt01"
+ $adoptResult = New-TaskWorktree -TaskId $adoptTaskId -TaskName "adopts checked-out branch" `
+ -ProjectRoot $adoptRoot -BotRoot $adoptBot
+ Assert-True -Name "Adopt target: New-TaskWorktree returns success" `
+ -Condition ($adoptResult -and $adoptResult.success -eq $true) `
+ -Message "Expected success, got: $($adoptResult | ConvertTo-Json -Compress)"
+
+ if ($adoptResult -and $adoptResult.success -and (Test-Path $adoptResult.worktree_path)) {
+ $adoptMapPath = Join-Path $adoptBot ".control/worktree-map.json"
+ $recordedBefore = ((Get-Content $adoptMapPath -Raw | ConvertFrom-Json).$adoptTaskId).base_branch
+ Assert-Equal -Name "Adopt target: worktree-map records main at creation" `
+ -Expected "main" -Actual "$recordedBefore"
+
+ "adopted artifact" | Set-Content -Path (Join-Path $adoptResult.worktree_path "adopt-artifact.txt") -Encoding UTF8
+
+ & git -C $adoptRoot checkout -b workflow/integration-run --quiet 2>&1 | Out-Null
+ $adoptMainBefore = (& git -C $adoptRoot rev-parse main 2>$null).Trim()
+
+ $adoptMerge = Complete-TaskWorktree -TaskId $adoptTaskId -ProjectRoot $adoptRoot -BotRoot $adoptBot -SkipRemotePush
+ Assert-True -Name "Adopt target: merge succeeds against the checked-out branch" `
+ -Condition ($adoptMerge.success -eq $true) `
+ -Message "Expected success, got: $($adoptMerge | ConvertTo-Json -Depth 10 -Compress)"
+ Assert-True -Name "Adopt target: message names the reconciliation" `
+ -Condition ([bool]("$($adoptMerge.message)" -match 'reconciled')) `
+ -Message "Expected the reconciliation to be surfaced, got: $($adoptMerge.message)"
+ Assert-Equal -Name "Adopt target: merged into the checked-out branch, not main" `
+ -Expected "Squash-merged to workflow/integration-run" `
+ -Actual ("$($adoptMerge.message)" -replace ' and cleaned up.*$', '')
+ Assert-Equal -Name "Adopt target: main is left untouched" `
+ -Expected $adoptMainBefore `
+ -Actual ((& git -C $adoptRoot rev-parse main 2>$null).Trim())
+ Assert-Equal -Name "Adopt target: checkout stays on the operator's branch" `
+ -Expected "workflow/integration-run" `
+ -Actual ((& git -C $adoptRoot rev-parse --abbrev-ref HEAD 2>$null).Trim())
+ Assert-PathExists -Name "Adopt target: artifact present on the integration branch" `
+ -Path (Join-Path $adoptRoot "adopt-artifact.txt")
+ }
+ } finally {
+ if ($adoptResult -and $adoptResult.worktree_path -and (Test-Path $adoptResult.worktree_path)) {
+ & git -C $adoptRoot worktree remove -f $adoptResult.worktree_path 2>&1 | Out-Null
+ }
+ if ($adoptResult -and $adoptResult.branch_name) {
+ & git -C $adoptRoot branch -D $adoptResult.branch_name 2>&1 | Out-Null
+ }
+ Remove-TestProject -Path $adoptRoot
+ }
+
+ $reqProj = New-TestProjectFromGolden -Flavor 'default' -Prefix 'dotbot-test-requested-target'
+ $reqRoot = $reqProj.ProjectRoot
+ $reqBot = $reqProj.BotDir
+ $reqResult = $null
+ try {
+ & git -C $reqRoot branch -M main 2>&1 | Out-Null
+ & git -C $reqRoot branch workflow/explicit-run 2>&1 | Out-Null
+
+ $reqTaskId = "t_reqbase1"
+ $reqResult = New-TaskWorktree -TaskId $reqTaskId -TaskName "honours explicit base" `
+ -ProjectRoot $reqRoot -BotRoot $reqBot
+ if ($reqResult -and $reqResult.success -and (Test-Path $reqResult.worktree_path)) {
+ "explicit artifact" | Set-Content -Path (Join-Path $reqResult.worktree_path "explicit-artifact.txt") -Encoding UTF8
+
+ $reqMainBefore = (& git -C $reqRoot rev-parse main 2>$null).Trim()
+ $reqMerge = Complete-TaskWorktree -TaskId $reqTaskId -ProjectRoot $reqRoot -BotRoot $reqBot `
+ -BaseBranch 'workflow/explicit-run' -SkipRemotePush
+ Assert-True -Name "Explicit base: merge succeeds" `
+ -Condition ($reqMerge.success -eq $true) `
+ -Message "Expected success, got: $($reqMerge | ConvertTo-Json -Depth 10 -Compress)"
+ Assert-Equal -Name "Explicit base: -BaseBranch outranks the recorded value" `
+ -Expected "Squash-merged to workflow/explicit-run" `
+ -Actual ("$($reqMerge.message)" -replace ' and cleaned up.*$', '')
+ Assert-Equal -Name "Explicit base: main untouched" `
+ -Expected $reqMainBefore `
+ -Actual ((& git -C $reqRoot rev-parse main 2>$null).Trim())
+ }
+ } finally {
+ if ($reqResult -and $reqResult.worktree_path -and (Test-Path $reqResult.worktree_path)) {
+ & git -C $reqRoot worktree remove -f $reqResult.worktree_path 2>&1 | Out-Null
+ }
+ if ($reqResult -and $reqResult.branch_name) {
+ & git -C $reqRoot branch -D $reqResult.branch_name 2>&1 | Out-Null
+ }
+ Remove-TestProject -Path $reqRoot
+ }
+
+ $cfgProj = New-TestProjectFromGolden -Flavor 'default' -Prefix 'dotbot-test-configured-target'
+ $cfgRoot = $cfgProj.ProjectRoot
+ $cfgBot = $cfgProj.BotDir
+ $cfgResult = $null
+ try {
+ & git -C $cfgRoot branch -M main 2>&1 | Out-Null
+ & git -C $cfgRoot branch develop 2>&1 | Out-Null
+ '{ "git": { "base_branch": "develop" } }' |
+ Set-Content -Path (Join-Path $cfgProj.ControlDir "settings.json") -Encoding UTF8
+
+ $cfgResult = New-TaskWorktree -TaskId "t_cfgbase1" -TaskName "honours configured base" `
+ -ProjectRoot $cfgRoot -BotRoot $cfgBot
+ if ($cfgResult -and $cfgResult.success -and (Test-Path $cfgResult.worktree_path)) {
+ "configured artifact" | Set-Content -Path (Join-Path $cfgResult.worktree_path "cfg-artifact.txt") -Encoding UTF8
+
+ & git -C $cfgRoot checkout main --quiet 2>&1 | Out-Null
+ $cfgMainBefore = (& git -C $cfgRoot rev-parse main 2>$null).Trim()
+
+ $cfgMerge = Complete-TaskWorktree -TaskId "t_cfgbase1" -ProjectRoot $cfgRoot -BotRoot $cfgBot -SkipRemotePush
+ Assert-True -Name "#466 precedence: merge succeeds" `
+ -Condition ($cfgMerge.success -eq $true) `
+ -Message "Expected success, got: $($cfgMerge | ConvertTo-Json -Depth 10 -Compress)"
+ Assert-Equal -Name "#466 precedence: configured git.base_branch outranks the checked-out branch" `
+ -Expected "Squash-merged to develop" `
+ -Actual ("$($cfgMerge.message)" -replace ' and cleaned up.*$', '')
+ Assert-Equal -Name "#466 precedence: main is not written to" `
+ -Expected $cfgMainBefore `
+ -Actual ((& git -C $cfgRoot rev-parse main 2>$null).Trim())
+ }
+ } finally {
+ if ($cfgResult -and $cfgResult.worktree_path -and (Test-Path $cfgResult.worktree_path)) {
+ & git -C $cfgRoot worktree remove -f $cfgResult.worktree_path 2>&1 | Out-Null
+ }
+ if ($cfgResult -and $cfgResult.branch_name) {
+ & git -C $cfgRoot branch -D $cfgResult.branch_name 2>&1 | Out-Null
+ }
+ Remove-TestProject -Path $cfgRoot
+ }
+
+ $tbProj = New-TestProjectFromGolden -Flavor 'default' -Prefix 'dotbot-test-taskbranch-target'
+ $tbRoot = $tbProj.ProjectRoot
+ $tbBot = $tbProj.BotDir
+ $tbResult = $null
+ try {
+ & git -C $tbRoot branch -M main 2>&1 | Out-Null
+ $tbResult = New-TaskWorktree -TaskId "t_tbguard1" -TaskName "declines task branch" `
+ -ProjectRoot $tbRoot -BotRoot $tbBot
+ if ($tbResult -and $tbResult.success -and (Test-Path $tbResult.worktree_path)) {
+ "task branch guard" | Set-Content -Path (Join-Path $tbResult.worktree_path "tb-artifact.txt") -Encoding UTF8
+ & git -C $tbRoot checkout -b ('task' + '/unrelated-parked') --quiet 2>&1 | Out-Null
+
+ $tbMerge = Complete-TaskWorktree -TaskId "t_tbguard1" -ProjectRoot $tbRoot -BotRoot $tbBot -SkipRemotePush
+ Assert-True -Name "Task-branch guard: merge succeeds" `
+ -Condition ($tbMerge.success -eq $true) `
+ -Message "Expected success, got: $($tbMerge | ConvertTo-Json -Depth 10 -Compress)"
+ Assert-Equal -Name "Task-branch guard: falls back to main rather than adopting a task branch" `
+ -Expected "Squash-merged to main" `
+ -Actual ("$($tbMerge.message)" -replace ' and cleaned up.*$', '')
+ }
+ } finally {
+ if ($tbResult -and $tbResult.worktree_path -and (Test-Path $tbResult.worktree_path)) {
+ & git -C $tbRoot worktree remove -f $tbResult.worktree_path 2>&1 | Out-Null
+ }
+ if ($tbResult -and $tbResult.branch_name) {
+ & git -C $tbRoot branch -D $tbResult.branch_name 2>&1 | Out-Null
+ }
+ Remove-TestProject -Path $tbRoot
+ }
+
+ $decProj = New-TestProjectFromGolden -Flavor 'default' -Prefix 'dotbot-test-decisions-stash'
+ $decRoot = $decProj.ProjectRoot
+ $decBot = $decProj.BotDir
+ $decResult = $null
+ try {
+ & git -C $decRoot branch -M main 2>&1 | Out-Null
+
+ $decFile = Join-Path $decBot "workspace/decisions/proposed/dec-001-stash.md"
+ New-Item -ItemType Directory -Force -Path (Split-Path $decFile -Parent) | Out-Null
+ "on main" | Set-Content -Path $decFile -Encoding UTF8
+ & git -C $decRoot add -f -- ".bot/workspace/decisions/proposed/dec-001-stash.md" 2>&1 | Out-Null
+ & git -C $decRoot commit -m "docs: dec-001 on main" --quiet 2>&1 | Out-Null
+
+ $decResult = New-TaskWorktree -TaskId "t_decstash" -TaskName "stashes decisions" `
+ -ProjectRoot $decRoot -BotRoot $decBot
+ if ($decResult -and $decResult.success -and (Test-Path $decResult.worktree_path)) {
+ "decisions artifact" | Set-Content -Path (Join-Path $decResult.worktree_path "dec-artifact.txt") -Encoding UTF8
+
+ & git -C $decRoot checkout --detach main --quiet 2>&1 | Out-Null
+ "dirty and uncommitted" | Set-Content -Path $decFile -Encoding UTF8
+
+ $decMerge = Complete-TaskWorktree -TaskId "t_decstash" -ProjectRoot $decRoot -BotRoot $decBot -SkipRemotePush
+ Assert-True -Name "Decisions stash: merge succeeds with a dirty tracked decisions file" `
+ -Condition ($decMerge.success -eq $true) `
+ -Message "Expected success, got: $($decMerge | ConvertTo-Json -Depth 10 -Compress)"
+ Assert-PathExists -Name "Decisions stash: task artifact merged" `
+ -Path (Join-Path $decRoot "dec-artifact.txt")
+ $decStashes = @(& git -C $decRoot stash list 2>$null)
+ Assert-Equal -Name "Decisions stash: no stash entry left behind" `
+ -Expected 0 `
+ -Actual $decStashes.Count `
+ -Message "Leftover stash(es): $($decStashes -join ' | ')"
+ Assert-FileContains -Name "Decisions stash: the operator's dirty content is restored" `
+ -Path $decFile -Pattern 'dirty and uncommitted'
+ }
+ } finally {
+ if ($decResult -and $decResult.worktree_path -and (Test-Path $decResult.worktree_path)) {
+ & git -C $decRoot worktree remove -f $decResult.worktree_path 2>&1 | Out-Null
+ }
+ if ($decResult -and $decResult.branch_name) {
+ & git -C $decRoot branch -D $decResult.branch_name 2>&1 | Out-Null
+ }
+ Remove-TestProject -Path $decRoot
+ }
+
+ $aobRepo = New-TestProject -Prefix 'dotbot-test-assert-onbase'
+ try {
+ & git -C $aobRepo branch -M main 2>&1 | Out-Null
+ & git -C $aobRepo branch develop 2>&1 | Out-Null
+ $aobFn = (Get-Module Dotbot.Worktree).Invoke({ Get-Command Assert-OnBaseBranch })
+
+ Assert-Equal -Name "Assert-OnBaseBranch: returns the branch when already on it" `
+ -Expected "main" -Actual (& $aobFn -ProjectRoot $aobRepo -BranchName 'main')
+
+ & git -C $aobRepo checkout develop --quiet 2>&1 | Out-Null
+ Assert-Equal -Name "Assert-OnBaseBranch: checks out the requested branch" `
+ -Expected "main" -Actual (& $aobFn -ProjectRoot $aobRepo -BranchName 'main')
+ Assert-Equal -Name "Assert-OnBaseBranch: checkout actually moved HEAD" `
+ -Expected "main" -Actual ((& git -C $aobRepo rev-parse --abbrev-ref HEAD 2>$null).Trim())
+
+ & git -C $aobRepo checkout --detach main --quiet 2>&1 | Out-Null
+ Assert-Equal -Name "Assert-OnBaseBranch: returns the target from a detached HEAD" `
+ -Expected "main" -Actual (& $aobFn -ProjectRoot $aobRepo -BranchName 'main')
+ Assert-Equal -Name "Assert-OnBaseBranch: re-attaches a detached HEAD to the target" `
+ -Expected "main" -Actual ((& git -C $aobRepo rev-parse --abbrev-ref HEAD 2>$null).Trim())
+
+ & git -C $aobRepo checkout develop --quiet 2>&1 | Out-Null
+ "differs on develop" | Set-Content -Path (Join-Path $aobRepo "blocker.txt") -Encoding UTF8
+ & git -C $aobRepo add blocker.txt 2>&1 | Out-Null
+ & git -C $aobRepo commit -m "feat: blocker on develop" --quiet 2>&1 | Out-Null
+ "dirty and uncommitted" | Set-Content -Path (Join-Path $aobRepo "blocker.txt") -Encoding UTF8
+
+ $aobErr = ""
+ try { & $aobFn -ProjectRoot $aobRepo -BranchName 'main' } catch { $aobErr = "$($_.Exception.Message)" }
+ Assert-True -Name "Assert-OnBaseBranch: throws when the checkout is blocked" `
+ -Condition ([bool]$aobErr) -Message "Expected a throw for a blocked checkout"
+ Assert-True -Name "Assert-OnBaseBranch: throw still names both branches" `
+ -Condition ([bool]($aobErr -match 'currently on')) -Message "Got: $aobErr"
+ Assert-True -Name "Assert-OnBaseBranch: throw carries git's own reason" `
+ -Condition ([bool]($aobErr -match 'would be overwritten')) `
+ -Message "Expected git's stderr in the message, got: $aobErr"
+ } finally {
+ Remove-TestProject -Path $aobRepo
+ }
+
# Regression (#655): a multi-task workflow run passes -SkipRemotePush so
# the shared base branch is pushed once at the end of the run instead of
# once per task completion — each push fires the remote's full CI