From 75bf87d6a892e568a3ca581619b7a753a3e8041a Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:41:25 -0400 Subject: [PATCH 1/2] fix(worktree): keep tracked .bot tree out of shared git exclude Ensure-DotbotWorktreeExcludes writes to the common-dir .git/info/exclude, which is shared with the operator main checkout, so the .bot/workspace/tasks, .bot/content, .bot/hooks and .bot/settings entries silently ignored tracked files in the project itself. Drop those entries and scope the worktree-local suppression to a nested .gitignore inside the generated content, hooks and settings copies instead. Complete-TaskWorktree now logs a warning when staging task state fails rather than discarding git output, and doctor reports any workspace tree that is still ignored along with the rule responsible. Refs: #681 --- src/cli/doctor.ps1 | 33 +++++++++ .../Dotbot.Worktree/Dotbot.Worktree.psm1 | 16 +++-- tests/Test-Components.ps1 | 67 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/cli/doctor.ps1 b/src/cli/doctor.ps1 index 5ca109db..9702a2fa 100644 --- a/src/cli/doctor.ps1 +++ b/src/cli/doctor.ps1 @@ -333,6 +333,39 @@ if ($writeHostCount -eq 0 -and $consoleErrorCount -eq 0) { Write-BlankLine +Write-DotbotSection -Title "WORKSPACE TRACKING" + +$trackedWorkspaceTrees = [ordered]@{ + '.bot/workspace/tasks' = '.bot/workspace/tasks/standalone/task.json' + '.bot/workspace/decisions' = '.bot/workspace/decisions/decision.md' + '.bot/content' = '.bot/content/workflows/workflow.json' + '.bot/hooks' = '.bot/hooks/verify/hook.ps1' + '.bot/settings' = '.bot/settings/settings.json' +} +$projectRoot = Split-Path $BotRoot -Parent + +$null = & git -C $projectRoot rev-parse --is-inside-work-tree 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Check "Workspace tracking" "not a git repository — skipped" Pass +} else { + $ignoredTrees = 0 + foreach ($tree in $trackedWorkspaceTrees.Keys) { + $source = & git -C $projectRoot check-ignore -v --no-index -- $trackedWorkspaceTrees[$tree] 2>$null + if ($LASTEXITCODE -ne 0 -or -not $source) { continue } + $ignoredTrees++ + $rule = ("$($source | Select-Object -First 1)" -split "`t")[0] + Write-Check $tree "ignored by $rule — changes here are invisible to git" Fail + } + if ($ignoredTrees -eq 0) { + Write-Check "Workspace tracking" "$($trackedWorkspaceTrees.Count) tracked trees, none ignored" Pass + } else { + Write-DotbotCommand "Remove the rule that ignores .bot/ from your .gitignore (or core.excludesFile / .git/info/exclude)" + Write-DotbotCommand "Then re-track the workspace tree: git add .bot/workspace/" + } +} + +Write-BlankLine + # ═══════════════════════════════════════════════════════════════════ # SUMMARY # ═══════════════════════════════════════════════════════════════════ diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index de2630b3..6247a145 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -565,11 +565,6 @@ function Ensure-DotbotWorktreeExcludes { '.bot/.control/' '.bot/.handoffs' '.bot/.handoffs/' - '.bot/workspace/tasks' - '.bot/workspace/tasks/' - '.bot/content/' - '.bot/hooks/' - '.bot/settings/' $markerEnd ) @@ -851,6 +846,10 @@ function Initialize-DotbotWorktreeExecutionEnvironment { Copy-DotbotDirectoryContents -Source (Join-Path $frameworkRoot 'content/settings') -Destination (Join-Path $worktreeBotRoot 'settings') Copy-DotbotDirectoryContents -Source (Join-Path $BotRoot 'settings') -Destination (Join-Path $worktreeBotRoot 'settings') + foreach ($name in @('content', 'hooks', 'settings')) { + Set-Content -Path (Join-Path (Join-Path $worktreeBotRoot $name) '.gitignore') -Value '*' -Encoding utf8NoBOM + } + Copy-DotbotProviderContent -WorktreePath $WorktreePath -BotRoot $BotRoot -FrameworkRoot $frameworkRoot Set-DotbotMcpServerJson -Path (Join-Path $WorktreePath '.mcp.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot Set-DotbotCodexMcpConfig -Path (Join-Path $WorktreePath '.codex/config.toml') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot @@ -1200,7 +1199,7 @@ function Restore-DotbotTaskStateBackup { $restorePath = Join-Path $tasksRoot ($key -replace '/', [System.IO.Path]::DirectorySeparatorChar) $restoreDir = Split-Path $restorePath -Parent if (-not (Test-Path -LiteralPath $restoreDir)) { - New-Item -LiteralPath $restoreDir -ItemType Directory -Force | Out-Null + New-Item -Path $restoreDir -ItemType Directory -Force | Out-Null } Write-TaskFileRawAtomic -Path $restorePath -RawContent $TaskBackup[$key] -TaskId (Get-BackupTaskIdFromJson $TaskBackup[$key]) } @@ -1877,7 +1876,10 @@ function Complete-TaskWorktree { # Commit current shared runtime state on main. Product workspace files # are branch-local and are replayed through Apply-TaskBranchPatch above. - git -C $ProjectRoot add .bot/workspace/tasks/ .bot/workspace/decisions/ 2>$null + $stateAddOutput = git -C $ProjectRoot add .bot/workspace/tasks/ .bot/workspace/decisions/ 2>&1 + if ($LASTEXITCODE -ne 0 -and (Get-Command Write-BotLog -ErrorAction SilentlyContinue)) { + Write-BotLog -Level Warn -Message "Could not stage task state: $(@($stateAddOutput | ForEach-Object { "$_" }) -join ' ')" + } $stateStaged = git -C $ProjectRoot diff --cached --name-only 2>$null if ($stateStaged) { $stateCommitOutput = git -C $ProjectRoot ` diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index 6e3d7c61..8bba3bf6 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -158,6 +158,13 @@ if (Test-Path $worktreeManagerModule) { ($worktreeManagerSrc -match "'rebase_conflict'") -and ($worktreeManagerSrc -match "Merge conflict during squash-merge")) ` -Message "An add/add conflict on a single file (e.g. .gitignore) must reach the operator as a 'rebase_conflict' pending_question naming the file, not a generic 'merge_command_failed' with empty conflict_files. See botdot task d954f7e7 incident on 2026-05-14." + Assert-True -Name "Worktree exclude block leaves the tracked .bot tree alone" ` + -Condition (($worktreeManagerSrc -match [regex]::Escape("'.mcp.json'")) -and + ($worktreeManagerSrc -notmatch [regex]::Escape("'.bot/workspace/tasks/'")) -and + ($worktreeManagerSrc -notmatch [regex]::Escape("'.bot/content/'")) -and + ($worktreeManagerSrc -notmatch [regex]::Escape("'.bot/hooks/'")) -and + ($worktreeManagerSrc -notmatch [regex]::Escape("'.bot/settings/'"))) ` + -Message "Ensure-DotbotWorktreeExcludes writes the shared common-dir .git/info/exclude, so any .bot/ entry there also ignores the operator's main checkout. The tracked workspace/content/hooks/settings tree must stay out of that block (issue #681); only generated, never-tracked paths belong in it." # ─────────────────────────────────────────────────────────────────────── # End-to-end: Apply-TaskBranchPatch on a real two-branch fixture with @@ -292,6 +299,66 @@ if (Test-Path $worktreeManagerModule) { Remove-Item -Path $divTmp -Recurse -Force -ErrorAction SilentlyContinue } + $exclTmp = Join-Path ([IO.Path]::GetTempPath()) ('dotbot-excl-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + $exclWorktree = "$exclTmp-wt" + New-Item -ItemType Directory -Path $exclTmp -Force | Out-Null + try { + Push-Location $exclTmp + try { + & git init --quiet 2>$null + & git config user.email 'test@example.com' 2>$null + & git config user.name 'Test' 2>$null + & git checkout -b main --quiet 2>$null + 'base' | Set-Content -Path (Join-Path $exclTmp 'README.md') -NoNewline + New-Item -ItemType Directory -Path (Join-Path $exclTmp '.bot/workspace/tasks/standalone') -Force | Out-Null + '' | Set-Content -Path (Join-Path $exclTmp '.bot/workspace/tasks/standalone/.gitkeep') -NoNewline + & git add -A 2>$null + & git commit -m 'dotbot init' --quiet 2>$null + } finally { + Pop-Location + } + + Add-Content -Path (Join-Path $exclTmp '.git/info/exclude') -Value @( + '# dotbot generated execution environment: start' + '.mcp.json' + '.bot/workspace/tasks' + '.bot/workspace/tasks/' + '.bot/content/' + '# dotbot generated execution environment: end' + ) + + & git -C $exclTmp worktree add --quiet -b 'task/excl-fixture' $exclWorktree main 2>$null + + $excludeFn = (Get-Module Dotbot.Worktree).Invoke({ Get-Command Ensure-DotbotWorktreeExcludes }) + & $excludeFn -WorktreePath $exclWorktree + + & git -C $exclTmp check-ignore -q --no-index -- '.bot/workspace/tasks' 2>$null + Assert-True -Name "Worktree setup leaves .bot/workspace/tasks unignored in the main repo" ` + -Condition ($LASTEXITCODE -ne 0) ` + -Message "check-ignore still reports the tracked task tree as ignored: $(& git -C $exclTmp check-ignore -v --no-index -- '.bot/workspace/tasks' 2>$null)" + & git -C $exclTmp check-ignore -q --no-index -- '.bot/content/workflows/workflow.json' 2>$null + Assert-True -Name "Worktree setup leaves .bot/content unignored in the main repo" ` + -Condition ($LASTEXITCODE -ne 0) ` + -Message "Project-tier overrides under .bot/content are tracked and are FrameworkIntegrity protected paths" + & git -C $exclTmp check-ignore -q --no-index -- '.mcp.json' 2>$null + Assert-True -Name "Worktree setup still ignores generated .mcp.json" ` + -Condition ($LASTEXITCODE -eq 0) ` + -Message "The generated execution environment must stay ignored, otherwise 01-git-clean fails every task" + + $exclRunDir = Join-Path $exclTmp '.bot/workspace/tasks/workflow-runs/probe' + New-Item -ItemType Directory -Path $exclRunDir -Force | Out-Null + Set-Content -Path (Join-Path $exclRunDir 'run.json') -Value '{"id":"wr_probe"}' + & git -C $exclTmp add .bot/workspace/tasks/ 2>$null + $exclStaged = @(& git -C $exclTmp diff --cached --name-only 2>$null) + Assert-True -Name "git add stages new task state without -f" ` + -Condition ($exclStaged -contains '.bot/workspace/tasks/workflow-runs/probe/run.json') ` + -Message "Complete-TaskWorktree stages this tree with a plain git add (no -f); staged set was: $($exclStaged -join ', ')" + } finally { + & git -C $exclTmp worktree remove --force $exclWorktree 2>$null + Remove-Item -Path $exclWorktree -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $exclTmp -Recurse -Force -ErrorAction SilentlyContinue + } + # ─────────────────────────────────────────────────────────────────────── # Write-TaskFileRawAtomic: byte-fidelity round-trip. Backup-restore in # Complete-TaskWorktree relies on the raw helper preserving the exact From f0eeea3e34b0b82dccec808b291b0b4a08d396d7 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:03:54 -0400 Subject: [PATCH 2/2] feat(doctor): resolve worktree tracking checks to the main checkout The WORKSPACE TRACKING section walked the path it was handed, so running doctor from a task worktree inspected that worktree's generated .gitignore files and flagged the tracked .bot tree as hidden. It now maps back to the main repository via git rev-parse --git-common-dir, reporting identically from either checkout, and the E2E worktree test asserts the clean result. Refs: #681 --- CHANGELOG.md | 4 ++++ src/cli/doctor.ps1 | 12 ++++++++++-- .../Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 | 2 +- tests/Test-Components.ps1 | 8 ++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef457d92..0b3e942b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,14 @@ All notable changes to dotbot are documented in this file. The format follows [K ## [Unreleased] ### Added +- **`dotbot doctor` WORKSPACE TRACKING section** reports any tracked `.bot/` tree hidden by an ignore rule, naming the rule responsible (`file:line:pattern`) so the offending entry can be found. Covers `.bot/workspace/tasks`, `.bot/workspace/decisions`, `.bot/content`, `.bot/hooks` and `.bot/settings`. Run from a task worktree it resolves back to the main repository via `git rev-parse --git-common-dir`, so it reports identically from either checkout. ### Changed ### Fixed +- **Creating a task worktree no longer ignores the project's own `.bot/` tree.** `Ensure-DotbotWorktreeExcludes` writes `.git/info/exclude`, which git keeps in the shared common directory, so its `.bot/workspace/tasks`, `.bot/content`, `.bot/hooks` and `.bot/settings` entries silently ignored those tracked trees in the operator's main checkout: `git add .bot/workspace/tasks/` became a no-op, workflow run state accumulated invisibly (`git status` stayed clean and `git clean -fd` could not remove it), and `FrameworkIntegrity`'s `git status` scan passed over three of its protected paths. Those entries are gone; suppression inside the worktree now uses a nested `.gitignore` in each generated copy, which cannot reach the main checkout. Already-affected repositories self-heal on their next task — the marker block is rewritten in place. (#681) +- **`Restore-DotbotTaskStateBackup` threw when recreating a task-state directory.** `New-Item -LiteralPath` is not a valid parameter; the call failed as soon as the task tree became visible to git again, surfacing as `failure_kind: exception` out of `Complete-TaskWorktree`. +- **`Complete-TaskWorktree` no longer discards a failed task-state staging.** The `git add` of `.bot/workspace/tasks/` and `.bot/workspace/decisions/` sent stderr to `$null` and never checked the exit code, so a refusal was invisible; it now logs a warning with git's output. ### Removed diff --git a/src/cli/doctor.ps1 b/src/cli/doctor.ps1 index 9702a2fa..d11e0d0a 100644 --- a/src/cli/doctor.ps1 +++ b/src/cli/doctor.ps1 @@ -344,10 +344,18 @@ $trackedWorkspaceTrees = [ordered]@{ } $projectRoot = Split-Path $BotRoot -Parent -$null = & git -C $projectRoot rev-parse --is-inside-work-tree 2>$null -if ($LASTEXITCODE -ne 0) { +$gitCommonDir = & git -C $projectRoot rev-parse --git-common-dir 2>$null +if ($LASTEXITCODE -ne 0 -or -not $gitCommonDir) { Write-Check "Workspace tracking" "not a git repository — skipped" Pass } else { + $commonCandidate = if ([System.IO.Path]::IsPathRooted($gitCommonDir)) { + $gitCommonDir + } else { + Join-Path $projectRoot $gitCommonDir + } + $resolvedCommon = Resolve-Path -LiteralPath $commonCandidate -ErrorAction SilentlyContinue + if ($resolvedCommon) { $projectRoot = Split-Path $resolvedCommon.Path -Parent } + $ignoredTrees = 0 foreach ($tree in $trackedWorkspaceTrees.Keys) { $source = & git -C $projectRoot check-ignore -v --no-index -- $trackedWorkspaceTrees[$tree] 2>$null diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 6247a145..41f68264 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -847,7 +847,7 @@ function Initialize-DotbotWorktreeExecutionEnvironment { Copy-DotbotDirectoryContents -Source (Join-Path $BotRoot 'settings') -Destination (Join-Path $worktreeBotRoot 'settings') foreach ($name in @('content', 'hooks', 'settings')) { - Set-Content -Path (Join-Path (Join-Path $worktreeBotRoot $name) '.gitignore') -Value '*' -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path (Join-Path $worktreeBotRoot $name) '.gitignore') -Value '*' -Encoding utf8NoBOM } Copy-DotbotProviderContent -WorktreePath $WorktreePath -BotRoot $BotRoot -FrameworkRoot $frameworkRoot diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index 8bba3bf6..ad545e29 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -663,6 +663,14 @@ if (Test-Path $worktreeManagerModule) { -Condition ($generatedStatus.Count -eq 0) ` -Message "Generated files should not appear in git status: $($generatedStatus -join '; ')" + $doctorOut = & pwsh -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $dotbotDir 'src/cli/doctor.ps1') ` + -BotRoot (Join-Path $e2eResult.worktree_path '.bot') 2>&1 + $doctorText = (@($doctorOut | ForEach-Object { ConvertTo-SanitizedConsoleText "$_" }) -join "`n") + Assert-True -Name "E2E: doctor reports no ignored workspace tree from inside a task worktree" ` + -Condition ($doctorText -notmatch 'changes here are invisible to git') ` + -Message "doctor flagged the worktree's own generated .gitignore files: $doctorText" + Assert-PathNotExists -Name "E2E: main checkout still has no .mcp.json" -Path (Join-Path $e2eRoot ".mcp.json") Assert-PathNotExists -Name "E2E: main checkout still has no .claude/" -Path (Join-Path $e2eRoot ".claude") Assert-PathNotExists -Name "E2E: main checkout still has no .opencode/" -Path (Join-Path $e2eRoot ".opencode")