headlesscode 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `headlesscode orchestrate cleanup` — deterministic, human-triggered worktree
|
|
3
|
+
* cleanup after a round's work is merged.
|
|
4
|
+
*
|
|
5
|
+
* Why this is a separate explicit command (never wired into the watcher's
|
|
6
|
+
* terminal-status transition): removing a worktree is the ONE operation in the
|
|
7
|
+
* whole pipeline with no undo, so it stays human-triggered — the same
|
|
8
|
+
* deliberate, separate-step philosophy as `headlesscode index` and the deploy
|
|
9
|
+
* gate.
|
|
10
|
+
*
|
|
11
|
+
* Eligibility — ALL gates below, computed FRESH every run (never cached from
|
|
12
|
+
* a prior round):
|
|
13
|
+
* 1. Worktree exists (a missing worktree is "already done", idempotent).
|
|
14
|
+
* 2. Group status is terminal (done|failed|needs-human|orphaned — see
|
|
15
|
+
* status.ts's isTerminalStatus). spawned/running/blocked is never touched.
|
|
16
|
+
* 3. Merge verified: `git merge-base --is-ancestor <branch> <base>` (exit 0)
|
|
17
|
+
* for local merges, or the GitHub PR `.merged` field when the group
|
|
18
|
+
* records pr.number (squash/rebase merges create a new hash — the local
|
|
19
|
+
* check alone would wrongly say "not merged"). If both are available and
|
|
20
|
+
* disagree, fail CLOSED (not merged). See merge-check.ts.
|
|
21
|
+
* 4. Worktree has no uncommitted changes: `git status --porcelain` shows
|
|
22
|
+
* only the harness's OWN untracked artifacts (run-worker.sh / run-qa.sh /
|
|
23
|
+
* the spawner / the executor write .harness.*, .qa.*, harness.log,
|
|
24
|
+
* qa.log, .headlesscode/, ORCHESTRATOR_TASK.md, .env, zoo-code) plus a
|
|
25
|
+
* small allowlist of known-safe regenerable residue workers routinely
|
|
26
|
+
* leave behind (a node_modules symlink/dir, __pycache__ dirs,
|
|
27
|
+
* *.pyc/*.pyo — issue #38). A human's untracked file or ANY tracked
|
|
28
|
+
* modification blocks cleanup — `git worktree remove` without --force is
|
|
29
|
+
* the final arbiter, and we never force.
|
|
30
|
+
* 5. No live worker process: <worktree>/.harness.pid and .qa.pid absent or
|
|
31
|
+
* the PID is not running (reuses watch.ts's isPidAlive liveness check —
|
|
32
|
+
* a stale pid FILE is not a running worker).
|
|
33
|
+
*
|
|
34
|
+
* Apply, per eligible group in state order (never reordered, so partial runs
|
|
35
|
+
* are resumable and idempotent):
|
|
36
|
+
* 1. Remove the harness's own untracked artifacts and the known-safe
|
|
37
|
+
* regenerable residue from the worktree. This step exists because `git
|
|
38
|
+
* worktree remove` (no --force) refuses on ANY untracked file — after a
|
|
39
|
+
* real round the harness leaves exactly those files, so without this the
|
|
40
|
+
* no-force removal the safety model requires would be impossible. Only
|
|
41
|
+
* the allowlisted paths are ever removed; if a human file raced in
|
|
42
|
+
* between the gate and this step, the no-force `git worktree remove`
|
|
43
|
+
* below refuses and the human file survives.
|
|
44
|
+
* 2. `git worktree remove <path>` (NO --force). A failure here means gate #4
|
|
45
|
+
* raced with something: that group's cleanup is aborted and reported,
|
|
46
|
+
* never forced.
|
|
47
|
+
* 3. `git branch -d <branch>` (lowercase -d — itself refuses to delete a
|
|
48
|
+
* branch not merged into HEAD/upstream: a second, independent safety net
|
|
49
|
+
* on top of gate #3). A failure here is reported and recorded in
|
|
50
|
+
* actions_taken, but is NOT a run failure: the irreversible operation
|
|
51
|
+
* (worktree removal) already succeeded, and re-running is a no-op.
|
|
52
|
+
* 4. Patch the group's state entry: add `cleaned_at` and append to
|
|
53
|
+
* `actions_taken`. The group entry itself is NEVER deleted — the round's
|
|
54
|
+
* history stays queryable (same philosophy as the `orphaned` status).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { execFileSync } from "node:child_process"
|
|
58
|
+
import * as fs from "node:fs"
|
|
59
|
+
import * as path from "node:path"
|
|
60
|
+
|
|
61
|
+
import { createAppAuthClient } from "../github/app-auth.js"
|
|
62
|
+
import { checkMergedByAncestor, checkMergedByGitHubPr, type MergeCheckResult } from "./merge-check.js"
|
|
63
|
+
import { loadStateSync, saveStateSync, updateGroup, type OrchestratorGroup, type OrchestratorState } from "./state.js"
|
|
64
|
+
import { isTerminalStatus } from "./status.js"
|
|
65
|
+
import { groupWorktreePath, isPidAlive } from "./watch.js"
|
|
66
|
+
import {
|
|
67
|
+
branchSyncStatus,
|
|
68
|
+
ORCHESTRATE_SYNC_DISABLED_ENV,
|
|
69
|
+
syncBranchWithOrigin,
|
|
70
|
+
syncSummaryLines,
|
|
71
|
+
syncWarningLines,
|
|
72
|
+
TRIVIAL_DRIFT_AHEAD,
|
|
73
|
+
} from "./git-sync.js"
|
|
74
|
+
|
|
75
|
+
// ─── Harness-owned worktree artifacts ─────────────────────────────────────────
|
|
76
|
+
// Paths the harness itself creates inside a worktree (run-worker.sh, run-qa.sh,
|
|
77
|
+
// spawn-parallel-worktrees.sh, the executor, headlesscode-answer.sh). These are
|
|
78
|
+
// the ONLY untracked entries the dirty gate ignores and the only paths apply
|
|
79
|
+
// removes before `git worktree remove` (which refuses on ANY untracked file).
|
|
80
|
+
const HARNESS_ARTIFACTS = [
|
|
81
|
+
".harness.pid",
|
|
82
|
+
".harness.pgid", // process-group id written by run-worker.sh's setsid wrapper (issue #38)
|
|
83
|
+
".harness.exit",
|
|
84
|
+
".harness.done",
|
|
85
|
+
".harness.needs-decision",
|
|
86
|
+
".harness.decision-answer",
|
|
87
|
+
".qa.pid",
|
|
88
|
+
".qa.exit",
|
|
89
|
+
".qa.done",
|
|
90
|
+
".qa-task.md",
|
|
91
|
+
"harness.log",
|
|
92
|
+
"qa.log",
|
|
93
|
+
".headlesscode",
|
|
94
|
+
"ORCHESTRATOR_TASK.md",
|
|
95
|
+
".env",
|
|
96
|
+
"zoo-code",
|
|
97
|
+
] as const
|
|
98
|
+
|
|
99
|
+
function isHarnessArtifact(relPath: string): boolean {
|
|
100
|
+
if ((HARNESS_ARTIFACTS as readonly string[]).includes(relPath)) {
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
// A partially-tracked .headlesscode/ shows individual files, not the dir.
|
|
104
|
+
return relPath.startsWith(".headlesscode/")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Remove exactly the harness-owned artifact paths from a worktree. */
|
|
108
|
+
export function removeHarnessArtifacts(wtPath: string): void {
|
|
109
|
+
for (const name of HARNESS_ARTIFACTS) {
|
|
110
|
+
fs.rmSync(path.join(wtPath, name), { recursive: true, force: true })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Known-safe regenerable worktree artifacts ───────────────────────────────
|
|
115
|
+
// Untracked residue workers/reviewer/QA sessions routinely leave behind that is
|
|
116
|
+
// ALWAYS safe to delete — regenerable by the package manager or interpreter —
|
|
117
|
+
// and that target repos' own .gitignore conventions already treat as disposable
|
|
118
|
+
// (issue #38). A worker-created `node_modules` SYMLINK in particular does not
|
|
119
|
+
// match the dir-only `node_modules/` gitignore pattern, so it shows as
|
|
120
|
+
// untracked and previously blocked cleanup every round. Anything NOT matching
|
|
121
|
+
// here or in HARNESS_ARTIFACTS still blocks cleanup: genuine uncommitted work
|
|
122
|
+
// is never silently discarded.
|
|
123
|
+
const KNOWN_SAFE_ARTIFACT_PATTERNS = [
|
|
124
|
+
// node tooling dependencies — symlink or real dir, at any depth
|
|
125
|
+
(p: string): boolean => path.posix.basename(p) === "node_modules",
|
|
126
|
+
// Python bytecode caches — __pycache__ dirs (any depth) and stray .pyc/.pyo
|
|
127
|
+
(p: string): boolean => path.posix.basename(p) === "__pycache__",
|
|
128
|
+
(p: string): boolean => /\.(?:pyc|pyo)$/.test(path.posix.basename(p)),
|
|
129
|
+
] as const
|
|
130
|
+
|
|
131
|
+
function isKnownSafeArtifact(relPath: string): boolean {
|
|
132
|
+
return KNOWN_SAFE_ARTIFACT_PATTERNS.some((match) => match(relPath))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Public result types ──────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** Per-group cleanup eligibility (also the `cleanup` column in status). */
|
|
138
|
+
export type CleanupStatus =
|
|
139
|
+
| { status: "eligible"; via?: string; detail?: string }
|
|
140
|
+
| { status: "blocked"; reason: string; via?: string; detail?: string }
|
|
141
|
+
| { status: "done" }
|
|
142
|
+
|
|
143
|
+
/** GitHub wiring for the PR-merge check (only when group.pr?.number is set). */
|
|
144
|
+
export interface GhWiring {
|
|
145
|
+
owner: string
|
|
146
|
+
repo: string
|
|
147
|
+
getInstallationToken: () => Promise<string>
|
|
148
|
+
baseUrl?: string
|
|
149
|
+
fetchImpl?: typeof fetch
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Injectable seams (tests). Defaults are the real git/fs operations; a test
|
|
154
|
+
* injects only what it needs to prove one gate/step in isolation.
|
|
155
|
+
*/
|
|
156
|
+
export interface CleanupDeps {
|
|
157
|
+
/** Full merge-check dispatch (GitHub-first when pr.number is set). */
|
|
158
|
+
checkMerged?: (repo: string, group: OrchestratorGroup, baseBranch: string, gh?: GhWiring) => Promise<MergeCheckResult>
|
|
159
|
+
/** Blocking (non-harness) `git status --porcelain` entries; throws on git failure. */
|
|
160
|
+
worktreeStatus?: (wtPath: string) => string[]
|
|
161
|
+
removeHarnessArtifacts?: (wtPath: string) => void
|
|
162
|
+
/** Remove a worktree's untracked known-safe residue (node_modules, __pycache__, *.pyc/*.pyo). */
|
|
163
|
+
removeKnownSafeArtifacts?: (wtPath: string) => void
|
|
164
|
+
/** `git worktree remove <wtPath>` (no --force); throws on failure. */
|
|
165
|
+
removeWorktree?: (repo: string, wtPath: string) => void
|
|
166
|
+
/** `git branch -d <branch>`; throws on failure. */
|
|
167
|
+
deleteBranch?: (repo: string, branch: string) => void
|
|
168
|
+
writeState?: (statePath: string, state: OrchestratorState) => void
|
|
169
|
+
now?: () => string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Small git helpers ────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** The branch the round's work was merged into: --base or the repo's HEAD branch. */
|
|
175
|
+
export function resolveBaseBranch(repo: string, explicit?: string): string {
|
|
176
|
+
if (explicit !== undefined && explicit !== "") {
|
|
177
|
+
return explicit
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const branch = execFileSync("git", ["-C", repo, "symbolic-ref", "--short", "HEAD"], {
|
|
181
|
+
encoding: "utf-8",
|
|
182
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
183
|
+
timeout: 5000,
|
|
184
|
+
}).trim()
|
|
185
|
+
if (branch !== "") {
|
|
186
|
+
return branch
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// fall through to the error below
|
|
190
|
+
}
|
|
191
|
+
throw new Error("cannot resolve the repo's current branch (detached HEAD?) — pass --base <branch>")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A group's branch: state's `branch` field, else the worktree's checked-out branch. */
|
|
195
|
+
export function resolveGroupBranch(repo: string, group: OrchestratorGroup): string | undefined {
|
|
196
|
+
if (group.branch) {
|
|
197
|
+
return group.branch
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return execFileSync("git", ["-C", groupWorktreePath(repo, group), "symbolic-ref", "--short", "HEAD"], {
|
|
201
|
+
encoding: "utf-8",
|
|
202
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
203
|
+
timeout: 5000,
|
|
204
|
+
}).trim()
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface PorcelainEntry {
|
|
211
|
+
code: string
|
|
212
|
+
path: string
|
|
213
|
+
raw: string
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Parse `git status --porcelain` output into {code, path, raw} entries. */
|
|
217
|
+
function parsePorcelain(out: string): PorcelainEntry[] {
|
|
218
|
+
const entries: PorcelainEntry[] = []
|
|
219
|
+
for (const line of out.split("\n")) {
|
|
220
|
+
if (line === "") {
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
const code = line.slice(0, 2)
|
|
224
|
+
let file = line.slice(3).trim()
|
|
225
|
+
if (file.includes(" -> ")) {
|
|
226
|
+
file = file.split(" -> ").pop()!.trim()
|
|
227
|
+
}
|
|
228
|
+
entries.push({ code, path: file.replace(/\/+$/, ""), raw: line.trim() })
|
|
229
|
+
}
|
|
230
|
+
return entries
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `git status --porcelain` for a worktree; throws on git failure. */
|
|
234
|
+
function gitStatusPorcelain(wtPath: string): string {
|
|
235
|
+
try {
|
|
236
|
+
return execFileSync("git", ["-C", wtPath, "status", "--porcelain"], {
|
|
237
|
+
encoding: "utf-8",
|
|
238
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
239
|
+
timeout: 10_000,
|
|
240
|
+
})
|
|
241
|
+
} catch (err) {
|
|
242
|
+
throw new Error(`cannot read worktree git status: ${err instanceof Error ? err.message : String(err)}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `git status --porcelain` entries that are NOT the harness's own untracked
|
|
248
|
+
* artifacts or known-safe regenerable residue (i.e. real uncommitted changes —
|
|
249
|
+
* tracked modifications or a human's untracked files). Empty = the worktree is
|
|
250
|
+
* safe to remove.
|
|
251
|
+
*/
|
|
252
|
+
export function worktreeUncommitted(wtPath: string): string[] {
|
|
253
|
+
const blocking: string[] = []
|
|
254
|
+
for (const entry of parsePorcelain(gitStatusPorcelain(wtPath))) {
|
|
255
|
+
if (entry.code === "??" && (isHarnessArtifact(entry.path) || isKnownSafeArtifact(entry.path))) {
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
blocking.push(entry.raw)
|
|
259
|
+
}
|
|
260
|
+
return blocking
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Remove a worktree's UNTRACKED known-safe residue (node_modules symlink/dir,
|
|
265
|
+
* __pycache__ dirs, stray .pyc/.pyo) before `git worktree remove`, which
|
|
266
|
+
* refuses on ANY untracked file. Git-status-driven: only paths git reports as
|
|
267
|
+
* untracked are ever removed, so a repo that TRACKS one of these names is
|
|
268
|
+
* never touched; removing a node_modules symlink unlinks the symlink itself,
|
|
269
|
+
* never its target. Anything not matching the allowlist is left alone (and
|
|
270
|
+
* would have blocked cleanup at the dirty gate anyway).
|
|
271
|
+
*/
|
|
272
|
+
export function removeKnownSafeArtifacts(wtPath: string): void {
|
|
273
|
+
let out: string
|
|
274
|
+
try {
|
|
275
|
+
out = gitStatusPorcelain(wtPath)
|
|
276
|
+
} catch {
|
|
277
|
+
return // the dirty gate already surfaced a status error — removal must not mask it
|
|
278
|
+
}
|
|
279
|
+
for (const entry of parsePorcelain(out)) {
|
|
280
|
+
if (entry.code !== "??" || !isKnownSafeArtifact(entry.path)) {
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
fs.rmSync(path.join(wtPath, entry.path), { recursive: true, force: true })
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Merge-check dispatch ─────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Dispatch by which workflow produced the group: GitHub PR first when
|
|
291
|
+
* group.pr?.number is set AND gh wiring exists, else local-ancestor.
|
|
292
|
+
*
|
|
293
|
+
* Disagreement rule (fail closed): when BOTH checks are available, a GitHub
|
|
294
|
+
* "not merged" alongside a local "is ancestor" is a real contradiction — the
|
|
295
|
+
* recorded PR was never merged even though the branch's commits are in
|
|
296
|
+
* history — and is treated as NOT merged with the discrepancy surfaced. The
|
|
297
|
+
* reverse (GitHub merged / local not-ancestor) is the EXPECTED squash/rebase
|
|
298
|
+
* shape (a new hash), not a contradiction: GitHub's `.merged` is
|
|
299
|
+
* authoritative for PR merges.
|
|
300
|
+
*/
|
|
301
|
+
export async function checkGroupMerged(
|
|
302
|
+
repo: string,
|
|
303
|
+
group: OrchestratorGroup,
|
|
304
|
+
baseBranch: string,
|
|
305
|
+
gh?: GhWiring,
|
|
306
|
+
): Promise<MergeCheckResult> {
|
|
307
|
+
const branch = resolveGroupBranch(repo, group)
|
|
308
|
+
if (group.pr?.number && gh) {
|
|
309
|
+
const ghResult = await checkMergedByGitHubPr({
|
|
310
|
+
owner: gh.owner,
|
|
311
|
+
repo: gh.repo,
|
|
312
|
+
prNumber: group.pr.number,
|
|
313
|
+
getInstallationToken: gh.getInstallationToken,
|
|
314
|
+
...(gh.baseUrl ? { baseUrl: gh.baseUrl } : {}),
|
|
315
|
+
...(gh.fetchImpl ? { fetchImpl: gh.fetchImpl } : {}),
|
|
316
|
+
})
|
|
317
|
+
let local: MergeCheckResult | undefined
|
|
318
|
+
if (branch) {
|
|
319
|
+
try {
|
|
320
|
+
local = checkMergedByAncestor(repo, branch, baseBranch)
|
|
321
|
+
} catch {
|
|
322
|
+
local = undefined // branch gone / unreadable — rely on GitHub alone
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (ghResult.merged) {
|
|
326
|
+
return {
|
|
327
|
+
merged: true,
|
|
328
|
+
via: "github-pr",
|
|
329
|
+
detail:
|
|
330
|
+
ghResult.detail +
|
|
331
|
+
(local && !local.merged
|
|
332
|
+
? ` (local ancestor check disagrees — expected for squash/rebase merges)`
|
|
333
|
+
: ""),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (ghResult.via === "unknown") {
|
|
337
|
+
// The GitHub check is UNAVAILABLE (API/network error), not
|
|
338
|
+
// disagreeing. Fall back to the local ancestor check: if the
|
|
339
|
+
// branch's commits are provably in history, deletion is safe no
|
|
340
|
+
// matter what the PR state is. Otherwise the unknown blocks.
|
|
341
|
+
if (local && local.merged) {
|
|
342
|
+
return local
|
|
343
|
+
}
|
|
344
|
+
return ghResult
|
|
345
|
+
}
|
|
346
|
+
// GitHub definitively says NOT merged.
|
|
347
|
+
if (local && local.merged) {
|
|
348
|
+
return {
|
|
349
|
+
merged: false,
|
|
350
|
+
via: "unknown",
|
|
351
|
+
detail: `disagreement: GitHub PR #${group.pr.number} is NOT merged but branch ${branch} IS an ancestor of ${baseBranch} — failing closed`,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { merged: false, via: ghResult.via, detail: ghResult.detail }
|
|
355
|
+
}
|
|
356
|
+
// No PR number (or no GitHub wiring): local-ancestor is the source of truth.
|
|
357
|
+
if (!branch) {
|
|
358
|
+
throw new Error(`cannot determine branch for group ${group.name}`)
|
|
359
|
+
}
|
|
360
|
+
return checkMergedByAncestor(repo, branch, baseBranch)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ─── Gates ────────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
/** Gates 4–5 (dirty + live process) applied after the merge gate passed. */
|
|
366
|
+
function gateAfterMerge(wtPath: string, merged: MergeCheckResult, worktreeStatus: (wtPath: string) => string[]): CleanupStatus {
|
|
367
|
+
let changes: string[]
|
|
368
|
+
try {
|
|
369
|
+
changes = worktreeStatus(wtPath)
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return { status: "blocked", reason: `cannot check worktree status: ${err instanceof Error ? err.message : String(err)}`, via: merged.via }
|
|
372
|
+
}
|
|
373
|
+
if (changes.length > 0) {
|
|
374
|
+
const shown = changes.slice(0, 3).join("; ")
|
|
375
|
+
return {
|
|
376
|
+
status: "blocked",
|
|
377
|
+
reason:
|
|
378
|
+
`merged (${merged.via}) but worktree has uncommitted changes: ` +
|
|
379
|
+
`${shown}${changes.length > 3 ? ` (+${changes.length - 3} more)` : ""}`,
|
|
380
|
+
via: merged.via,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (isPidAlive(wtPath) || isPidAlive(wtPath, ".qa.pid")) {
|
|
384
|
+
return {
|
|
385
|
+
status: "blocked",
|
|
386
|
+
reason: `merged (${merged.via}) but worker process is still running`,
|
|
387
|
+
via: merged.via,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { status: "eligible", via: merged.via, detail: merged.detail }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function sharedGates(wtPath: string, group: OrchestratorGroup): CleanupStatus | undefined {
|
|
394
|
+
if (!fs.existsSync(wtPath)) {
|
|
395
|
+
return { status: "done" }
|
|
396
|
+
}
|
|
397
|
+
if (!isTerminalStatus(group.status)) {
|
|
398
|
+
return { status: "blocked", reason: `status "${group.status}" is not terminal` }
|
|
399
|
+
}
|
|
400
|
+
return undefined
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Full eligibility assessment (cleanup command path — may use the GitHub PR
|
|
405
|
+
* check when wired). Non-throwing: any failure becomes a blocked result.
|
|
406
|
+
*/
|
|
407
|
+
export async function assessGroupCleanup(
|
|
408
|
+
repo: string,
|
|
409
|
+
group: OrchestratorGroup,
|
|
410
|
+
opts: { baseBranch: string; gh?: GhWiring; deps?: CleanupDeps },
|
|
411
|
+
): Promise<CleanupStatus> {
|
|
412
|
+
const deps = opts.deps ?? {}
|
|
413
|
+
const checkMerged = deps.checkMerged ?? checkGroupMerged
|
|
414
|
+
const worktreeStatus = deps.worktreeStatus ?? worktreeUncommitted
|
|
415
|
+
const wtPath = groupWorktreePath(repo, group)
|
|
416
|
+
|
|
417
|
+
const shared = sharedGates(wtPath, group)
|
|
418
|
+
if (shared) {
|
|
419
|
+
return shared
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let merged: MergeCheckResult
|
|
423
|
+
try {
|
|
424
|
+
merged = await checkMerged(repo, group, opts.baseBranch, opts.gh)
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return { status: "blocked", reason: `cannot verify merge: ${err instanceof Error ? err.message : String(err)}`, via: "unknown" }
|
|
427
|
+
}
|
|
428
|
+
if (!merged.merged) {
|
|
429
|
+
return { status: "blocked", reason: `not merged (${merged.via}): ${merged.detail}`, via: merged.via, detail: merged.detail }
|
|
430
|
+
}
|
|
431
|
+
return gateAfterMerge(wtPath, merged, worktreeStatus)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Read-only eligibility for the `orchestrate status` cleanup column: the SAME
|
|
436
|
+
* gates as assessGroupCleanup minus the network (local-ancestor merge check
|
|
437
|
+
* only), synchronous and never throwing. A group with a pr.number that cannot
|
|
438
|
+
* be verified locally reports blocked with a pointer to the cleanup command.
|
|
439
|
+
*/
|
|
440
|
+
export function assessGroupCleanupSync(repo: string, group: OrchestratorGroup, baseBranch: string): CleanupStatus {
|
|
441
|
+
const wtPath = groupWorktreePath(repo, group)
|
|
442
|
+
|
|
443
|
+
const shared = sharedGates(wtPath, group)
|
|
444
|
+
if (shared) {
|
|
445
|
+
return shared
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let merged: MergeCheckResult
|
|
449
|
+
try {
|
|
450
|
+
const branch = resolveGroupBranch(repo, group)
|
|
451
|
+
if (!branch) {
|
|
452
|
+
return { status: "blocked", reason: "cannot determine branch" }
|
|
453
|
+
}
|
|
454
|
+
merged = checkMergedByAncestor(repo, branch, baseBranch)
|
|
455
|
+
} catch (err) {
|
|
456
|
+
return {
|
|
457
|
+
status: "blocked",
|
|
458
|
+
reason:
|
|
459
|
+
group.pr?.number
|
|
460
|
+
? `PR #${group.pr.number} merge not verifiable locally — run "orchestrate cleanup --dry-run" for the GitHub check`
|
|
461
|
+
: `cannot verify merge: ${err instanceof Error ? err.message : String(err)}`,
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (!merged.merged) {
|
|
465
|
+
return {
|
|
466
|
+
status: "blocked",
|
|
467
|
+
reason:
|
|
468
|
+
group.pr?.number
|
|
469
|
+
? `not merged locally (PR #${group.pr.number} may be squash-merged) — run "orchestrate cleanup --dry-run" for the GitHub check`
|
|
470
|
+
: `not merged (${merged.via}): ${merged.detail}`,
|
|
471
|
+
via: merged.via,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return gateAfterMerge(wtPath, merged, worktreeUncommitted)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── Planning + applying ──────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
export interface CleanupPlanEntry {
|
|
480
|
+
name: string
|
|
481
|
+
status: CleanupStatus
|
|
482
|
+
/**
|
|
483
|
+
* The branch resolved for this group (state `branch` field, else the
|
|
484
|
+
* worktree's checked-out branch). Carried from the plan so apply can
|
|
485
|
+
* delete it AFTER the worktree is gone (when the state never recorded a
|
|
486
|
+
* branch, the worktree was the only way to learn it).
|
|
487
|
+
*/
|
|
488
|
+
branch?: string
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export interface CleanupPlan {
|
|
492
|
+
repo: string
|
|
493
|
+
statePath: string
|
|
494
|
+
baseBranch: string
|
|
495
|
+
/** State-file order — never reordered, so partial runs are resumable. */
|
|
496
|
+
groups: CleanupPlanEntry[]
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export interface CleanupRunResult {
|
|
500
|
+
plan: CleanupPlan
|
|
501
|
+
/** Groups whose worktree was removed this run. */
|
|
502
|
+
removed: string[]
|
|
503
|
+
/** Groups whose worktree removal failed (no branch delete / state patch). */
|
|
504
|
+
failed: Array<{ name: string; error: string }>
|
|
505
|
+
/** Groups whose state entry was patched with cleaned_at. */
|
|
506
|
+
statePatched: string[]
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Compute the full plan for a state. Pure (git reads only), never mutates. */
|
|
510
|
+
export async function planCleanup(
|
|
511
|
+
repo: string,
|
|
512
|
+
state: OrchestratorState,
|
|
513
|
+
opts: { statePath: string; baseBranch: string; gh?: GhWiring; deps?: CleanupDeps },
|
|
514
|
+
): Promise<CleanupPlan> {
|
|
515
|
+
const groups: CleanupPlanEntry[] = []
|
|
516
|
+
for (const group of state.groups) {
|
|
517
|
+
const status = await assessGroupCleanup(repo, group, opts)
|
|
518
|
+
const branch = status.status === "eligible" ? resolveGroupBranch(repo, group) : undefined
|
|
519
|
+
groups.push({ name: group.name, status, branch })
|
|
520
|
+
}
|
|
521
|
+
return { repo, statePath: opts.statePath, baseBranch: opts.baseBranch, groups }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Ensure a group's worktree path stays inside <repo>/.worktrees/ — cleanup
|
|
525
|
+
* deletes, so a hostile/rotten state file must never point it outside. */
|
|
526
|
+
function worktreePathInRepo(repo: string, wtPath: string): boolean {
|
|
527
|
+
const parent = path.join(repo, ".worktrees") + path.sep
|
|
528
|
+
return wtPath.startsWith(parent)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Execute cleanup for every eligible group (in state order): remove harness
|
|
533
|
+
* artifacts and known-safe residue → `git worktree remove` (no --force) →
|
|
534
|
+
* `git branch -d` → patch the state entry. A worktree-removal failure aborts
|
|
535
|
+
* ONLY that group (no branch delete, no state patch) and continues with the
|
|
536
|
+
* next.
|
|
537
|
+
*/
|
|
538
|
+
export async function applyCleanup(
|
|
539
|
+
repo: string,
|
|
540
|
+
statePath: string,
|
|
541
|
+
opts: { baseBranch: string; gh?: GhWiring; deps?: CleanupDeps },
|
|
542
|
+
): Promise<CleanupRunResult> {
|
|
543
|
+
const deps = {
|
|
544
|
+
removeHarnessArtifacts,
|
|
545
|
+
removeKnownSafeArtifacts,
|
|
546
|
+
removeWorktree: (r: string, wtPath: string): void => {
|
|
547
|
+
execFileSync("git", ["-C", r, "worktree", "remove", wtPath], { stdio: "ignore", timeout: 30_000 })
|
|
548
|
+
},
|
|
549
|
+
deleteBranch: (r: string, branch: string): void => {
|
|
550
|
+
execFileSync("git", ["-C", r, "branch", "-d", branch], { stdio: "ignore", timeout: 10_000 })
|
|
551
|
+
},
|
|
552
|
+
writeState: saveStateSync,
|
|
553
|
+
now: () => new Date().toISOString(),
|
|
554
|
+
...opts.deps,
|
|
555
|
+
} as Required<
|
|
556
|
+
Pick<CleanupDeps, "removeHarnessArtifacts" | "removeKnownSafeArtifacts" | "removeWorktree" | "deleteBranch" | "writeState" | "now">
|
|
557
|
+
>
|
|
558
|
+
const state = loadStateSync(statePath)
|
|
559
|
+
const plan = await planCleanup(repo, state, { ...opts, statePath })
|
|
560
|
+
|
|
561
|
+
const removed: string[] = []
|
|
562
|
+
const failed: Array<{ name: string; error: string }> = []
|
|
563
|
+
const statePatched: string[] = []
|
|
564
|
+
|
|
565
|
+
for (const entry of plan.groups) {
|
|
566
|
+
if (entry.status.status !== "eligible") {
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
const group = state.groups.find((g) => g.name === entry.name)
|
|
570
|
+
if (!group) {
|
|
571
|
+
continue
|
|
572
|
+
}
|
|
573
|
+
const wtPath = groupWorktreePath(repo, group)
|
|
574
|
+
if (!worktreePathInRepo(repo, wtPath)) {
|
|
575
|
+
failed.push({ name: entry.name, error: `worktree path escapes <repo>/.worktrees/: ${wtPath} — refusing` })
|
|
576
|
+
continue
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Step 1: drop the harness's own untracked artifacts and the known-safe
|
|
580
|
+
// regenerable residue so the no-force `git worktree remove` below can
|
|
581
|
+
// succeed (it refuses on ANY untracked file; the dirty gate already
|
|
582
|
+
// proved everything untracked is allowlisted).
|
|
583
|
+
try {
|
|
584
|
+
deps.removeHarnessArtifacts(wtPath)
|
|
585
|
+
deps.removeKnownSafeArtifacts(wtPath)
|
|
586
|
+
} catch (err) {
|
|
587
|
+
failed.push({
|
|
588
|
+
name: entry.name,
|
|
589
|
+
error: `removing worktree artifacts failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
590
|
+
})
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
// Step 2: remove the worktree, NO --force. If this fails the worktree
|
|
594
|
+
// got dirty/raced after the gate — abort THIS group, never force.
|
|
595
|
+
try {
|
|
596
|
+
deps.removeWorktree(repo, wtPath)
|
|
597
|
+
} catch (err) {
|
|
598
|
+
failed.push({ name: entry.name, error: `git worktree remove failed: ${err instanceof Error ? err.message : String(err)}` })
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
removed.push(entry.name)
|
|
602
|
+
|
|
603
|
+
// Step 3: delete the branch with lowercase -d (refuses unless merged
|
|
604
|
+
// into HEAD/upstream — a second, independent safety net). A failure
|
|
605
|
+
// here is recorded, not fatal: the irreversible removal succeeded.
|
|
606
|
+
const branch = entry.branch ?? resolveGroupBranch(repo, group)
|
|
607
|
+
const actions: string[] = [...(group.actions_taken ?? [])]
|
|
608
|
+
actions.push(`worktree removed (merged via ${entry.status.via ?? "unknown"})`)
|
|
609
|
+
let branchNote = ""
|
|
610
|
+
if (branch) {
|
|
611
|
+
try {
|
|
612
|
+
deps.deleteBranch(repo, branch)
|
|
613
|
+
actions.push(`branch ${branch} deleted`)
|
|
614
|
+
branchNote = `; branch ${branch} deleted`
|
|
615
|
+
} catch (err) {
|
|
616
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
617
|
+
actions.push(`branch ${branch} delete FAILED (delete manually): ${msg}`)
|
|
618
|
+
branchNote = `; branch ${branch} delete FAILED — see actions_taken`
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Step 4: patch the state entry (reload fresh — the watcher or a
|
|
623
|
+
// concurrent status call may have rewritten the file mid-run). The
|
|
624
|
+
// entry itself is never deleted.
|
|
625
|
+
try {
|
|
626
|
+
const fresh = loadStateSync(statePath)
|
|
627
|
+
const patched = updateGroup(fresh, entry.name, {
|
|
628
|
+
cleaned_at: deps.now(),
|
|
629
|
+
actions_taken: actions,
|
|
630
|
+
last_activity: {
|
|
631
|
+
note: `cleaned: worktree removed (merged via ${entry.status.via ?? "unknown"})${branchNote}`,
|
|
632
|
+
},
|
|
633
|
+
})
|
|
634
|
+
deps.writeState(statePath, patched)
|
|
635
|
+
statePatched.push(entry.name)
|
|
636
|
+
} catch (err) {
|
|
637
|
+
failed.push({
|
|
638
|
+
name: entry.name,
|
|
639
|
+
error: `state patch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return { plan, removed, failed, statePatched }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
const CLEANUP_USAGE = `headlesscode orchestrate cleanup — remove merged worktrees after a round
|
|
650
|
+
|
|
651
|
+
Usage:
|
|
652
|
+
headlesscode orchestrate cleanup --repo <path> [--base <branch>] [--dry-run|--apply] [--json]
|
|
653
|
+
headlesscode orchestrate cleanup --repo <path> [--gh-installation-id <id>] [--dry-run|--apply]
|
|
654
|
+
|
|
655
|
+
Options:
|
|
656
|
+
--repo <path> Target repo root whose .worktrees/.orchestrator-state.json
|
|
657
|
+
is read (required) and whose worktrees live under
|
|
658
|
+
<repo>/.worktrees/<name>.
|
|
659
|
+
--base <branch> Branch the round's work was merged INTO (default: the
|
|
660
|
+
repo's current branch, via HEAD).
|
|
661
|
+
--dry-run Print which groups are cleanup-eligible / blocked and
|
|
662
|
+
why, touching NOTHING. The DEFAULT when neither
|
|
663
|
+
--dry-run nor --apply is given.
|
|
664
|
+
--apply Actually perform the cleanup. For each eligible group,
|
|
665
|
+
in state order: remove the worktree (git worktree
|
|
666
|
+
remove, NO --force), delete the branch (git branch -d —
|
|
667
|
+
a second safety net), and record cleaned_at +
|
|
668
|
+
actions_taken in the state file.
|
|
669
|
+
--json Machine-readable output (plan + results as JSON).
|
|
670
|
+
--gh-installation-id <id>
|
|
671
|
+
GitHub App installation id for the PR-merge check when
|
|
672
|
+
a group records pr.number (squash/rebase merges cannot
|
|
673
|
+
be detected locally). Owner/repo are derived from the
|
|
674
|
+
repo's 'origin' remote. Requires GITHUB_APP_ID +
|
|
675
|
+
GITHUB_APP_PRIVATE_KEY[_PATH] (docs/github-app-setup.md).
|
|
676
|
+
Without it, groups with a pr.number fall back to the
|
|
677
|
+
local ancestor check.
|
|
678
|
+
--help, -h Show this help and exit.
|
|
679
|
+
|
|
680
|
+
Safety model (all gates recomputed fresh, never cached):
|
|
681
|
+
- merge verified: git merge-base --is-ancestor <branch> <base> (exit 0), or
|
|
682
|
+
GitHub PR .merged when pr.number is recorded; disagreement fails closed
|
|
683
|
+
- worktree clean: git status --porcelain shows only the harness's own
|
|
684
|
+
untracked artifacts (.harness.*, .qa.*, harness.log, qa.log,
|
|
685
|
+
.headlesscode/, ORCHESTRATOR_TASK.md, .env, zoo-code) plus known-safe
|
|
686
|
+
regenerable residue (node_modules symlink/dir, __pycache__, *.pyc/*.pyo)
|
|
687
|
+
- no live worker: .harness.pid and .qa.pid absent or the PID is not running
|
|
688
|
+
- terminal status: done | failed | needs-human | orphaned
|
|
689
|
+
|
|
690
|
+
Anything failing a gate is a documented no-op (reported, never forced).
|
|
691
|
+
Exit codes:
|
|
692
|
+
0 dry-run: plan printed; --apply: every eligible group cleaned (blocked
|
|
693
|
+
groups are not errors)
|
|
694
|
+
1 --apply: at least one worktree removal or state write failed
|
|
695
|
+
2 usage error
|
|
696
|
+
`
|
|
697
|
+
|
|
698
|
+
export interface CleanupCliOptions {
|
|
699
|
+
repo: string
|
|
700
|
+
base?: string
|
|
701
|
+
dryRun: boolean
|
|
702
|
+
apply: boolean
|
|
703
|
+
json: boolean
|
|
704
|
+
ghInstallationId?: string
|
|
705
|
+
help: boolean
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function parseCleanupArgs(argv: string[]): { options: CleanupCliOptions; error?: string } {
|
|
709
|
+
const options: CleanupCliOptions = {
|
|
710
|
+
repo: "",
|
|
711
|
+
base: undefined,
|
|
712
|
+
dryRun: false,
|
|
713
|
+
apply: false,
|
|
714
|
+
json: false,
|
|
715
|
+
ghInstallationId: undefined,
|
|
716
|
+
help: false,
|
|
717
|
+
}
|
|
718
|
+
for (let i = 0; i < argv.length; i++) {
|
|
719
|
+
const arg = argv[i]
|
|
720
|
+
const eq = arg.indexOf("=")
|
|
721
|
+
const flag = eq === -1 ? arg : arg.slice(0, eq)
|
|
722
|
+
const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
|
|
723
|
+
const next = (): string | undefined => {
|
|
724
|
+
if (inlineValue !== undefined) {
|
|
725
|
+
return inlineValue
|
|
726
|
+
}
|
|
727
|
+
const v = argv[i + 1]
|
|
728
|
+
if (v === undefined || v.startsWith("--")) {
|
|
729
|
+
return undefined
|
|
730
|
+
}
|
|
731
|
+
i++
|
|
732
|
+
return v
|
|
733
|
+
}
|
|
734
|
+
switch (flag) {
|
|
735
|
+
case "--repo": {
|
|
736
|
+
const v = next()
|
|
737
|
+
if (v === undefined) {
|
|
738
|
+
return { options, error: "Missing value for --repo" }
|
|
739
|
+
}
|
|
740
|
+
options.repo = v
|
|
741
|
+
break
|
|
742
|
+
}
|
|
743
|
+
case "--base": {
|
|
744
|
+
const v = next()
|
|
745
|
+
if (v === undefined) {
|
|
746
|
+
return { options, error: "Missing value for --base" }
|
|
747
|
+
}
|
|
748
|
+
options.base = v
|
|
749
|
+
break
|
|
750
|
+
}
|
|
751
|
+
case "--dry-run":
|
|
752
|
+
options.dryRun = true
|
|
753
|
+
break
|
|
754
|
+
case "--apply":
|
|
755
|
+
options.apply = true
|
|
756
|
+
break
|
|
757
|
+
case "--json":
|
|
758
|
+
options.json = true
|
|
759
|
+
break
|
|
760
|
+
case "--gh-installation-id": {
|
|
761
|
+
const v = next()
|
|
762
|
+
if (v === undefined) {
|
|
763
|
+
return { options, error: "Missing value for --gh-installation-id" }
|
|
764
|
+
}
|
|
765
|
+
options.ghInstallationId = v
|
|
766
|
+
break
|
|
767
|
+
}
|
|
768
|
+
case "--help":
|
|
769
|
+
case "-h":
|
|
770
|
+
options.help = true
|
|
771
|
+
break
|
|
772
|
+
default:
|
|
773
|
+
return { options, error: `Unknown orchestrate cleanup argument: ${arg}` }
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return { options }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export interface CleanupIo {
|
|
780
|
+
stdout?: (text: string) => void
|
|
781
|
+
stderr?: (text: string) => void
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function repoOwnerRepoFromRemote(repo: string): { owner: string; repo: string } | undefined {
|
|
785
|
+
try {
|
|
786
|
+
const url = execFileSync("git", ["-C", repo, "remote", "get-url", "origin"], {
|
|
787
|
+
encoding: "utf-8",
|
|
788
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
789
|
+
timeout: 5000,
|
|
790
|
+
}).trim()
|
|
791
|
+
const match = url.match(/(?:github\.com[/:])([^/]+)\/([^/]+?)(?:\.git)?$/)
|
|
792
|
+
if (match) {
|
|
793
|
+
return { owner: match[1], repo: match[2] }
|
|
794
|
+
}
|
|
795
|
+
} catch {
|
|
796
|
+
// no origin remote — the GitHub check simply isn't available
|
|
797
|
+
}
|
|
798
|
+
return undefined
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Build GitHub wiring from --gh-installation-id + env creds; undefined when
|
|
802
|
+
* the remote isn't a GitHub URL or the App credentials are missing. */
|
|
803
|
+
function buildGhWiring(repo: string, installationId: string): GhWiring | undefined {
|
|
804
|
+
const parsed = repoOwnerRepoFromRemote(repo)
|
|
805
|
+
if (!parsed) {
|
|
806
|
+
return undefined
|
|
807
|
+
}
|
|
808
|
+
const appId = process.env.GITHUB_APP_ID
|
|
809
|
+
const inlineKey = process.env.GITHUB_APP_PRIVATE_KEY
|
|
810
|
+
const keyPath = process.env.GITHUB_APP_PRIVATE_KEY_PATH
|
|
811
|
+
if (!appId || (!inlineKey && !keyPath)) {
|
|
812
|
+
return undefined
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
const privateKey = inlineKey ?? fs.readFileSync(path.resolve(keyPath!), "utf-8")
|
|
816
|
+
const client = createAppAuthClient({
|
|
817
|
+
appId,
|
|
818
|
+
privateKey,
|
|
819
|
+
...(process.env.GITHUB_API_BASE_URL ? { baseUrl: process.env.GITHUB_API_BASE_URL } : {}),
|
|
820
|
+
})
|
|
821
|
+
return {
|
|
822
|
+
owner: parsed.owner,
|
|
823
|
+
repo: parsed.repo,
|
|
824
|
+
getInstallationToken: () => client.getInstallationToken(installationId),
|
|
825
|
+
...(process.env.GITHUB_API_BASE_URL ? { baseUrl: process.env.GITHUB_API_BASE_URL } : {}),
|
|
826
|
+
}
|
|
827
|
+
} catch {
|
|
828
|
+
return undefined
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function shortStatus(status: CleanupStatus): string {
|
|
833
|
+
return status.status
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function formatCleanupText(
|
|
837
|
+
plan: CleanupPlan,
|
|
838
|
+
opts: { mode: "dry-run" | "apply"; result?: CleanupRunResult },
|
|
839
|
+
): string {
|
|
840
|
+
const lines: string[] = []
|
|
841
|
+
lines.push(`── Orchestrator cleanup (${opts.mode}) ──`)
|
|
842
|
+
lines.push(`repo: ${plan.repo}`)
|
|
843
|
+
lines.push(`state file: ${plan.statePath}`)
|
|
844
|
+
lines.push(`base branch: ${plan.baseBranch}`)
|
|
845
|
+
lines.push("")
|
|
846
|
+
if (plan.groups.length === 0) {
|
|
847
|
+
lines.push("No orchestrator groups found in this state file (nothing to clean).")
|
|
848
|
+
} else {
|
|
849
|
+
lines.push(`${"GROUP".padEnd(14)}${"CLEANUP".padEnd(10)}DETAIL`)
|
|
850
|
+
for (const entry of plan.groups) {
|
|
851
|
+
const s = entry.status
|
|
852
|
+
let detail = ""
|
|
853
|
+
if (s.status === "eligible") {
|
|
854
|
+
detail = `merged via ${s.via ?? "unknown"}` + (s.detail ? ` (${s.detail})` : "")
|
|
855
|
+
} else if (s.status === "blocked") {
|
|
856
|
+
detail = s.reason
|
|
857
|
+
} else {
|
|
858
|
+
detail = "worktree already removed"
|
|
859
|
+
}
|
|
860
|
+
lines.push(`${entry.name.padEnd(14)}${shortStatus(s).padEnd(10)}${detail}`)
|
|
861
|
+
}
|
|
862
|
+
lines.push("")
|
|
863
|
+
const counts = { eligible: 0, blocked: 0, done: 0 }
|
|
864
|
+
for (const entry of plan.groups) {
|
|
865
|
+
counts[entry.status.status === "blocked" ? "blocked" : entry.status.status === "done" ? "done" : "eligible"]++
|
|
866
|
+
}
|
|
867
|
+
lines.push(
|
|
868
|
+
`${counts.eligible} eligible · ${counts.blocked} blocked · ${counts.done} already-done ` +
|
|
869
|
+
`(of ${plan.groups.length} group${plan.groups.length === 1 ? "" : "s"})`,
|
|
870
|
+
)
|
|
871
|
+
}
|
|
872
|
+
if (opts.mode === "apply" && opts.result) {
|
|
873
|
+
if (opts.result.removed.length > 0) {
|
|
874
|
+
lines.push(`removed: ${opts.result.removed.join(", ")}`)
|
|
875
|
+
}
|
|
876
|
+
if (opts.result.statePatched.length > 0) {
|
|
877
|
+
lines.push(`state patched: ${opts.result.statePatched.join(", ")}`)
|
|
878
|
+
}
|
|
879
|
+
for (const f of opts.result.failed) {
|
|
880
|
+
lines.push(`FAILED: ${f.name} — ${f.error}`)
|
|
881
|
+
}
|
|
882
|
+
} else {
|
|
883
|
+
lines.push("")
|
|
884
|
+
lines.push('Dry run — nothing was removed. Re-run with --apply to perform deletions.')
|
|
885
|
+
}
|
|
886
|
+
return lines.join("\n") + "\n"
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export function cleanupJson(plan: CleanupPlan, opts: { mode: "dry-run" | "apply"; result?: CleanupRunResult }): string {
|
|
890
|
+
const groups = plan.groups.map((entry) => {
|
|
891
|
+
const s = entry.status
|
|
892
|
+
return {
|
|
893
|
+
name: entry.name,
|
|
894
|
+
status: s.status,
|
|
895
|
+
...(s.status !== "done" && s.via ? { via: s.via } : {}),
|
|
896
|
+
...(s.status === "blocked" && s.reason ? { reason: s.reason } : {}),
|
|
897
|
+
...(s.status === "eligible" && s.detail ? { detail: s.detail } : {}),
|
|
898
|
+
}
|
|
899
|
+
})
|
|
900
|
+
const out: Record<string, unknown> = {
|
|
901
|
+
mode: opts.mode,
|
|
902
|
+
repo: plan.repo,
|
|
903
|
+
statePath: plan.statePath,
|
|
904
|
+
baseBranch: plan.baseBranch,
|
|
905
|
+
groups,
|
|
906
|
+
}
|
|
907
|
+
if (opts.result) {
|
|
908
|
+
out.removed = opts.result.removed
|
|
909
|
+
out.statePatched = opts.result.statePatched
|
|
910
|
+
out.failed = opts.result.failed
|
|
911
|
+
}
|
|
912
|
+
return JSON.stringify(out, null, 2) + "\n"
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export async function cleanupMain(argv: string[], io: CleanupIo = {}): Promise<number> {
|
|
916
|
+
const writeOut = io.stdout ?? ((text: string) => process.stdout.write(text))
|
|
917
|
+
const writeErr = io.stderr ?? ((text: string) => process.stderr.write(text))
|
|
918
|
+
|
|
919
|
+
const { options, error } = parseCleanupArgs(argv)
|
|
920
|
+
if (error) {
|
|
921
|
+
writeErr(`headlesscode orchestrate cleanup: ${error}\n\n${CLEANUP_USAGE}`)
|
|
922
|
+
return 2
|
|
923
|
+
}
|
|
924
|
+
if (options.help) {
|
|
925
|
+
writeOut(CLEANUP_USAGE)
|
|
926
|
+
return 0
|
|
927
|
+
}
|
|
928
|
+
if (!options.repo) {
|
|
929
|
+
writeErr(`headlesscode orchestrate cleanup: --repo <path> is required\n\n${CLEANUP_USAGE}`)
|
|
930
|
+
return 2
|
|
931
|
+
}
|
|
932
|
+
if (options.apply && options.dryRun) {
|
|
933
|
+
writeErr(`headlesscode orchestrate cleanup: --apply and --dry-run are mutually exclusive\n\n${CLEANUP_USAGE}`)
|
|
934
|
+
return 2
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const repo = path.resolve(options.repo)
|
|
938
|
+
try {
|
|
939
|
+
execFileSync("git", ["-C", repo, "rev-parse", "--git-dir"], { stdio: "ignore", timeout: 5000 })
|
|
940
|
+
} catch {
|
|
941
|
+
writeErr(`headlesscode orchestrate cleanup: not a git repo: ${repo}\n`)
|
|
942
|
+
return 2
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
let baseBranch: string
|
|
946
|
+
try {
|
|
947
|
+
baseBranch = resolveBaseBranch(repo, options.base)
|
|
948
|
+
} catch (err) {
|
|
949
|
+
writeErr(`headlesscode orchestrate cleanup: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
950
|
+
return 2
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const gh = options.ghInstallationId ? buildGhWiring(repo, options.ghInstallationId) : undefined
|
|
954
|
+
const statePath = path.join(repo, ".worktrees", ".orchestrator-state.json")
|
|
955
|
+
let state: OrchestratorState
|
|
956
|
+
try {
|
|
957
|
+
state = loadStateSync(statePath)
|
|
958
|
+
} catch (err) {
|
|
959
|
+
writeErr(`headlesscode orchestrate cleanup: cannot read state file ${statePath}: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
960
|
+
return 1
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const mode = options.apply ? "apply" : "dry-run"
|
|
964
|
+
if (mode === "apply") {
|
|
965
|
+
const result = await applyCleanup(repo, statePath, { baseBranch, gh })
|
|
966
|
+
if (options.json) {
|
|
967
|
+
writeOut(cleanupJson(result.plan, { mode, result }))
|
|
968
|
+
} else {
|
|
969
|
+
writeOut(formatCleanupText(result.plan, { mode, result }))
|
|
970
|
+
}
|
|
971
|
+
// Issue #25: cleanup --apply is the "a round just settled" checkpoint —
|
|
972
|
+
// reconcile local master with origin (fetch, push, fast-forward) so
|
|
973
|
+
// unpushed merge commits stop accumulating. Best-effort: failures are
|
|
974
|
+
// loud warnings, never a cleanup failure. Sync lines go to stderr so
|
|
975
|
+
// --json stdout stays machine-readable (see git-sync.ts).
|
|
976
|
+
if (process.env[ORCHESTRATE_SYNC_DISABLED_ENV]) {
|
|
977
|
+
const drift = branchSyncStatus(repo, baseBranch)
|
|
978
|
+
if (drift && drift.ahead > TRIVIAL_DRIFT_AHEAD) {
|
|
979
|
+
writeErr(
|
|
980
|
+
`headlesscode orchestrate cleanup: WARNING: local ${drift.branch} is ${drift.ahead} commit(s) ahead of ${drift.remoteRef} ` +
|
|
981
|
+
`(${ORCHESTRATE_SYNC_DISABLED_ENV} set — not pushing). Push it now: git -C ${repo} push origin ${drift.branch}\n`,
|
|
982
|
+
)
|
|
983
|
+
}
|
|
984
|
+
} else {
|
|
985
|
+
const sync = syncBranchWithOrigin(repo, baseBranch)
|
|
986
|
+
for (const line of syncSummaryLines(sync)) {
|
|
987
|
+
writeErr(`[sync] ${line}\n`)
|
|
988
|
+
}
|
|
989
|
+
for (const line of syncWarningLines(sync, repo)) {
|
|
990
|
+
writeErr(`headlesscode orchestrate cleanup: ${line}\n`)
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return result.failed.length > 0 ? 1 : 0
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const plan = await planCleanup(repo, state, { statePath, baseBranch, gh })
|
|
997
|
+
if (options.json) {
|
|
998
|
+
writeOut(cleanupJson(plan, { mode }))
|
|
999
|
+
} else {
|
|
1000
|
+
writeOut(formatCleanupText(plan, { mode }))
|
|
1001
|
+
}
|
|
1002
|
+
return 0
|
|
1003
|
+
}
|