@michaelxwb/code-flow 0.6.5 → 1.0.0
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/README.md +75 -110
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +268 -0
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +21 -9
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -264
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -67
- package/src/adapters/claude/commands/cf-stats.md +0 -86
- package/src/adapters/claude/commands/cf-sync.md +0 -25
- package/src/adapters/claude/commands/cf-task/align.md +0 -309
- package/src/adapters/claude/commands/cf-task/archive.md +0 -161
- package/src/adapters/claude/commands/cf-task/block.md +0 -36
- package/src/adapters/claude/commands/cf-task/graph.md +0 -80
- package/src/adapters/claude/commands/cf-task/note.md +0 -236
- package/src/adapters/claude/commands/cf-task/plan.md +0 -363
- package/src/adapters/claude/commands/cf-task/prd.md +0 -162
- package/src/adapters/claude/commands/cf-task/start.md +0 -304
- package/src/adapters/claude/commands/cf-task/status.md +0 -59
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/claude/commands/cf-validate.md +0 -75
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -50
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -264
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -67
- package/src/adapters/costrict/commands/cf-stats.md +0 -86
- package/src/adapters/costrict/commands/cf-sync.md +0 -25
- package/src/adapters/costrict/commands/cf-task/align.md +0 -309
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
- package/src/adapters/costrict/commands/cf-task/block.md +0 -36
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
- package/src/adapters/costrict/commands/cf-task/note.md +0 -236
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
- package/src/adapters/costrict/commands/cf-task/start.md +0 -304
- package/src/adapters/costrict/commands/cf-task/status.md +0 -59
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/costrict/commands/cf-validate.md +0 -75
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -59
- package/src/adapters/opencode/commands/cf-stats.md +0 -90
- package/src/adapters/opencode/commands/cf-sync.md +0 -29
- package/src/adapters/opencode/commands/cf-task/align.md +0 -314
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
- package/src/adapters/opencode/commands/cf-task/block.md +0 -40
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
- package/src/adapters/opencode/commands/cf-task/note.md +0 -240
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
- package/src/adapters/opencode/commands/cf-task/start.md +0 -309
- package/src/adapters/opencode/commands/cf-task/status.md +0 -63
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/opencode/commands/cf-validate.md +0 -79
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/cli.js +0 -1023
- package/src/core/code-flow/config.yml +0 -158
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_checks.py +0 -336
- package/src/core/code-flow/scripts/cf_core.py +0 -787
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_feedback.py +0 -56
- package/src/core/code-flow/scripts/cf_log.py +0 -154
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
- package/src/core/code-flow/scripts/cf_scan.py +0 -295
- package/src/core/code-flow/scripts/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
- package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
- package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
- package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
- package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
- package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
- package/src/core/code-flow/scripts/cf_stats.py +0 -506
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
- package/src/core/code-flow/scripts/cf_sync.py +0 -140
- package/src/core/code-flow/scripts/cf_task_index.py +0 -148
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_validation.py +0 -81
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -53
- package/src/migrate/spec-workflow.js +0 -238
package/lib/worktree.js
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const f = require('./fs');
|
|
7
|
+
const config = require('./config');
|
|
8
|
+
const taskdoc = require('./taskdoc');
|
|
9
|
+
const task = require('./task');
|
|
10
|
+
const init = require('./init');
|
|
11
|
+
const adapters = require('./adapters');
|
|
12
|
+
const toml = require('./toml');
|
|
13
|
+
|
|
14
|
+
const WORKTREES = '.code-flow/worktrees';
|
|
15
|
+
const TOOL_PREFIXES = ['.code-flow/templates/', '.code-flow/launcher/', '.claude/', '.codex/',
|
|
16
|
+
'.costrict/', '.opencode/', '.agents/'];
|
|
17
|
+
const TOOL_FILES = ['AGENTS.md', 'CLAUDE.md', 'opencode.json'];
|
|
18
|
+
const MIXED_PATHS = ['AGENTS.md', 'CLAUDE.md', '.codex/config.toml', '.codex/hooks.json',
|
|
19
|
+
'.claude/settings.local.json', '.costrict/settings.local.json'];
|
|
20
|
+
|
|
21
|
+
function git(root, args, raw = false) {
|
|
22
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
|
|
23
|
+
if (result.error || result.status !== 0) {
|
|
24
|
+
throw new f.FlowError('git_error', (result.stderr || result.error?.message || 'git failed').trim(), 1);
|
|
25
|
+
}
|
|
26
|
+
return raw ? result.stdout : result.stdout.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function diffPaths(root, args) {
|
|
30
|
+
return git(root, ['diff', '--name-only', '-z', ...args], true)
|
|
31
|
+
.split('\0').filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statusEntries(root) {
|
|
35
|
+
const raw = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], true);
|
|
36
|
+
const entries = [], parts = raw.split('\0');
|
|
37
|
+
for (let index = 0; index < parts.length; index++) {
|
|
38
|
+
const entry = parts[index];
|
|
39
|
+
if (!entry) continue;
|
|
40
|
+
const code = entry.slice(0, 2), relative = entry.slice(3);
|
|
41
|
+
entries.push({ code, path: relative });
|
|
42
|
+
if (code[0] === 'R' || code[0] === 'C') {
|
|
43
|
+
const source = parts[++index];
|
|
44
|
+
if (source) entries.push({ code: 'D', path: source, renamed: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return entries;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function canonicalText(text) {
|
|
51
|
+
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hashFile(file) {
|
|
55
|
+
return fs.existsSync(file) && fs.statSync(file).isFile()
|
|
56
|
+
? crypto.createHash('sha256').update(canonicalText(fs.readFileSync(file, 'utf8'))).digest('hex') : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hashText(text) {
|
|
60
|
+
return text === null ? null : crypto.createHash('sha256').update(canonicalText(text)).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function commitContent(worktree, relative) {
|
|
64
|
+
const result = spawnSync('git', ['show', 'HEAD:' + relative], { cwd: worktree, maxBuffer: 8 * 1024 * 1024 });
|
|
65
|
+
return result.status === 0
|
|
66
|
+
? crypto.createHash('sha256').update(canonicalText(result.stdout.toString('utf8'))).digest('hex') : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function commitText(worktree, relative) {
|
|
70
|
+
const result = spawnSync('git', ['show', 'HEAD:' + relative], { cwd: worktree, maxBuffer: 8 * 1024 * 1024 });
|
|
71
|
+
return result.status === 0 ? canonicalText(result.stdout.toString('utf8')) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function commitTextAt(worktree, revision, relative) {
|
|
75
|
+
const result = spawnSync('git', ['show', revision + ':' + relative], { cwd: worktree, maxBuffer: 8 * 1024 * 1024 });
|
|
76
|
+
return result.status === 0 ? canonicalText(result.stdout.toString('utf8')) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stripInstructions(text) {
|
|
80
|
+
return canonicalText(text).replace(/<!-- code-flow:v1 start -->[\s\S]*?<!-- code-flow:v1 end -->\n?/g, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function codexComment(line) {
|
|
84
|
+
const match = /(#.*)$/.exec(line);
|
|
85
|
+
return match ? match[1] : '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stripCodexConfig(text, keepHeader = false) {
|
|
89
|
+
const value = canonicalText(text), found = toml.featuresHooks(value), lines = value.split('\n');
|
|
90
|
+
if (found.unsupported) return value;
|
|
91
|
+
if (found.header < 0 && found.key < 0) return value;
|
|
92
|
+
if (found.header < 0) {
|
|
93
|
+
const comment = found.key >= 0 ? codexComment(lines[found.key]) : '';
|
|
94
|
+
const rest = lines.filter((line, index) => index !== found.key);
|
|
95
|
+
if (comment) rest.push('# ' + comment);
|
|
96
|
+
return rest.join('\n');
|
|
97
|
+
}
|
|
98
|
+
const prefix = lines.slice(0, found.header);
|
|
99
|
+
const body = lines.slice(found.header + 1, found.end);
|
|
100
|
+
const relative = found.key >= 0 ? found.key - found.header - 1 : -1;
|
|
101
|
+
const comment = relative >= 0 ? codexComment(body[relative]) : '';
|
|
102
|
+
const headerComment = codexComment(lines[found.header]);
|
|
103
|
+
const userLines = body.filter((line, index) => index !== relative);
|
|
104
|
+
if (!userLines.some(line => line.trim()) && !comment && !headerComment) {
|
|
105
|
+
return (keepHeader ? [...prefix, lines[found.header]] : prefix)
|
|
106
|
+
.concat(lines.slice(found.end)).join('\n');
|
|
107
|
+
}
|
|
108
|
+
const content = [lines[found.header], ...userLines];
|
|
109
|
+
if (comment) content.push('# ' + comment);
|
|
110
|
+
return [...prefix, ...content, ...lines.slice(found.end)].join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stripManagedHooks(text) {
|
|
114
|
+
try {
|
|
115
|
+
const data = JSON.parse(text), hooks = data.hooks ?? {};
|
|
116
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
117
|
+
const kept = (Array.isArray(groups) ? groups : []).map(group => ({
|
|
118
|
+
...group, hooks: Array.isArray(group?.hooks)
|
|
119
|
+
? group.hooks.filter(item => !adapters.managed(item)) : []
|
|
120
|
+
})).filter(group => group.hooks.length);
|
|
121
|
+
if (kept.length) hooks[event] = kept; else delete hooks[event];
|
|
122
|
+
}
|
|
123
|
+
if (!Object.keys(hooks).length) delete data.hooks;
|
|
124
|
+
return JSON.stringify(data);
|
|
125
|
+
} catch (_) { return text; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mixedKind(relative) {
|
|
129
|
+
if (relative === 'AGENTS.md' || relative === 'CLAUDE.md') return 'instructions';
|
|
130
|
+
if (relative === '.codex/hooks.json' || relative.endsWith('settings.local.json')) return 'settings';
|
|
131
|
+
if (relative === '.codex/config.toml') return 'codex-config';
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function residualUserContent(kind, text) {
|
|
136
|
+
if (!kind || text === null) return '';
|
|
137
|
+
if (kind === 'instructions') return stripInstructions(text).trim();
|
|
138
|
+
if (kind === 'settings') {
|
|
139
|
+
const value = stripManagedHooks(text).trim();
|
|
140
|
+
return value === '{}' ? '' : value;
|
|
141
|
+
}
|
|
142
|
+
if (kind === 'codex-config') return stripCodexConfig(text).trim();
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function mixedUserContent(relative, before, after) {
|
|
147
|
+
const kind = mixedKind(relative);
|
|
148
|
+
if (!kind || before === null) return '';
|
|
149
|
+
const keepHeader = kind === 'codex-config' && toml.featuresHooks(before).header >= 0;
|
|
150
|
+
const beforeText = kind === 'instructions' ? stripInstructions(before) :
|
|
151
|
+
kind === 'settings' ? stripManagedHooks(before) :
|
|
152
|
+
kind === 'codex-config' ? stripCodexConfig(before, keepHeader) : before;
|
|
153
|
+
const afterText = kind === 'instructions' ? stripInstructions(after) :
|
|
154
|
+
kind === 'settings' ? stripManagedHooks(after) :
|
|
155
|
+
kind === 'codex-config' ? stripCodexConfig(after, keepHeader) : after;
|
|
156
|
+
return beforeText.trim() === afterText.trim() ? '' : 'user content';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function assertMixedFiles(worktree, baseRef) {
|
|
160
|
+
for (const relative of MIXED_PATHS) {
|
|
161
|
+
const kind = mixedKind(relative), file = path.join(worktree, relative);
|
|
162
|
+
if (!kind) continue;
|
|
163
|
+
const baseline = commitTextAt(worktree, baseRef, relative);
|
|
164
|
+
if (baseline === null && !fs.existsSync(file)) continue;
|
|
165
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
166
|
+
if (baseline === null) {
|
|
167
|
+
f.demand(!residualUserContent(kind, current ?? ''), 'init_user_content',
|
|
168
|
+
relative + ' contains uncommitted user content; commit or remove it before prepare', 1);
|
|
169
|
+
} else {
|
|
170
|
+
f.demand(!mixedUserContent(relative, baseline, current ?? ''), 'init_user_content',
|
|
171
|
+
relative + ' contains uncommitted user content; commit or remove it before prepare', 1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isTool(relative) {
|
|
177
|
+
return TOOL_FILES.includes(relative) || TOOL_PREFIXES.some(prefix => relative.startsWith(prefix));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isRequirement(relative, requirementDir) {
|
|
181
|
+
return relative === requirementDir || relative.startsWith(requirementDir + '/');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function baselinePaths(entries, requirementDir) {
|
|
185
|
+
const allowed = [], tool = [], other = [], staged = [];
|
|
186
|
+
const isAllowed = relative => relative.startsWith('.code-flow/specs/') ||
|
|
187
|
+
relative === '.code-flow/config.json' || isRequirement(relative, requirementDir);
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
const isStaged = entry.code[0] !== ' ' && entry.code[0] !== '?';
|
|
190
|
+
if (isAllowed(entry.path)) {
|
|
191
|
+
if (!entry.renamed) allowed.push(entry.path);
|
|
192
|
+
} else if (isStaged) staged.push(entry.path);
|
|
193
|
+
else if (isTool(entry.path)) tool.push(entry.path);
|
|
194
|
+
else other.push(entry.path);
|
|
195
|
+
}
|
|
196
|
+
if (staged.length) throw new f.FlowError('staged_tree',
|
|
197
|
+
'Staged changes outside baseline: ' + staged.slice(0, 8).join(', '), 1);
|
|
198
|
+
if (other.length) throw new f.FlowError('dirty_tree',
|
|
199
|
+
'Uncommitted changes outside .code-flow docs: ' + other.slice(0, 8).join(', '), 1);
|
|
200
|
+
return { allowed, tool };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function commitBaseline(root, requirementDir, runId) {
|
|
204
|
+
const before = git(root, ['rev-parse', 'HEAD']);
|
|
205
|
+
const entries = statusEntries(root);
|
|
206
|
+
const { allowed } = baselinePaths(entries, requirementDir);
|
|
207
|
+
if (!allowed.length) return before;
|
|
208
|
+
git(root, ['add', '--', ...allowed]);
|
|
209
|
+
git(root, ['commit', '-q', '-m', 'code-flow: baseline ' + runId]);
|
|
210
|
+
return git(root, ['rev-parse', 'HEAD']);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function specsHash(root) {
|
|
214
|
+
const base = path.join(root, '.code-flow/specs');
|
|
215
|
+
const digest = crypto.createHash('sha256');
|
|
216
|
+
if (!fs.existsSync(base)) return digest.digest('hex');
|
|
217
|
+
const walk = directory => fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
|
|
218
|
+
.flatMap(entry => {
|
|
219
|
+
if (entry.isSymbolicLink()) throw new f.FlowError('specs_incomplete',
|
|
220
|
+
'Specs must not contain symlinks: ' + path.join(directory, entry.name), 1);
|
|
221
|
+
return entry.isDirectory() ? walk(path.join(directory, entry.name))
|
|
222
|
+
: entry.isFile() ? [path.join(directory, entry.name)] : [];
|
|
223
|
+
});
|
|
224
|
+
for (const file of walk(base)) {
|
|
225
|
+
const relative = path.relative(base, file).split(path.sep).join('/');
|
|
226
|
+
digest.update(relative + '\0'); digest.update(canonicalText(fs.readFileSync(file, 'utf8'))); digest.update('\0');
|
|
227
|
+
}
|
|
228
|
+
return digest.digest('hex');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function ledgerPath(root, runId) { return path.join(root, WORKTREES, runId, 'run.json'); }
|
|
232
|
+
|
|
233
|
+
function readLedger(root, runId) {
|
|
234
|
+
const raw = f.readText(ledgerPath(root, runId), 1024 * 1024);
|
|
235
|
+
f.demand(raw !== null, 'run_not_found', 'Missing worktree run: ' + runId, 2);
|
|
236
|
+
let ledger;
|
|
237
|
+
try { ledger = JSON.parse(raw); } catch (error) {
|
|
238
|
+
throw new f.FlowError('run_invalid', 'Invalid run ledger: ' + error.message, 2);
|
|
239
|
+
}
|
|
240
|
+
const errors = require('./runs').validateLedger(ledger, runId);
|
|
241
|
+
f.demand(!errors.length, 'run_invalid', errors[0] || ('Invalid run ledger: ' + runId), 2);
|
|
242
|
+
return ledger;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function writeLedger(root, ledger) {
|
|
246
|
+
const file = ledgerPath(root, ledger.run_id);
|
|
247
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
248
|
+
const temporary = file + '.tmp';
|
|
249
|
+
fs.writeFileSync(temporary, JSON.stringify(ledger, null, 2) + '\n');
|
|
250
|
+
fs.renameSync(temporary, file);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function ensureRunId(value) {
|
|
254
|
+
f.demand(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value),
|
|
255
|
+
'invalid_run_id', 'Invalid run id', 2);
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function worktreePath(runId, taskId) { return WORKTREES + '/' + runId + '/' + taskId; }
|
|
260
|
+
function branchName(runId, taskId) { return 'cf-task/' + runId + '/' + taskId; }
|
|
261
|
+
|
|
262
|
+
function selectedTasks(document, taskIds) {
|
|
263
|
+
f.demand(Array.isArray(taskIds) && taskIds.length > 0, 'tasks_required', 'Select at least one TASK', 2);
|
|
264
|
+
f.demand(new Set(taskIds).size === taskIds.length, 'tasks_duplicate', 'TASK list contains duplicates', 2);
|
|
265
|
+
const byId = new Map(document.tasks.map(item => [item.id, item]));
|
|
266
|
+
const selected = taskIds.map(id => {
|
|
267
|
+
f.demand(byId.has(id), 'unknown_task', 'Unknown TASK: ' + id, 2);
|
|
268
|
+
return byId.get(id);
|
|
269
|
+
});
|
|
270
|
+
const first = taskdoc.batches(document.tasks)[0] || [];
|
|
271
|
+
const notReady = selected.filter(item => !first.includes(item.id)).map(item => item.id);
|
|
272
|
+
f.demand(!notReady.length, 'task_not_ready', 'Not in current dependency batch: ' + notReady.join(', '), 1);
|
|
273
|
+
return selected;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function plannedInitChanges(worktree, platforms, previous = []) {
|
|
277
|
+
const paths = new Map(previous.map(item => [item.path, { path: item.path, after_sha256: item.after_sha256 }]));
|
|
278
|
+
for (const platform of platforms) {
|
|
279
|
+
for (const item of init.plan(worktree, { platform }).plans) {
|
|
280
|
+
if (!paths.has(item.path)) paths.set(item.path, item);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return [...paths].sort(([left], [right]) => left.localeCompare(right)).map(([relative, item]) => {
|
|
284
|
+
const beforeHash = commitContent(worktree, relative);
|
|
285
|
+
return { path: relative, status: beforeHash === null ? 'created' : 'modified',
|
|
286
|
+
tracked: beforeHash !== null, before_sha256: beforeHash,
|
|
287
|
+
after_sha256: item.after !== undefined ? hashText(item.after) : item.after_sha256 };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function initWorktree(worktree, platforms, previous = []) {
|
|
292
|
+
const planned = new Map(previous.map(item => [item.path, { ...item }]));
|
|
293
|
+
for (const platform of platforms) {
|
|
294
|
+
const prepared = init.plan(worktree, { platform }), conflict = prepared.conflicts[0];
|
|
295
|
+
f.demand(!conflict, conflict ? conflict.code : 'init_conflict',
|
|
296
|
+
conflict ? conflict.path + ': ' + conflict.message : '', 1);
|
|
297
|
+
for (const item of prepared.plans) {
|
|
298
|
+
if (!planned.has(item.path)) planned.set(item.path, item);
|
|
299
|
+
}
|
|
300
|
+
await init.init(worktree, { platform });
|
|
301
|
+
}
|
|
302
|
+
const changes = [];
|
|
303
|
+
for (const [relative, item] of [...planned].sort(([left], [right]) => left.localeCompare(right))) {
|
|
304
|
+
const file = path.join(worktree, relative);
|
|
305
|
+
const after = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
306
|
+
const before = commitText(worktree, relative);
|
|
307
|
+
const kind = mixedKind(relative);
|
|
308
|
+
if (before === null && kind) {
|
|
309
|
+
const residual = residualUserContent(kind, after ?? '');
|
|
310
|
+
f.demand(!residual, 'init_user_content',
|
|
311
|
+
relative + ' contains uncommitted user content; commit or remove it before prepare', 1);
|
|
312
|
+
}
|
|
313
|
+
const userContent = mixedUserContent(relative, before, after ?? '');
|
|
314
|
+
f.demand(!userContent, 'init_user_content',
|
|
315
|
+
relative + ' contains uncommitted user content; commit or remove it before prepare', 1);
|
|
316
|
+
if (!kind && item.after_sha256 !== undefined && item.after_sha256 !== null &&
|
|
317
|
+
hashFile(file) !== item.after_sha256) {
|
|
318
|
+
throw new f.FlowError('init_incomplete',
|
|
319
|
+
relative + ' was not written exactly as planned; retry prepare', 1);
|
|
320
|
+
}
|
|
321
|
+
const beforeHash = commitContent(worktree, relative);
|
|
322
|
+
changes.push({ path: relative, status: beforeHash === null ? 'created' : 'modified',
|
|
323
|
+
tracked: beforeHash !== null, before_sha256: beforeHash,
|
|
324
|
+
after_sha256: hashFile(file) });
|
|
325
|
+
}
|
|
326
|
+
return changes;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function integrationEnv(runId, order) {
|
|
330
|
+
return { CF_WORKTREE_ID: runId + '/integration', CF_TASK_ID: '',
|
|
331
|
+
CF_TASK_IDS: order.join(','), CF_RUN_ID: runId };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function integrationPlan(tasks, order) {
|
|
335
|
+
const byId = new Map(tasks.map(item => [item.id, item]));
|
|
336
|
+
const tests = [], sources = [], skipped = [];
|
|
337
|
+
for (const id of order) {
|
|
338
|
+
const item = byId.get(id);
|
|
339
|
+
f.demand(item, 'task_missing', 'Missing TASK in document: ' + id, 1);
|
|
340
|
+
if (!item.tests.length) {
|
|
341
|
+
if (item.skipTests) skipped.push({ task: id, reason: item.skipReason });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
for (const test of item.tests) {
|
|
345
|
+
const scoped = id + ':' + test.id;
|
|
346
|
+
tests.push({ ...test, id: scoped });
|
|
347
|
+
sources.push({ id: scoped, task: id, test: test.id });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { tests, sources, skipped, sourceById: new Map(sources.map(item => [item.id, item])) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function assertCoordinator(root, ledger) {
|
|
354
|
+
require('./repo').requireCoordinator(root, 'task parallel');
|
|
355
|
+
const top = f.realpath(git(root, ['rev-parse', '--show-toplevel']));
|
|
356
|
+
f.demand(top === ledger.coordinator.root, 'coordinator_changed',
|
|
357
|
+
'Coordinator worktree changed for run ' + ledger.run_id, 1);
|
|
358
|
+
f.demand(git(root, ['symbolic-ref', '-q', 'HEAD']) === ledger.coordinator.branch,
|
|
359
|
+
'coordinator_changed', 'Coordinator branch changed for run ' + ledger.run_id, 1);
|
|
360
|
+
const common = f.realpath(path.resolve(root, git(root, ['rev-parse', '--git-common-dir'])));
|
|
361
|
+
f.demand(common === ledger.coordinator.git_common_dir, 'coordinator_changed',
|
|
362
|
+
'Coordinator repository changed for run ' + ledger.run_id, 1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function assertInitState(worktree, worker) {
|
|
366
|
+
for (const change of worker.init_changes) {
|
|
367
|
+
f.demand(hashFile(path.join(worktree, change.path)) === change.after_sha256,
|
|
368
|
+
'init_changed', worker.task + ' changed managed tool file: ' + change.path, 1);
|
|
369
|
+
}
|
|
370
|
+
const committed = diffPaths(worktree, [worker.base_head + '..HEAD']);
|
|
371
|
+
const tool = committed.filter(relative => isTool(relative) ||
|
|
372
|
+
worker.init_changes.some(item => item.path === relative));
|
|
373
|
+
f.demand(!tool.length, 'init_committed',
|
|
374
|
+
worker.task + ' committed managed tool files: ' + tool.slice(0, 8).join(', '), 1);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function taskAt(root, revision, taskFile) {
|
|
378
|
+
return taskdoc.parse(git(root, ['show', revision + ':' + taskFile], true), path.join(root, taskFile));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function assertScope(root, ledger, worker, head) {
|
|
382
|
+
f.demand(spawnSync('git', ['merge-base', '--is-ancestor', worker.base_head, head],
|
|
383
|
+
{ cwd: root }).status === 0, 'worktree_scope_violation',
|
|
384
|
+
worker.task + ' branch is not based on baseline', 1);
|
|
385
|
+
const requirementDir = path.posix.dirname(ledger.task_file);
|
|
386
|
+
const paths = diffPaths(root, [worker.base_head + '..' + head]);
|
|
387
|
+
const illegal = paths.filter(relative => {
|
|
388
|
+
if (relative === ledger.task_file) return false;
|
|
389
|
+
if (isTool(relative)) return true;
|
|
390
|
+
if (relative.startsWith('.code-flow/')) return true;
|
|
391
|
+
if (relative === requirementDir || relative.startsWith(requirementDir + '/')) return true;
|
|
392
|
+
return false;
|
|
393
|
+
});
|
|
394
|
+
f.demand(!illegal.length, 'worktree_scope_violation',
|
|
395
|
+
worker.task + ' changed shared paths: ' + illegal.slice(0, 8).join(', '), 1);
|
|
396
|
+
const base = taskAt(root, worker.base_head, ledger.task_file);
|
|
397
|
+
const current = taskAt(root, head, ledger.task_file);
|
|
398
|
+
f.demand(JSON.stringify(base.meta) === JSON.stringify(current.meta) &&
|
|
399
|
+
base.preamble === current.preamble, 'worktree_scope_violation',
|
|
400
|
+
worker.task + ' changed shared task-document structure', 1);
|
|
401
|
+
f.demand(base.tasks.map(item => item.id).join(',') === current.tasks.map(item => item.id).join(','),
|
|
402
|
+
'worktree_scope_violation', worker.task + ' changed TASK set or order', 1);
|
|
403
|
+
const baseMap = new Map(base.tasks.map(item => [item.id, item.text]));
|
|
404
|
+
for (const item of current.tasks) {
|
|
405
|
+
if (item.id !== worker.task) f.demand(item.text === baseMap.get(item.id), 'worktree_scope_violation',
|
|
406
|
+
worker.task + ' changed another TASK: ' + item.id, 1);
|
|
407
|
+
}
|
|
408
|
+
const own = current.tasks.find(item => item.id === worker.task);
|
|
409
|
+
f.demand(own && own.status === 'done', 'task_not_done', worker.task + ' is not done', 1);
|
|
410
|
+
f.demand(own.text !== baseMap.get(worker.task), 'no_task_change',
|
|
411
|
+
worker.task + ' has no task-section change', 1);
|
|
412
|
+
return own;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function verifyCollected(root, ledger, worker) {
|
|
416
|
+
const worktree = path.join(root, worker.path);
|
|
417
|
+
f.demand(fs.existsSync(worktree), 'worktree_missing', 'Missing worktree: ' + worker.path, 1);
|
|
418
|
+
f.demand(f.realpath(git(worktree, ['rev-parse', '--show-toplevel'])) === f.realpath(worktree),
|
|
419
|
+
'worktree_mismatch', 'Path is not the registered worktree: ' + worker.path, 1);
|
|
420
|
+
f.demand(f.realpath(path.resolve(worktree, git(worktree, ['rev-parse', '--git-common-dir']))) ===
|
|
421
|
+
ledger.coordinator.git_common_dir, 'worktree_mismatch',
|
|
422
|
+
worker.task + ' uses another repository', 1);
|
|
423
|
+
f.demand(git(worktree, ['symbolic-ref', '-q', 'HEAD']) === 'refs/heads/' + worker.branch,
|
|
424
|
+
'worktree_mismatch', worker.task + ' is on the wrong branch', 1);
|
|
425
|
+
const branch = git(root, ['rev-parse', 'refs/heads/' + worker.branch]);
|
|
426
|
+
f.demand(branch === worker.collected_head, 'worker_changed',
|
|
427
|
+
worker.task + ' branch moved after collect', 1);
|
|
428
|
+
f.demand(git(worktree, ['rev-parse', 'HEAD']) === worker.collected_head, 'worker_changed',
|
|
429
|
+
worker.task + ' HEAD moved after collect', 1);
|
|
430
|
+
assertInitState(worktree, worker);
|
|
431
|
+
const managed = new Set(worker.init_changes.map(item => item.path));
|
|
432
|
+
const dirty = statusEntries(worktree).filter(entry => !managed.has(entry.path));
|
|
433
|
+
f.demand(!dirty.length, 'dirty_worktree',
|
|
434
|
+
worker.task + ' has uncommitted changes: ' + dirty.slice(0, 8).map(item => item.path).join(', '), 1);
|
|
435
|
+
assertMixedFiles(worktree, worker.base_head);
|
|
436
|
+
const current = taskAt(root, worker.collected_head, ledger.task_file).tasks.find(item => item.id === worker.task);
|
|
437
|
+
f.demand(current && crypto.createHash('sha256').update(current.text).digest('hex') ===
|
|
438
|
+
worker.collected_task_sha256, 'worker_changed', worker.task + ' task section changed after collect', 1);
|
|
439
|
+
return current;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function prepare(root, options) {
|
|
443
|
+
return require('./locks').withRepository(root, () => prepareLocked(root, options));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function ensureWorker(root, worker, baseHead) {
|
|
447
|
+
const worktree = path.join(root, worker.path);
|
|
448
|
+
const branchRef = 'refs/heads/' + worker.branch;
|
|
449
|
+
const branch = spawnSync('git', ['rev-parse', '--verify', branchRef], { cwd: root, encoding: 'utf8' });
|
|
450
|
+
if (fs.existsSync(worktree)) {
|
|
451
|
+
f.demand(f.realpath(git(worktree, ['rev-parse', '--show-toplevel'])) === f.realpath(worktree),
|
|
452
|
+
'worktree_mismatch', 'Path is not the registered worktree: ' + worker.path, 1);
|
|
453
|
+
f.demand(git(worktree, ['symbolic-ref', '-q', 'HEAD']) === branchRef,
|
|
454
|
+
'worktree_mismatch', 'Worktree is on the wrong branch: ' + worker.path, 1);
|
|
455
|
+
f.demand(git(worktree, ['rev-parse', 'HEAD']) === baseHead,
|
|
456
|
+
'worktree_changed', 'Worktree has commits beyond baseline: ' + worker.path, 1);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (branch.status === 0) {
|
|
460
|
+
f.demand(branch.stdout.trim() === baseHead, 'branch_changed',
|
|
461
|
+
'Branch moved beyond baseline: ' + worker.branch, 1);
|
|
462
|
+
git(root, ['worktree', 'add', worktree, worker.branch]);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
git(root, ['worktree', 'add', '-b', worker.branch, worktree, baseHead]);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function preflightNewRun(root, runId, taskFile, selected, order) {
|
|
469
|
+
const claimed = require('./runs').unfinished(root, run => run.ledger &&
|
|
470
|
+
run.task_file === taskFile && Array.isArray(run.ledger.worktrees) &&
|
|
471
|
+
run.ledger.worktrees.some(worker => order.includes(worker.task) && worker.status !== 'removed'));
|
|
472
|
+
f.demand(!claimed.length, 'task_claimed',
|
|
473
|
+
'Task already claimed by unfinished runs: ' + claimed.map(run => run.run_id).join(', '), 1);
|
|
474
|
+
for (const item of selected) {
|
|
475
|
+
f.demand(!fs.existsSync(path.join(root, worktreePath(runId, item.id))), 'worktree_exists',
|
|
476
|
+
'Worktree path already exists: ' + worktreePath(runId, item.id), 1);
|
|
477
|
+
f.demand(spawnSync('git', ['rev-parse', '--verify', 'refs/heads/' + branchName(runId, item.id)],
|
|
478
|
+
{ cwd: root }).status !== 0, 'branch_exists', 'Branch already exists: ' + branchName(runId, item.id), 1);
|
|
479
|
+
}
|
|
480
|
+
const repo = f.realpath(git(root, ['rev-parse', '--show-toplevel']));
|
|
481
|
+
f.demand(repo === f.realpath(root), 'not_repository_root', 'Run at the repository root', 1);
|
|
482
|
+
f.demand(git(root, ['rev-parse', '--is-bare-repository']) === 'false',
|
|
483
|
+
'not_repository', 'Bare repositories are not supported', 1);
|
|
484
|
+
f.demand(spawnSync('git', ['check-ignore', '-q', WORKTREES + '/' + runId + '/probe'],
|
|
485
|
+
{ cwd: root }).status === 0, 'worktrees_not_ignored', 'Add ' + WORKTREES + '/ to .gitignore', 1);
|
|
486
|
+
const branch = git(root, ['symbolic-ref', '-q', 'HEAD']);
|
|
487
|
+
f.demand(branch.startsWith('refs/heads/'), 'detached_head', 'Coordinator must be on a branch', 1);
|
|
488
|
+
const commonReal = f.realpath(path.resolve(root, git(root, ['rev-parse', '--git-common-dir'])));
|
|
489
|
+
return { requirementDir: path.posix.dirname(taskFile), initialHead: git(root, ['rev-parse', 'HEAD']),
|
|
490
|
+
coordinator: { root: repo, git_common_dir: commonReal, repository_key: f.hash(commonReal), branch } };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function buildLedger(runId, taskFile, selected, order, baseline, baseHash, platforms, workers, context) {
|
|
494
|
+
return { version: 1, run_id: runId, task_file: taskFile, base_head: baseline,
|
|
495
|
+
coordinator: { ...context.coordinator, initial_head: context.initialHead, base_head: baseline },
|
|
496
|
+
baseline_specs_sha256: baseHash, platforms, workers,
|
|
497
|
+
jobs: config.load(context.coordinator.root).tests.jobs,
|
|
498
|
+
status: 'preparing', operation: null,
|
|
499
|
+
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
|
500
|
+
worktrees: selected.map(item => ({
|
|
501
|
+
task: item.id, path: worktreePath(runId, item.id), branch: branchName(runId, item.id),
|
|
502
|
+
base_head: baseline, status: 'planned', collected_head: null, collected_task_sha256: null,
|
|
503
|
+
env: { CF_WORKTREE_ID: runId + '/' + item.id, CF_TASK_ID: item.id, CF_RUN_ID: runId },
|
|
504
|
+
init_changes: [], error: null
|
|
505
|
+
})),
|
|
506
|
+
merge: { order, steps: [], conflicts: [] },
|
|
507
|
+
integration: { status: 'not-run', tested_head: null, env: integrationEnv(runId, order),
|
|
508
|
+
commands: [], skipped: [], deduped: 0, elapsed_ms: 0, error: null },
|
|
509
|
+
cleanup: { steps: [], kept: [] } };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function plannedInitPaths(worktree, platforms) {
|
|
513
|
+
const planned = new Set();
|
|
514
|
+
for (const platform of platforms) {
|
|
515
|
+
for (const item of init.plan(worktree, { platform }).plans) planned.add(item.path);
|
|
516
|
+
}
|
|
517
|
+
return planned;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function assertWorkerReadyForInit(worktree, worker, platforms) {
|
|
521
|
+
if (!fs.existsSync(worktree) || worker.status === 'prepared') return;
|
|
522
|
+
const planned = plannedInitPaths(worktree, platforms);
|
|
523
|
+
for (const change of worker.init_changes) planned.add(change.path);
|
|
524
|
+
const dirty = statusEntries(worktree).filter(entry => !planned.has(entry.path));
|
|
525
|
+
f.demand(!dirty.length, 'dirty_worktree',
|
|
526
|
+
worker.task + ' has uncommitted changes: ' + dirty.slice(0, 8).map(item => item.path).join(', '), 1);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function prepareWorkers(root, ledger, platforms) {
|
|
530
|
+
for (const worker of ledger.worktrees) {
|
|
531
|
+
if (worker.status === 'prepared') continue;
|
|
532
|
+
try {
|
|
533
|
+
const worktree = path.join(root, worker.path);
|
|
534
|
+
assertWorkerReadyForInit(worktree, worker, platforms);
|
|
535
|
+
ensureWorker(root, worker, ledger.base_head);
|
|
536
|
+
worker.status = 'created';
|
|
537
|
+
ledger.updated_at = new Date().toISOString();
|
|
538
|
+
writeLedger(root, ledger);
|
|
539
|
+
worker.init_changes = plannedInitChanges(worktree, platforms, worker.init_changes);
|
|
540
|
+
writeLedger(root, ledger);
|
|
541
|
+
worker.init_changes = await initWorktree(worktree, platforms, worker.init_changes);
|
|
542
|
+
f.demand(specsHash(worktree) === ledger.baseline_specs_sha256, 'specs_incomplete',
|
|
543
|
+
worker.task + ' worker Specs snapshot differs from baseline', 1);
|
|
544
|
+
assertMixedFiles(worktree, worker.base_head);
|
|
545
|
+
worker.status = 'prepared';
|
|
546
|
+
worker.error = null;
|
|
547
|
+
ledger.updated_at = new Date().toISOString();
|
|
548
|
+
writeLedger(root, ledger);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
worker.error = error.message;
|
|
551
|
+
ledger.status = 'blocked';
|
|
552
|
+
ledger.updated_at = new Date().toISOString();
|
|
553
|
+
writeLedger(root, ledger);
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
ledger.status = 'running';
|
|
558
|
+
ledger.error = null;
|
|
559
|
+
ledger.updated_at = new Date().toISOString();
|
|
560
|
+
writeLedger(root, ledger);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function prepareLocked(root, options) {
|
|
564
|
+
require('./repo').requireCoordinator(root, 'task parallel prepare');
|
|
565
|
+
const runId = ensureRunId(options.runId);
|
|
566
|
+
const document = task.loadDocument(root, options.taskFile);
|
|
567
|
+
const taskFile = document.relative;
|
|
568
|
+
const selected = selectedTasks(document, options.tasks);
|
|
569
|
+
const order = selected.map(item => item.id);
|
|
570
|
+
if (fs.existsSync(ledgerPath(root, runId))) {
|
|
571
|
+
return resumePrepare(root, runId, taskFile, order);
|
|
572
|
+
}
|
|
573
|
+
const context = preflightNewRun(root, runId, taskFile, selected, order);
|
|
574
|
+
const cleanDocs = baselinePaths(statusEntries(root), context.requirementDir).allowed;
|
|
575
|
+
if (options.noAutoBaseline) {
|
|
576
|
+
f.demand(!cleanDocs.length, 'baseline_dirty',
|
|
577
|
+
'--no-auto-baseline requires committed .code-flow docs', 1);
|
|
578
|
+
}
|
|
579
|
+
const baseline = options.noAutoBaseline ? context.initialHead :
|
|
580
|
+
commitBaseline(root, context.requirementDir, runId);
|
|
581
|
+
const platforms = options.noInit ? [] : init.installed(root);
|
|
582
|
+
const workers = options.workers ?? 2;
|
|
583
|
+
f.positive(workers, 'workers', true);
|
|
584
|
+
const ledger = buildLedger(runId, taskFile, selected, order, baseline, specsHash(root),
|
|
585
|
+
platforms, workers, context);
|
|
586
|
+
writeLedger(root, ledger);
|
|
587
|
+
await prepareWorkers(root, ledger, platforms);
|
|
588
|
+
return { ok: true, action: 'prepare', run_id: runId, base_head: baseline,
|
|
589
|
+
workers: ledger.workers, jobs: ledger.jobs, worktrees: ledger.worktrees };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function assertResumeIdentity(root, ledger, runId, taskFile, order) {
|
|
593
|
+
f.demand(ledger.task_file === taskFile, 'run_mismatch', 'Run uses another task document: ' + runId, 1);
|
|
594
|
+
f.demand(JSON.stringify(ledger.merge.order) === JSON.stringify(order), 'run_mismatch',
|
|
595
|
+
'Run uses another TASK order: ' + runId, 1);
|
|
596
|
+
f.demand(['preparing', 'running', 'blocked'].includes(ledger.status), 'run_exists',
|
|
597
|
+
'Worktree run already exists: ' + runId, 1);
|
|
598
|
+
f.demand(ledger.worktrees.every(item => ['planned', 'created', 'prepared'].includes(item.status)),
|
|
599
|
+
'run_exists', 'Run has progressed beyond prepare: ' + runId, 1);
|
|
600
|
+
assertCoordinator(root, ledger);
|
|
601
|
+
f.demand(git(root, ['rev-parse', 'HEAD']) === ledger.base_head, 'baseline_changed',
|
|
602
|
+
'Coordinator HEAD moved after baseline', 1);
|
|
603
|
+
f.demand(specsHash(root) === ledger.baseline_specs_sha256, 'specs_changed',
|
|
604
|
+
'Specs changed after baseline', 1);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function assertPreparedWorker(root, worker, specs) {
|
|
608
|
+
const worktree = path.join(root, worker.path);
|
|
609
|
+
f.demand(fs.existsSync(worktree), 'worktree_missing', 'Missing worktree: ' + worker.path, 1);
|
|
610
|
+
f.demand(git(worktree, ['rev-parse', 'HEAD']) === worker.base_head, 'worktree_changed',
|
|
611
|
+
'Worker has commits before dispatch: ' + worker.path, 1);
|
|
612
|
+
f.demand(specsHash(worktree) === specs, 'specs_incomplete',
|
|
613
|
+
worker.task + ' worker Specs snapshot differs from baseline', 1);
|
|
614
|
+
assertInitState(worktree, worker);
|
|
615
|
+
assertMixedFiles(worktree, worker.base_head);
|
|
616
|
+
const managed = new Set(worker.init_changes.map(item => item.path));
|
|
617
|
+
const dirty = statusEntries(worktree).filter(entry => !managed.has(entry.path));
|
|
618
|
+
f.demand(!dirty.length, 'dirty_worktree', 'Worker changed before dispatch: ' + worker.path, 1);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function resumePrepare(root, runId, taskFile, order) {
|
|
622
|
+
const ledger = readLedger(root, runId);
|
|
623
|
+
assertResumeIdentity(root, ledger, runId, taskFile, order);
|
|
624
|
+
for (const worker of ledger.worktrees) {
|
|
625
|
+
if (worker.status === 'prepared') assertPreparedWorker(root, worker, ledger.baseline_specs_sha256);
|
|
626
|
+
}
|
|
627
|
+
await prepareWorkers(root, ledger, ledger.platforms ?? []);
|
|
628
|
+
return { ok: true, action: 'prepare', run_id: runId, resumed: true,
|
|
629
|
+
base_head: ledger.base_head, workers: ledger.workers, jobs: ledger.jobs,
|
|
630
|
+
worktrees: ledger.worktrees };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function collect(root, runId) {
|
|
634
|
+
return require('./locks').withRepositorySync(root, () => collectLocked(root, runId));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function collectLocked(root, runId) {
|
|
638
|
+
const ledger = readLedger(root, runId);
|
|
639
|
+
assertCoordinator(root, ledger);
|
|
640
|
+
for (const worker of ledger.worktrees) {
|
|
641
|
+
if (worker.status === 'removed') continue;
|
|
642
|
+
const worktree = path.join(root, worker.path);
|
|
643
|
+
f.demand(fs.existsSync(worktree), 'worktree_missing', 'Missing worktree: ' + worker.path, 1);
|
|
644
|
+
const head = git(worktree, ['rev-parse', 'HEAD']);
|
|
645
|
+
f.demand(head !== worker.base_head, 'no_commit', worker.task + ' has no commit', 1);
|
|
646
|
+
if (worker.status === 'merged' && worker.collected_head === head) continue;
|
|
647
|
+
const current = assertScope(root, ledger, worker, head);
|
|
648
|
+
assertInitState(worktree, worker);
|
|
649
|
+
const managed = new Set(worker.init_changes.map(item => item.path));
|
|
650
|
+
const dirty = statusEntries(worktree).filter(entry => !managed.has(entry.path));
|
|
651
|
+
f.demand(!dirty.length, 'dirty_worktree',
|
|
652
|
+
worker.task + ' has uncommitted changes: ' + dirty.slice(0, 8).map(item => item.path).join(', '), 1);
|
|
653
|
+
worker.status = 'collected';
|
|
654
|
+
worker.collected_head = head;
|
|
655
|
+
worker.collected_task_sha256 = crypto.createHash('sha256').update(current.text).digest('hex');
|
|
656
|
+
}
|
|
657
|
+
ledger.status = 'merging';
|
|
658
|
+
ledger.updated_at = new Date().toISOString();
|
|
659
|
+
writeLedger(root, ledger);
|
|
660
|
+
return { ok: true, action: 'collect', run_id: runId, worktrees: ledger.worktrees };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function taskMerge(baseText, mainText, workerText, taskId, file) {
|
|
664
|
+
const base = taskdoc.parse(baseText, file), main = taskdoc.parse(mainText, file), worker = taskdoc.parse(workerText, file);
|
|
665
|
+
const ids = main.tasks.map(item => item.id).join(',');
|
|
666
|
+
f.demand(base.tasks.map(item => item.id).join(',') === ids && worker.tasks.map(item => item.id).join(',') === ids,
|
|
667
|
+
'task_merge_conflict', 'TASK set/order changed in worker', 1);
|
|
668
|
+
f.demand(JSON.stringify(worker.meta) === JSON.stringify(base.meta) && worker.preamble === base.preamble,
|
|
669
|
+
'task_merge_conflict', 'Worker changed shared task-document structure', 1);
|
|
670
|
+
const baseMap = new Map(base.tasks.map(item => [item.id, item.text]));
|
|
671
|
+
const mainMap = new Map(main.tasks.map(item => [item.id, item.text]));
|
|
672
|
+
const workerMap = new Map(worker.tasks.map(item => [item.id, item.text]));
|
|
673
|
+
const sections = main.tasks.map(item => {
|
|
674
|
+
const B = baseMap.get(item.id), M = mainMap.get(item.id), W = workerMap.get(item.id);
|
|
675
|
+
if (item.id === taskId) {
|
|
676
|
+
if (M === B) return W;
|
|
677
|
+
if (W === B) return M;
|
|
678
|
+
if (M === W) return M;
|
|
679
|
+
throw new f.FlowError('task_merge_conflict', 'Both sides changed ' + taskId, 1);
|
|
680
|
+
}
|
|
681
|
+
if (W !== B) throw new f.FlowError('task_merge_conflict', 'Worker changed another TASK: ' + item.id, 1);
|
|
682
|
+
return M;
|
|
683
|
+
});
|
|
684
|
+
return taskdoc.write({ ...main, body: main.preamble + sections.join('') }, main.preamble + sections.join(''));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function merge(root, runId) {
|
|
688
|
+
return require('./locks').withRepository(root, () => mergeLocked(root, runId));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function recoverMerge(root, worker) {
|
|
692
|
+
return spawnSync('git', ['merge-base', '--is-ancestor', worker.collected_head, 'HEAD'],
|
|
693
|
+
{ cwd: root }).status === 0;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function applyMerge(root, ledger, worker, before, head) {
|
|
697
|
+
const result = spawnSync('git', ['merge', '--no-ff', '--no-commit', head],
|
|
698
|
+
{ cwd: root, encoding: 'utf8' });
|
|
699
|
+
if (result.status !== 0) {
|
|
700
|
+
const conflicts = diffPaths(root, ['--diff-filter=U']);
|
|
701
|
+
if (!conflicts.length) {
|
|
702
|
+
spawnSync('git', ['merge', '--abort'], { cwd: root });
|
|
703
|
+
throw new f.FlowError('merge_failed',
|
|
704
|
+
(result.stderr || result.error?.message || 'git merge failed').trim(), 1);
|
|
705
|
+
}
|
|
706
|
+
if (conflicts.some(item => item !== ledger.task_file)) {
|
|
707
|
+
ledger.status = 'blocked'; ledger.merge.conflicts = conflicts;
|
|
708
|
+
ledger.operation = null; ledger.error = 'Code conflict';
|
|
709
|
+
writeLedger(root, ledger);
|
|
710
|
+
throw new f.FlowError('code_conflict', 'Code conflict: ' + conflicts.join(', '), 1);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const mergeBase = git(root, ['merge-base', before, head]);
|
|
714
|
+
const baseText = git(root, ['show', mergeBase + ':' + ledger.task_file]);
|
|
715
|
+
const mainText = git(root, ['show', before + ':' + ledger.task_file]);
|
|
716
|
+
const workerText = git(root, ['show', head + ':' + ledger.task_file]);
|
|
717
|
+
const body = taskMerge(baseText, mainText, workerText, worker.task, path.join(root, ledger.task_file));
|
|
718
|
+
fs.writeFileSync(path.join(root, ledger.task_file), body);
|
|
719
|
+
git(root, ['add', ledger.task_file]);
|
|
720
|
+
git(root, ['commit', '-q', '-m', 'Merge cf-task ' + worker.task]);
|
|
721
|
+
return git(root, ['rev-parse', 'HEAD']);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function mergeWorker(root, ledger, worker) {
|
|
725
|
+
if (worker.status === 'merged') return;
|
|
726
|
+
verifyCollected(root, ledger, worker);
|
|
727
|
+
const head = worker.collected_head;
|
|
728
|
+
if (recoverMerge(root, worker)) {
|
|
729
|
+
worker.status = 'merged';
|
|
730
|
+
ledger.merge.steps.push({ task: worker.task, collected_head: head, status: 'recovered' });
|
|
731
|
+
ledger.operation = null;
|
|
732
|
+
ledger.updated_at = new Date().toISOString();
|
|
733
|
+
writeLedger(root, ledger);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const before = git(root, ['rev-parse', 'HEAD']);
|
|
737
|
+
ledger.operation = { kind: 'merge', task: worker.task, expected_head: before,
|
|
738
|
+
collected_head: head, target: null };
|
|
739
|
+
ledger.updated_at = new Date().toISOString();
|
|
740
|
+
writeLedger(root, ledger);
|
|
741
|
+
try {
|
|
742
|
+
const after = applyMerge(root, ledger, worker, before, head);
|
|
743
|
+
worker.status = 'merged';
|
|
744
|
+
ledger.merge.steps.push({ task: worker.task, collected_head: head, before_head: before,
|
|
745
|
+
after_head: after, status: 'merged' });
|
|
746
|
+
ledger.operation = null;
|
|
747
|
+
ledger.updated_at = new Date().toISOString();
|
|
748
|
+
writeLedger(root, ledger);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if (error.code !== 'code_conflict') {
|
|
751
|
+
ledger.status = 'blocked';
|
|
752
|
+
ledger.operation = null;
|
|
753
|
+
ledger.error = error.message;
|
|
754
|
+
ledger.updated_at = new Date().toISOString();
|
|
755
|
+
writeLedger(root, ledger);
|
|
756
|
+
}
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function integrationCommands(plan, verified) {
|
|
762
|
+
return verified.results.map(result => {
|
|
763
|
+
const source = plan.sourceById.get(result.id) ?? { task: null, test: null };
|
|
764
|
+
return { ...result, task: source.task, test: source.test };
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function assertCoordinatorClean(root) {
|
|
769
|
+
const dirty = statusEntries(root).filter(entry => !isTool(entry.path) ||
|
|
770
|
+
(entry.code[0] !== ' ' && entry.code[0] !== '?'));
|
|
771
|
+
f.demand(!dirty.length, 'coordinator_dirty', 'Commit coordinator changes before merge: ' +
|
|
772
|
+
dirty.slice(0, 8).map(entry => entry.path).join(', '), 1);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function integrate(root, ledger, runId) {
|
|
776
|
+
assertCoordinatorClean(root);
|
|
777
|
+
ledger.operation = null;
|
|
778
|
+
const merged = task.loadDocument(root, ledger.task_file);
|
|
779
|
+
const plan = integrationPlan(merged.tasks, ledger.merge.order);
|
|
780
|
+
const settings = config.load(root), regression = settings.tests.regression;
|
|
781
|
+
if (regression) {
|
|
782
|
+
plan.tests.push(regression);
|
|
783
|
+
plan.sources.push({ id: regression.id, task: null, test: 'regression' });
|
|
784
|
+
plan.sourceById.set(regression.id, { task: null, test: 'regression' });
|
|
785
|
+
}
|
|
786
|
+
const testedHead = git(root, ['rev-parse', 'HEAD']);
|
|
787
|
+
const env = integrationEnv(runId, ledger.merge.order);
|
|
788
|
+
if (!plan.tests.length) {
|
|
789
|
+
ledger.status = 'cleaning';
|
|
790
|
+
ledger.integration = { status: 'skipped', tested_head: testedHead, env, regression: false,
|
|
791
|
+
commands: [], skipped: plan.skipped, deduped: 0, elapsed_ms: 0, error: null };
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
ledger.status = 'verifying';
|
|
795
|
+
ledger.integration = { status: 'running', tested_head: testedHead, env, regression: Boolean(regression),
|
|
796
|
+
commands: [], skipped: plan.skipped, deduped: 0, elapsed_ms: 0, error: null };
|
|
797
|
+
ledger.updated_at = new Date().toISOString();
|
|
798
|
+
writeLedger(root, ledger);
|
|
799
|
+
const verified = await require('./verify').run(root, plan.tests, settings.tests, { env });
|
|
800
|
+
const commands = integrationCommands(plan, verified);
|
|
801
|
+
const dirty = statusEntries(root).filter(entry => !isTool(entry.path));
|
|
802
|
+
const invalidated = git(root, ['rev-parse', 'HEAD']) !== testedHead || dirty.length > 0 ||
|
|
803
|
+
specsHash(root) !== ledger.baseline_specs_sha256;
|
|
804
|
+
const status = !verified.ok ? 'failed' : invalidated ? 'invalidated' : 'passed';
|
|
805
|
+
ledger.integration = { status, tested_head: testedHead, env, regression: Boolean(regression),
|
|
806
|
+
commands, skipped: plan.skipped,
|
|
807
|
+
deduped: verified.deduped, elapsed_ms: Math.round(verified.elapsed_ms),
|
|
808
|
+
error: status === 'failed' ? 'integration tests failed' :
|
|
809
|
+
status === 'invalidated' ? 'Working tree or HEAD changed during integration' : null };
|
|
810
|
+
if (status !== 'passed') {
|
|
811
|
+
ledger.status = 'blocked';
|
|
812
|
+
ledger.updated_at = new Date().toISOString();
|
|
813
|
+
writeLedger(root, ledger);
|
|
814
|
+
throw new f.FlowError(status === 'invalidated' ? 'integration_invalidated' : 'integration_failed',
|
|
815
|
+
ledger.integration.error, 1);
|
|
816
|
+
}
|
|
817
|
+
ledger.status = 'cleaning';
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function mergeLocked(root, runId) {
|
|
821
|
+
const ledger = readLedger(root, runId);
|
|
822
|
+
assertCoordinator(root, ledger);
|
|
823
|
+
f.demand(ledger.worktrees.every(item => ['collected', 'merged'].includes(item.status)),
|
|
824
|
+
'not_collected', 'Collect all workers first', 1);
|
|
825
|
+
if (spawnSync('git', ['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: root }).status === 0) {
|
|
826
|
+
ledger.status = 'blocked'; ledger.error = 'Merge is already in progress';
|
|
827
|
+
writeLedger(root, ledger);
|
|
828
|
+
throw new f.FlowError('merge_in_progress', 'Resolve or abort the in-progress merge first', 1);
|
|
829
|
+
}
|
|
830
|
+
if (specsHash(root) !== ledger.baseline_specs_sha256) {
|
|
831
|
+
throw new f.FlowError('specs_changed', 'Specs changed since baseline; restart the run', 1);
|
|
832
|
+
}
|
|
833
|
+
assertCoordinatorClean(root);
|
|
834
|
+
for (const worker of ledger.worktrees) mergeWorker(root, ledger, worker);
|
|
835
|
+
await integrate(root, ledger, runId);
|
|
836
|
+
ledger.updated_at = new Date().toISOString();
|
|
837
|
+
writeLedger(root, ledger);
|
|
838
|
+
return { ok: true, action: 'merge', run_id: runId, worktrees: ledger.worktrees, integration: ledger.integration };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function cleanup(root, runId, force = false) {
|
|
842
|
+
return require('./locks').withRepositorySync(root, () => cleanupLocked(root, runId, force));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function assertCleanupReady(root, ledger) {
|
|
846
|
+
f.demand(['cleaning', 'done'].includes(ledger.status), 'run_not_done',
|
|
847
|
+
'Run is not ready for cleanup; use --force to abandon', 1);
|
|
848
|
+
f.demand(['passed', 'skipped'].includes(ledger.integration.status), 'integration_not_passed',
|
|
849
|
+
'Integration has not passed; use --force to abandon', 1);
|
|
850
|
+
f.demand(git(root, ['rev-parse', 'HEAD']) === ledger.integration.tested_head, 'cleanup_changed',
|
|
851
|
+
'Coordinator HEAD changed after integration; rerun merge', 1);
|
|
852
|
+
f.demand(specsHash(root) === ledger.baseline_specs_sha256, 'cleanup_changed',
|
|
853
|
+
'Specs changed after integration; rerun merge', 1);
|
|
854
|
+
const dirty = statusEntries(root).filter(entry => !isTool(entry.path));
|
|
855
|
+
f.demand(!dirty.length, 'cleanup_dirty', 'Coordinator has unverified changes: ' +
|
|
856
|
+
dirty.slice(0, 8).map(item => item.path).join(', '), 1);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function assertCleanupWorker(worktree, worker) {
|
|
860
|
+
f.demand(git(worktree, ['rev-parse', 'HEAD']) === worker.collected_head,
|
|
861
|
+
'cleanup_changed', worker.task + ' worktree HEAD changed after collect', 1);
|
|
862
|
+
assertInitState(worktree, worker);
|
|
863
|
+
const managed = new Set(worker.init_changes.map(item => item.path));
|
|
864
|
+
const dirty = statusEntries(worktree).filter(entry => !managed.has(entry.path));
|
|
865
|
+
f.demand(!dirty.length, 'cleanup_dirty', worker.task + ' has user changes: ' +
|
|
866
|
+
dirty.slice(0, 8).map(item => item.path).join(', '), 1);
|
|
867
|
+
assertMixedFiles(worktree, worker.base_head);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function assertWorkerOwnership(root, ledger, worker) {
|
|
871
|
+
f.demand(worker.path === worktreePath(ledger.run_id, worker.task) &&
|
|
872
|
+
worker.branch === branchName(ledger.run_id, worker.task), 'cleanup_ownership',
|
|
873
|
+
worker.task + ' ledger resource names are not owned by run ' + ledger.run_id, 1);
|
|
874
|
+
const worktree = f.inside(root, worker.path);
|
|
875
|
+
if (fs.existsSync(worktree)) {
|
|
876
|
+
f.demand(f.realpath(git(worktree, ['rev-parse', '--show-toplevel'])) === f.realpath(worktree),
|
|
877
|
+
'worktree_mismatch', 'Path is not the owned worktree: ' + worker.path, 1);
|
|
878
|
+
f.demand(git(worktree, ['symbolic-ref', '-q', 'HEAD']) === 'refs/heads/' + worker.branch,
|
|
879
|
+
'worktree_mismatch', worker.task + ' worktree branch changed: ' + worker.path, 1);
|
|
880
|
+
}
|
|
881
|
+
return worktree;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function cleanupWorker(root, ledger, worker, force, steps) {
|
|
885
|
+
const abandoned = worker.status !== 'merged';
|
|
886
|
+
const worktree = assertWorkerOwnership(root, ledger, worker);
|
|
887
|
+
if (fs.existsSync(worktree)) {
|
|
888
|
+
if (!force) assertCleanupWorker(worktree, worker);
|
|
889
|
+
git(root, ['worktree', 'remove', '--force', worktree]);
|
|
890
|
+
}
|
|
891
|
+
const branch = spawnSync('git', ['rev-parse', '--verify', 'refs/heads/' + worker.branch],
|
|
892
|
+
{ cwd: root, encoding: 'utf8' });
|
|
893
|
+
if (branch.status === 0) {
|
|
894
|
+
if (!force) f.demand(branch.stdout.trim() === worker.collected_head, 'branch_changed',
|
|
895
|
+
worker.task + ' branch moved after collect', 1);
|
|
896
|
+
git(root, ['branch', '-D', worker.branch]);
|
|
897
|
+
}
|
|
898
|
+
worker.status = 'removed';
|
|
899
|
+
steps.set(worker.task, { task: worker.task, path: worker.path, branch: worker.branch,
|
|
900
|
+
collected_head: worker.collected_head, status: 'removed', abandoned: Boolean(force && abandoned) });
|
|
901
|
+
ledger.cleanup.steps = [...steps.values()];
|
|
902
|
+
ledger.updated_at = new Date().toISOString();
|
|
903
|
+
writeLedger(root, ledger);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function cleanupLocked(root, runId, force = false) {
|
|
907
|
+
const ledger = readLedger(root, runId);
|
|
908
|
+
assertCoordinator(root, ledger);
|
|
909
|
+
if (!force) assertCleanupReady(root, ledger);
|
|
910
|
+
const steps = new Map(ledger.cleanup.steps.map(item => [item.task, item]));
|
|
911
|
+
for (const worker of ledger.worktrees) {
|
|
912
|
+
if (worker.status !== 'removed') cleanupWorker(root, ledger, worker, force, steps);
|
|
913
|
+
}
|
|
914
|
+
ledger.status = force ? 'aborted' : 'done';
|
|
915
|
+
ledger.cleanup.steps = [...steps.values()];
|
|
916
|
+
ledger.cleanup.kept = [];
|
|
917
|
+
ledger.updated_at = new Date().toISOString();
|
|
918
|
+
writeLedger(root, ledger);
|
|
919
|
+
return { ok: true, action: 'cleanup', run_id: runId, steps: ledger.cleanup.steps };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function status(root, runId) {
|
|
923
|
+
const ledger = readLedger(root, runId);
|
|
924
|
+
return { ok: true, action: 'status', run_id: runId, status: ledger.status,
|
|
925
|
+
workers: ledger.workers, jobs: ledger.jobs, operation: ledger.operation ?? null,
|
|
926
|
+
worktrees: ledger.worktrees, merge: ledger.merge, integration: ledger.integration,
|
|
927
|
+
cleanup: ledger.cleanup };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
module.exports = { prepare, collect, merge, cleanup, status, readLedger };
|