Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions src/cli/doctor.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,47 @@ 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

$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
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
# ═══════════════════════════════════════════════════════════════════
Expand Down
16 changes: 9 additions & 7 deletions src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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 -LiteralPath (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
Expand Down Expand Up @@ -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])
}
Expand Down Expand Up @@ -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 `
Expand Down
75 changes: 75 additions & 0 deletions tests/Test-Components.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -596,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")
Expand Down
Loading