atris 3.80.1 → 3.82.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/LICENSE +21 -0
- package/README.md +11 -1
- package/atris/atris.md +14 -0
- package/atris/features/README.md +15 -0
- package/atris/policies/README.md +24 -0
- package/atris/skills/README.md +61 -45
- package/atris/skills/design/SKILL.md +6 -5
- package/atris/skills/taste/SKILL.md +39 -0
- package/atris/skills/taste/notes.md +29 -0
- package/atris/skills/taste/principles.md +23 -0
- package/atris/skills/wiki/SKILL.md +14 -2
- package/atris/skills/wiki/index-lines.md +30 -0
- package/atris/skills/wiki/measure.md +20 -0
- package/atris/skills/wiki/note-shape.md +16 -0
- package/atris/skills/wiki/upkeep.md +16 -0
- package/atris/team/README.md +38 -0
- package/atris.md +14 -0
- package/bin/atris.js +1 -1
- package/commands/activate.js +1 -1
- package/commands/autoland.js +25 -26
- package/commands/autopilot-front.js +46 -1
- package/commands/autopilot.js +55 -25
- package/commands/clean.js +22 -11
- package/commands/computer.js +1 -1
- package/commands/doc-health.js +48 -1
- package/commands/improve.js +16 -3
- package/commands/init.js +4 -0
- package/commands/integrations.js +69 -1616
- package/commands/mission.js +112 -14
- package/commands/release.js +41 -10
- package/commands/status.js +1 -1
- package/commands/sync.js +12 -8
- package/commands/task.js +150 -137
- package/commands/unknowns.js +9 -5
- package/commands/visualize.js +1 -12
- package/commands/workflow.js +4 -3
- package/commands/worktree.js +107 -1610
- package/commands/xp.js +23 -2113
- package/commands/youtube.js +192 -3110
- package/lib/business-core.js +3 -4
- package/lib/campaign-run-lock.js +12 -1
- package/lib/engine-ask.js +1 -1
- package/lib/engine-receipt-sweep.js +64 -0
- package/lib/file-lock.js +175 -0
- package/lib/first-minute.js +37 -4
- package/lib/fleet.js +1 -1
- package/lib/integrations-core.js +1695 -0
- package/lib/intents.js +7 -0
- package/lib/json-file.js +22 -1
- package/lib/loop-doctor.js +36 -2
- package/lib/map-refs.js +68 -8
- package/lib/member-context.js +20 -1
- package/lib/member-engine.js +1 -1
- package/lib/member-map-fold.js +49 -11
- package/lib/member-map-inbox.js +2 -1
- package/lib/mission-core.js +9 -2
- package/lib/next-work-context.js +190 -0
- package/lib/recall-exam.js +171 -0
- package/lib/roster-markdown.js +2 -2
- package/lib/runner-command.js +5 -5
- package/lib/task-db.js +107 -38
- package/lib/taste-notes.js +35 -0
- package/lib/team-lineup.js +3 -3
- package/lib/todo.js +2 -0
- package/lib/wiki.js +11 -3
- package/lib/worktree-core.js +2032 -0
- package/lib/xp-core.js +2172 -0
- package/lib/youtube-core.js +3178 -0
- package/lib/zen-apply.js +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,2032 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawn, spawnSync } = require('child_process');
|
|
7
|
+
const { hasFlag } = require('./arg-parser');
|
|
8
|
+
const { planKeep } = require('./campaign-keep');
|
|
9
|
+
const { isConductorArtifact, isConductorUntracked } = require('./conductor-artifacts');
|
|
10
|
+
const { fixMapDocs } = require('./map-refs');
|
|
11
|
+
const { sleepSync } = require('./sleep-sync');
|
|
12
|
+
const space = require('./worktree-space');
|
|
13
|
+
const close = require('../commands/close');
|
|
14
|
+
|
|
15
|
+
const ATRIS_BIN = path.resolve(__dirname, '..', 'bin', 'atris.js');
|
|
16
|
+
|
|
17
|
+
// Files atris regenerates on its own. Unstaged churn in them is restored
|
|
18
|
+
// before ship commits, unless the commit message names the file. The task
|
|
19
|
+
// projection is rewritten by any task command run in a work folder, and two
|
|
20
|
+
// parallel landings that each carry a copy always clash.
|
|
21
|
+
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
|
|
22
|
+
const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
23
|
+
// Push also runs local hooks. Backend startup and regression checks alone can
|
|
24
|
+
// exceed the short network-only allowance used by fetch and discovery.
|
|
25
|
+
const GIT_PUSH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
|
|
27
|
+
const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const SHIP_HEALTH_SOURCE = 'ship-health:experiments-daily';
|
|
30
|
+
|
|
31
|
+
function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
|
|
32
|
+
const env = { ...source, GIT_TERMINAL_PROMPT: '0' };
|
|
33
|
+
// Custom commands may be wrappers that do not accept OpenSSH flags.
|
|
34
|
+
// Leave Git's own command/config/program precedence intact.
|
|
35
|
+
if (source.GIT_SSH_COMMAND || source.GIT_SSH) return env;
|
|
36
|
+
const configured = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
|
|
37
|
+
cwd, encoding: 'utf8', env: source, timeout: 5000, killSignal: 'SIGKILL',
|
|
38
|
+
});
|
|
39
|
+
if (configured.status === 0 && String(configured.stdout || '').trim()) return env;
|
|
40
|
+
env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15';
|
|
41
|
+
return env;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A killed network call leaves stderr empty; name the timeout instead.
|
|
45
|
+
function gitFailureDetail(result) {
|
|
46
|
+
if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
|
|
47
|
+
return [result && result.stdout, result && result.stderr]
|
|
48
|
+
.map(output => String(output || '').trim()).filter(Boolean).join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
|
|
52
|
+
const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
|
|
53
|
+
// Only network calls need the SSH probe; local calls skip its extra git spawn.
|
|
54
|
+
const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
|
|
55
|
+
// Listing a large repo's index or files can pass the 1 MB default buffer.
|
|
56
|
+
const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout, maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
57
|
+
killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
|
|
58
|
+
let result = spawnSync('git', args, options);
|
|
59
|
+
// Linked checkouts share config. A concurrent start must not silently lose
|
|
60
|
+
// its owner/base metadata, but real config errors still stop immediately.
|
|
61
|
+
for (let retry = 1; args[0] === 'config' && retry < 3 && configLocked(result); retry += 1) {
|
|
62
|
+
sleepSync(100 * retry);
|
|
63
|
+
result = spawnSync('git', args, options);
|
|
64
|
+
}
|
|
65
|
+
if (check && result.status !== 0) {
|
|
66
|
+
const detail = gitFailureDetail(result);
|
|
67
|
+
throw new Error(detail === 'timed out' ? `git ${args[0]} timed out` : (detail || `git ${args.join(' ')} failed`));
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function configLocked(result) {
|
|
73
|
+
return result.status !== 0 && /could not lock config file/i.test(`${result.stderr || ''}${result.stdout || ''}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runCommand(command, { cwd = process.cwd(), check = true } = {}) {
|
|
77
|
+
const result = spawnSync(command, {
|
|
78
|
+
cwd,
|
|
79
|
+
encoding: 'utf8',
|
|
80
|
+
shell: true,
|
|
81
|
+
stdio: 'pipe',
|
|
82
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
83
|
+
});
|
|
84
|
+
if (result.error) throw result.error;
|
|
85
|
+
if (check && result.status !== 0) {
|
|
86
|
+
const msg = (result.stderr || result.stdout || `${command} failed`).trim();
|
|
87
|
+
throw new Error(msg);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function statusSnapshot(root) {
|
|
93
|
+
const result = runGit(['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root, check: false });
|
|
94
|
+
if (result.status !== 0) return [];
|
|
95
|
+
return result.stdout
|
|
96
|
+
.split(/\r?\n/)
|
|
97
|
+
.map((line) => line.trimEnd())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function newStatusRows(before, after) {
|
|
102
|
+
const seen = new Set(before);
|
|
103
|
+
return after.filter((row) => !seen.has(row));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readOneLapProofBinding(root, branch) {
|
|
107
|
+
const sidecarPath = path.join(root, '.atris', 'agent-worktree.json');
|
|
108
|
+
let sidecar = null;
|
|
109
|
+
try {
|
|
110
|
+
sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error && error.code !== 'ENOENT') throw new Error('one-lap proof metadata is unreadable');
|
|
113
|
+
}
|
|
114
|
+
const metadata = sidecar && sidecar.one_lap_proof;
|
|
115
|
+
const configured = runGit(['config', '--get', `branch.${branch}.atris-proof-ref`], { cwd: root, check: false });
|
|
116
|
+
const configuredRef = configured.status === 0 ? String(configured.stdout || '').trim() : '';
|
|
117
|
+
if (!metadata && !configuredRef) return null;
|
|
118
|
+
if (!metadata || !configuredRef) throw new Error('one-lap proof binding is incomplete');
|
|
119
|
+
|
|
120
|
+
const proofRef = String(metadata.ref || '');
|
|
121
|
+
const sourceCommit = String(metadata.commit || '');
|
|
122
|
+
const sourceTree = String(metadata.tree || '');
|
|
123
|
+
const refMatch = proofRef.match(ONE_LAP_PROOF_REF_PATTERN);
|
|
124
|
+
if (!refMatch || proofRef !== configuredRef || !GIT_OID_PATTERN.test(sourceCommit) || !GIT_OID_PATTERN.test(sourceTree)) {
|
|
125
|
+
throw new Error('one-lap proof binding is invalid');
|
|
126
|
+
}
|
|
127
|
+
const resolvedCommit = runGit(['rev-parse', '--verify', `${proofRef}^{commit}`], { cwd: root, check: false });
|
|
128
|
+
const resolvedTree = runGit(['rev-parse', '--verify', `${proofRef}^{tree}`], { cwd: root, check: false });
|
|
129
|
+
if (resolvedCommit.status !== 0 || String(resolvedCommit.stdout || '').trim() !== sourceCommit || refMatch[1] !== sourceCommit) {
|
|
130
|
+
throw new Error('one-lap proof commit no longer matches its retained ref');
|
|
131
|
+
}
|
|
132
|
+
if (resolvedTree.status !== 0 || String(resolvedTree.stdout || '').trim() !== sourceTree) {
|
|
133
|
+
throw new Error('one-lap proof tree no longer matches its retained ref');
|
|
134
|
+
}
|
|
135
|
+
return { ref: proofRef, commit: sourceCommit, tree: sourceTree };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function worktreeTree(root, source) {
|
|
139
|
+
const result = runGit(source === 'index'
|
|
140
|
+
? ['write-tree']
|
|
141
|
+
: ['rev-parse', '--verify', 'HEAD^{tree}'], { cwd: root, check: false });
|
|
142
|
+
if (result.status !== 0) throw new Error(`could not resolve the ${source} tree for one-lap proof`);
|
|
143
|
+
return String(result.stdout || '').trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertOneLapProofTree(root, binding, source) {
|
|
147
|
+
if (!binding) return;
|
|
148
|
+
if (worktreeTree(root, source) !== binding.tree) {
|
|
149
|
+
throw new Error(`one-lap ${source} tree differs from the independently verified proof tree`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function repoRoot(cwd = process.cwd()) {
|
|
154
|
+
return runGit(['rev-parse', '--show-toplevel'], { cwd }).stdout.trim();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function slugify(value, fallback = 'task', limit = 48) {
|
|
158
|
+
const slug = String(value || '')
|
|
159
|
+
.trim()
|
|
160
|
+
.toLowerCase()
|
|
161
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
162
|
+
.replace(/^-+|-+$/g, '')
|
|
163
|
+
.slice(0, limit)
|
|
164
|
+
.replace(/-+$/g, '');
|
|
165
|
+
return slug || fallback;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stamp(now = new Date()) {
|
|
169
|
+
return now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '').replace('T', '-');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function branchName(member, task, now = new Date()) {
|
|
173
|
+
return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The newest earlier run of this same member+job, if its commits never reached
|
|
177
|
+
// the base, so a retry continues that work instead of redoing it. Only the
|
|
178
|
+
// newest run counts: once it landed or did nothing, anything older is stale.
|
|
179
|
+
function lastRunBranch(root, owner, task, base, skip) {
|
|
180
|
+
const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
|
|
181
|
+
const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
|
|
182
|
+
if (listed.status !== 0) return null;
|
|
183
|
+
// Only exact runs of this job (stamp plus optional same-second number), so
|
|
184
|
+
// "fix-login" never matches "fix-login-page".
|
|
185
|
+
const runs = listed.stdout.split('\n').map((name) => name.trim())
|
|
186
|
+
.map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
|
|
187
|
+
.filter(({ name, run }) => run && name !== skip)
|
|
188
|
+
.sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
|
|
189
|
+
if (!runs.length) return null;
|
|
190
|
+
const newest = runs[0].name;
|
|
191
|
+
const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
|
|
192
|
+
if (cherry.status !== 0) return null;
|
|
193
|
+
return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// True only when git proves the branch no longer merges cleanly with the base.
|
|
197
|
+
// merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
|
|
198
|
+
// resume as before.
|
|
199
|
+
function conflictsWithBase(root, base, branch) {
|
|
200
|
+
const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
|
|
201
|
+
return result.status === 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function defaultWorktreePath(root, member, task, now = new Date()) {
|
|
205
|
+
const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
206
|
+
return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseWorktrees(text) {
|
|
210
|
+
const out = [];
|
|
211
|
+
let current = {};
|
|
212
|
+
for (const raw of `${text}\n`.split(/\r?\n/)) {
|
|
213
|
+
const line = raw.trim();
|
|
214
|
+
if (!line) {
|
|
215
|
+
if (current.worktree) {
|
|
216
|
+
let branch = current.branch || 'detached';
|
|
217
|
+
branch = branch.replace(/^refs\/heads\//, '');
|
|
218
|
+
const item = { path: current.worktree, branch, head: current.HEAD || '' };
|
|
219
|
+
if (current.locked) item.locked = true;
|
|
220
|
+
if (current.prunable) item.prunable = true;
|
|
221
|
+
out.push(item);
|
|
222
|
+
}
|
|
223
|
+
current = {};
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const idx = line.indexOf(' ');
|
|
227
|
+
if (idx === -1) {
|
|
228
|
+
current[line] = true;
|
|
229
|
+
} else {
|
|
230
|
+
current[line.slice(0, idx)] = line.slice(idx + 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function listWorktrees(root = repoRoot()) {
|
|
237
|
+
return parseWorktrees(runGit(['worktree', 'list', '--porcelain'], { cwd: root }).stdout);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Duplicate flight guard. On 2026-08-07 two agents were dispatched onto the
|
|
241
|
+
// same map rewrite 44 seconds apart: fleet dispatch claims a task first, but a
|
|
242
|
+
// direct `worktree start` had no pre-check beyond the target path existing.
|
|
243
|
+
// Refuse only the same task slug. A shared word on an unrelated flight is not
|
|
244
|
+
// a collision (dispatch-cli-900 must not block dispatch-cli-901).
|
|
245
|
+
const AGENT_FLIGHT_NAME_PATTERN = /^codex\/(.+)-(\d{8}-\d{6})$/;
|
|
246
|
+
const FLIGHT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
247
|
+
const FLIGHT_STOPWORDS = new Set([
|
|
248
|
+
'fix', 'add', 'update', 'make', 'change', 'new', 'task', 'work', 'repo',
|
|
249
|
+
'cli', 'atris', 'backend', 'the', 'a', 'an', 'and', 'for', 'to', 'of', 'in', 'on',
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
function flightStampMs(stampText) {
|
|
253
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(String(stampText || ''));
|
|
254
|
+
if (!m) return null;
|
|
255
|
+
const [, year, month, day, hour, minute, second] = m.map(Number);
|
|
256
|
+
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return null;
|
|
257
|
+
return Date.UTC(year, month - 1, day, hour, minute, second);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function taskTokens(value) {
|
|
261
|
+
return String(value || '')
|
|
262
|
+
.toLowerCase()
|
|
263
|
+
.split(/[^a-z0-9]+/)
|
|
264
|
+
.filter(Boolean)
|
|
265
|
+
.filter((token) => !FLIGHT_STOPWORDS.has(token));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// codex/<owner>-<task-slug>-<YYYYMMDD-HHMMSS>: drop the owner segment and the
|
|
269
|
+
// stamp, and what is left is the task the flight is working on.
|
|
270
|
+
function parseAgentFlightName(name) {
|
|
271
|
+
const text = String(name || '').trim().replace(/^refs\/heads\//, '');
|
|
272
|
+
const match = AGENT_FLIGHT_NAME_PATTERN.exec(text);
|
|
273
|
+
if (!match) return null;
|
|
274
|
+
const stampMs = flightStampMs(match[2]);
|
|
275
|
+
if (stampMs === null) return null;
|
|
276
|
+
const segments = match[1].split('-').filter(Boolean);
|
|
277
|
+
if (segments.length < 2) return null;
|
|
278
|
+
return {
|
|
279
|
+
name: text,
|
|
280
|
+
owner: segments[0],
|
|
281
|
+
taskSlug: segments.slice(1).join('-'),
|
|
282
|
+
stamp: match[2],
|
|
283
|
+
stampMs,
|
|
284
|
+
tokens: taskTokens(segments.slice(1).join('-')),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function remoteAgentBranchNames(root) {
|
|
289
|
+
// One ls-remote, best effort: offline dispatchers still get the local view.
|
|
290
|
+
const result = runGit(['ls-remote', '--heads', 'origin'], { cwd: root, check: false, timeout: 10000 });
|
|
291
|
+
if (result.status !== 0) return [];
|
|
292
|
+
return String(result.stdout || '')
|
|
293
|
+
.split(/\r?\n/)
|
|
294
|
+
.map((line) => (line.split(/\s+/)[1] || '').replace(/^refs\/heads\//, '').trim())
|
|
295
|
+
.filter(Boolean);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function inFlightAgentFlights({ root = repoRoot(), now = new Date(), windowMs = FLIGHT_WINDOW_MS } = {}) {
|
|
299
|
+
const nowMs = now.getTime();
|
|
300
|
+
const flights = new Map();
|
|
301
|
+
const add = (name, kind) => {
|
|
302
|
+
const flight = parseAgentFlightName(name);
|
|
303
|
+
if (!flight) return;
|
|
304
|
+
// Branch age comes from the stamp in the name, never a git log per branch.
|
|
305
|
+
if (kind !== 'worktree' && nowMs - flight.stampMs > windowMs) return;
|
|
306
|
+
const existing = flights.get(flight.name);
|
|
307
|
+
if (existing && existing.kind === 'worktree') return;
|
|
308
|
+
flights.set(flight.name, { ...flight, kind });
|
|
309
|
+
};
|
|
310
|
+
for (const wt of listWorktrees(root)) add(wt.branch, 'worktree');
|
|
311
|
+
const local = runGit(['branch', '--format=%(refname:short)'], { cwd: root, check: false });
|
|
312
|
+
if (local.status === 0) {
|
|
313
|
+
for (const name of String(local.stdout || '').split(/\r?\n/)) add(name, 'branch');
|
|
314
|
+
}
|
|
315
|
+
for (const name of remoteAgentBranchNames(root)) add(name, 'branch');
|
|
316
|
+
return [...flights.values()].sort((a, b) => b.stampMs - a.stampMs);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function collidingFlights(flights, taskSlug) {
|
|
320
|
+
const wanted = String(taskSlug || '').trim().toLowerCase();
|
|
321
|
+
if (!wanted) return [];
|
|
322
|
+
return flights.filter((flight) => String(flight.taskSlug || '').toLowerCase() === wanted);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function describeFlightAge(flight, nowMs = Date.now()) {
|
|
326
|
+
const minutes = Math.max(0, Math.round((nowMs - flight.stampMs) / 60000));
|
|
327
|
+
if (minutes < 60) return `${minutes}m old`;
|
|
328
|
+
return `${Math.floor(minutes / 60)}h${minutes % 60}m old`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function refExists(root, ref) {
|
|
332
|
+
return runGit(['rev-parse', '--verify', `${ref}^{commit}`], { cwd: root, check: false }).status === 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function currentBranch(root = repoRoot()) {
|
|
336
|
+
return runGit(['branch', '--show-current'], { cwd: root, check: false }).stdout.trim();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function detachedShipBranch(root) {
|
|
340
|
+
return `codex/${path.basename(root)}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function createDetachedShipBranch(root, branch) {
|
|
344
|
+
if (refExists(root, `refs/heads/${branch}`) || refExists(root, `refs/remotes/origin/${branch}`)) {
|
|
345
|
+
console.error(`blocked: detached head branch already exists: ${branch}`);
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
const created = runGit(['switch', '-c', branch], { cwd: root, check: false });
|
|
349
|
+
if (created.status !== 0) {
|
|
350
|
+
console.error(`blocked: detached head branch create failed: ${branch}`);
|
|
351
|
+
console.error((created.stderr || created.stdout || '').trim());
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
console.log(`ship: detached head, created branch ${branch}`);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function currentUpstream(root) {
|
|
359
|
+
const result = runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: root, check: false });
|
|
360
|
+
return result.status === 0 ? result.stdout.trim() : '';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function remoteHead(root) {
|
|
364
|
+
// The saved origin/HEAD survives branch renames. Ask the remote before
|
|
365
|
+
// trusting it, keeping the offline fallback when the remote is unavailable.
|
|
366
|
+
const remote = runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false });
|
|
367
|
+
if (remote.status === 0) {
|
|
368
|
+
const live = runGit(['ls-remote', '--symref', 'origin', 'HEAD'], { cwd: root, check: false, timeout: 5000 });
|
|
369
|
+
const match = live.status === 0 && String(live.stdout || '').match(/^ref: refs\/heads\/(.+)\s+HEAD$/m);
|
|
370
|
+
if (match) {
|
|
371
|
+
const head = `origin/${match[1]}`;
|
|
372
|
+
if (!refExists(root, head)) {
|
|
373
|
+
const fetched = runGit(['fetch', 'origin', `refs/heads/${match[1]}:refs/remotes/${head}`], { cwd: root, check: false, timeout: 5000 });
|
|
374
|
+
if (fetched.status !== 0 || !refExists(root, head)) {
|
|
375
|
+
throw new Error('could not fetch the remote default branch; stop and retry when the remote is available');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return head;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const result = runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, check: false });
|
|
382
|
+
return result.status === 0 ? result.stdout.trim() : '';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function normalizeTargetRef(root, target) {
|
|
386
|
+
const value = String(target || '').trim();
|
|
387
|
+
if (!value) return '';
|
|
388
|
+
const remotes = runGit(['remote'], { cwd: root, check: false }).stdout.split(/\r?\n/);
|
|
389
|
+
const preferRemote =
|
|
390
|
+
!value.startsWith('origin/') &&
|
|
391
|
+
!value.startsWith('refs/') &&
|
|
392
|
+
value !== 'HEAD' &&
|
|
393
|
+
!/^[0-9a-f]{7,40}$/i.test(value) &&
|
|
394
|
+
remotes.includes('origin');
|
|
395
|
+
const remoteRef = value.startsWith('origin/') ? value : `origin/${value}`;
|
|
396
|
+
if (preferRemote && refExists(root, remoteRef)) return remoteRef;
|
|
397
|
+
if (preferRemote) return remoteRef;
|
|
398
|
+
if (refExists(root, value)) return value;
|
|
399
|
+
if (refExists(root, remoteRef)) return remoteRef;
|
|
400
|
+
if (value.startsWith('origin/') || remotes.includes('origin')) return remoteRef;
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function defaultMainlineBase(root) {
|
|
405
|
+
const head = remoteHead(root);
|
|
406
|
+
if (head && refExists(root, head)) return head;
|
|
407
|
+
for (const candidate of ['origin/main', 'origin/master']) {
|
|
408
|
+
if (refExists(root, candidate)) return candidate;
|
|
409
|
+
}
|
|
410
|
+
return 'HEAD';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function defaultStartBase(root) {
|
|
414
|
+
const upstream = currentUpstream(root);
|
|
415
|
+
const mainline = defaultMainlineBase(root);
|
|
416
|
+
// Always refresh before cutting a worktree: a stale origin/master base sent
|
|
417
|
+
// an engine off to rebuild work that had already landed (2026-07-09, packs
|
|
418
|
+
// one-click flight rebuilt the stars backend from a base two PRs behind).
|
|
419
|
+
refreshRemoteRef(root, mainline);
|
|
420
|
+
if (!upstream || !refExists(root, upstream)) return mainline;
|
|
421
|
+
if (mainline === 'HEAD' || mainline === upstream) return upstream;
|
|
422
|
+
// A launcher upstream that is behind the mainline is a stale cut point
|
|
423
|
+
// (2026-07-05: a stale origin/task branch sent two flights into rebase
|
|
424
|
+
// conflicts). Start from the mainline unless the upstream carries new work.
|
|
425
|
+
refreshRemoteRef(root, upstream);
|
|
426
|
+
const behind = runGit(['merge-base', '--is-ancestor', upstream, mainline], { cwd: root, check: false });
|
|
427
|
+
return behind.status === 0 ? mainline : upstream;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function defaultShipTarget(root) {
|
|
431
|
+
const branch = currentBranch(root);
|
|
432
|
+
if (branch) {
|
|
433
|
+
const configured = runGit(['config', '--get', `branch.${branch}.atris-base`], { cwd: root, check: false }).stdout.trim();
|
|
434
|
+
if (configured && refExists(root, configured)) return configured;
|
|
435
|
+
}
|
|
436
|
+
const head = remoteHead(root);
|
|
437
|
+
if (head && refExists(root, head)) return head;
|
|
438
|
+
for (const candidate of ['origin/main', 'origin/master']) {
|
|
439
|
+
if (refExists(root, candidate)) return candidate;
|
|
440
|
+
}
|
|
441
|
+
return 'HEAD';
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function refreshRemoteRef(root, ref) {
|
|
445
|
+
if (!ref.startsWith('origin/')) return;
|
|
446
|
+
const remoteBranch = ref.slice('origin/'.length);
|
|
447
|
+
const fetched = runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false, timeout: 5000 });
|
|
448
|
+
return fetched.status === 0 ? '' : (gitFailureDetail(fetched) || 'fetch failed');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function baseBranchName(ref) {
|
|
452
|
+
return String(ref || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function statusCounts(root, {
|
|
456
|
+
ignoredUnstagedFiles = new Set(),
|
|
457
|
+
ignoredUntrackedFiles = new Set(),
|
|
458
|
+
ignoreUntracked = null,
|
|
459
|
+
ignoreAny = null,
|
|
460
|
+
} = {}) {
|
|
461
|
+
if (!fs.existsSync(root)) return null;
|
|
462
|
+
// -uall expands untracked directories to individual files so ignoredUntrackedFiles
|
|
463
|
+
// and ignoreUntracked can match exact paths (plain porcelain collapses them to "?? dir/").
|
|
464
|
+
const expandUntracked = ignoredUntrackedFiles.size
|
|
465
|
+
|| typeof ignoreUntracked === 'function'
|
|
466
|
+
|| typeof ignoreAny === 'function';
|
|
467
|
+
const statusArgs = expandUntracked ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
|
|
468
|
+
const result = runGit(statusArgs, { cwd: root, check: false });
|
|
469
|
+
if (result.status !== 0) return null;
|
|
470
|
+
let staged = 0;
|
|
471
|
+
let unstaged = 0;
|
|
472
|
+
let untracked = 0;
|
|
473
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
474
|
+
const file = line.slice(3);
|
|
475
|
+
if (typeof ignoreAny === 'function' && ignoreAny(file)) continue;
|
|
476
|
+
if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
|
|
477
|
+
if (line.startsWith('??')) {
|
|
478
|
+
if (ignoredUntrackedFiles.has(file)) continue;
|
|
479
|
+
if (typeof ignoreUntracked === 'function' && ignoreUntracked(file)) continue;
|
|
480
|
+
untracked += 1;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (line[0] !== ' ') staged += 1;
|
|
484
|
+
if (line[1] !== ' ') unstaged += 1;
|
|
485
|
+
}
|
|
486
|
+
return { staged, unstaged, untracked };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Remove conductor plumbing from the index after a broad `git add -A`, so the commit
|
|
490
|
+
// records engine output only. Paths that were never staged are simply not listed.
|
|
491
|
+
// GitHub refuses a merge with "Base branch was modified" when another PR
|
|
492
|
+
// merged into the same base a moment earlier; the same merge succeeds on a
|
|
493
|
+
// second try. On 2026-10-15 five parallel dispatches lost three landings to
|
|
494
|
+
// it. Retry only that message, a few times, with a short wait.
|
|
495
|
+
const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
|
|
496
|
+
function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
|
|
497
|
+
let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
498
|
+
for (let attempt = 1; attempt < attempts && merged.status !== 0
|
|
499
|
+
&& BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
|
|
500
|
+
console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
|
|
501
|
+
wait(1500 * attempt);
|
|
502
|
+
merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
503
|
+
}
|
|
504
|
+
return merged;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function unstageConductorArtifacts(root) {
|
|
508
|
+
const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
|
|
509
|
+
if (staged.status !== 0) return [];
|
|
510
|
+
const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
|
|
511
|
+
const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
|
|
512
|
+
// A file this commit would add under .atris/ is the tool's own state, never work.
|
|
513
|
+
const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
|
|
514
|
+
.filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
|
|
515
|
+
if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
|
|
516
|
+
return drop;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function changedFiles(root, args) {
|
|
520
|
+
const result = runGit([...args, '--', ...REGEN_ADAPTER_FILES], { cwd: root, check: false });
|
|
521
|
+
if (result.status !== 0) return new Set();
|
|
522
|
+
return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function restoreRegeneratedAdapterChurn(root, message, { dryRun = false } = {}) {
|
|
526
|
+
const unstaged = changedFiles(root, ['diff', '--name-only']);
|
|
527
|
+
const staged = changedFiles(root, ['diff', '--cached', '--name-only']);
|
|
528
|
+
const commitMessage = String(message || '');
|
|
529
|
+
const skipped = REGEN_ADAPTER_FILES.filter(
|
|
530
|
+
(file) => unstaged.has(file) && !staged.has(file) && !commitMessage.includes(file)
|
|
531
|
+
);
|
|
532
|
+
if (!skipped.length) return [];
|
|
533
|
+
console.log(`ship: skipped regenerated adapter churn: ${skipped.join(', ')}`);
|
|
534
|
+
if (!dryRun) runGit(['checkout', '--', ...skipped], { cwd: root });
|
|
535
|
+
return skipped;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function swarloClaim(root, { channel, taskKey, content }) {
|
|
539
|
+
const script = path.join(root, 'scripts', 'swarlo.py');
|
|
540
|
+
if (!fs.existsSync(script)) return 'skip: scripts/swarlo.py not found';
|
|
541
|
+
const result = spawnSync('python3', [script, 'claim', channel, taskKey, content], { cwd: root, encoding: 'utf8' });
|
|
542
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
543
|
+
if (result.status === 0) return output || `claimed ${taskKey}`;
|
|
544
|
+
return `skip: swarlo claim failed: ${output || result.status}`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Programmatic core shared by `atris worktree start` and `atris mission start
|
|
548
|
+
// --worktree`: creates the branch + isolated checkout + identity sidecar and
|
|
549
|
+
// returns the facts. Throws on failure; callers own messaging and next steps.
|
|
550
|
+
function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, formerTasks = [], branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
|
|
551
|
+
const owner = member || agent;
|
|
552
|
+
if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
|
|
553
|
+
// Pin the caller's flight base, or resolve it once for this new flight.
|
|
554
|
+
const mainline = baseOverride || defaultMainlineBase(root);
|
|
555
|
+
// Every new flight pays down completed checkout debt before adding another
|
|
556
|
+
// full repo copy. Branches and commits survive worktree removal, while real
|
|
557
|
+
// uncommitted changes remain protected by cleanupWorktrees.
|
|
558
|
+
const cleanupBudget = { count: 0, kib: 0 };
|
|
559
|
+
let reapedBeforeStart = [];
|
|
560
|
+
let cachePrunedBeforeStart = [];
|
|
561
|
+
try {
|
|
562
|
+
const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start', cleanupBudget });
|
|
563
|
+
reapedBeforeStart = cleaned.removed;
|
|
564
|
+
cachePrunedBeforeStart = cleaned.cachePruned;
|
|
565
|
+
} catch {}
|
|
566
|
+
let branch = branchOverride || branchName(owner, task, now);
|
|
567
|
+
let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
|
|
568
|
+
// Two runs of the same job in the same second get the same name; number
|
|
569
|
+
// the later one instead of failing it.
|
|
570
|
+
if (!pathOverride && !branchOverride) {
|
|
571
|
+
const baseBranch = branch;
|
|
572
|
+
const baseTarget = target;
|
|
573
|
+
for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
|
|
574
|
+
branch = `${baseBranch}-${n}`;
|
|
575
|
+
target = `${baseTarget}-${n}`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const checkoutBase = normalizeTargetRef(root, mainline);
|
|
579
|
+
const shipBase = checkoutBase;
|
|
580
|
+
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
581
|
+
// A full disk stops every flight at once. Reap first; refuse before cutting.
|
|
582
|
+
const room = checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget });
|
|
583
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
584
|
+
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
585
|
+
let resumedFrom = (!pathOverride && !branchOverride)
|
|
586
|
+
? lastRunBranch(root, owner, task, checkoutBase, branch)
|
|
587
|
+
: null;
|
|
588
|
+
let skippedResume = null;
|
|
589
|
+
if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
|
|
590
|
+
skippedResume = resumedFrom;
|
|
591
|
+
resumedFrom = null;
|
|
592
|
+
console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
|
|
593
|
+
}
|
|
594
|
+
const own = slugify(task, 'task', 36);
|
|
595
|
+
for (const former of Array.isArray(formerTasks) ? formerTasks : [formerTasks]) {
|
|
596
|
+
if (!former || !String(former).trim() || slugify(former, 'task', 36) === own) continue;
|
|
597
|
+
const olderRun = lastRunBranch(root, owner, former, checkoutBase, branch);
|
|
598
|
+
if (!olderRun) continue;
|
|
599
|
+
console.error(`note: older unfinished work may be on ${olderRun}; ${resumedFrom ? `continuing from ${resumedFrom} instead` : 'starting fresh'}`);
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
const startPoint = resumedFrom || checkoutBase;
|
|
603
|
+
const ref = `refs/heads/${branch}`;
|
|
604
|
+
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
605
|
+
const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
|
|
606
|
+
const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
|
|
607
|
+
if (staleBase && startCommit) {
|
|
608
|
+
console.error(`warning: could not refresh ${checkoutBase} (${staleBase.split('\n')[0]}); the new worktree starts from the local copy, which may be behind`);
|
|
609
|
+
}
|
|
610
|
+
const discardFailedStart = () => {
|
|
611
|
+
if (existed || !startCommit) return;
|
|
612
|
+
const registered = listWorktrees(root).find((wt) => wt.branch === branch);
|
|
613
|
+
if (registered) {
|
|
614
|
+
if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
|
|
615
|
+
|| registered.head !== startCommit) return;
|
|
616
|
+
const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
|
|
617
|
+
if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
|
|
618
|
+
runGit(['worktree', 'remove', target], { cwd: root });
|
|
619
|
+
}
|
|
620
|
+
if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
|
|
621
|
+
};
|
|
622
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
623
|
+
const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
|
|
624
|
+
if (added.status === 0) break;
|
|
625
|
+
if (!/already exists|already used by worktree/i.test(added.stderr || '')) discardFailedStart();
|
|
626
|
+
if (!configLocked(added) || attempt === 2) {
|
|
627
|
+
throw new Error(String(added.stderr || added.stdout || 'worktree start failed').trim());
|
|
628
|
+
}
|
|
629
|
+
sleepSync(100 * (attempt + 1));
|
|
630
|
+
}
|
|
631
|
+
if (resumedFrom) {
|
|
632
|
+
const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
|
|
633
|
+
if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
|
|
637
|
+
runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
|
|
638
|
+
runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target });
|
|
639
|
+
fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
|
|
640
|
+
fs.writeFileSync(
|
|
641
|
+
path.join(target, '.atris', 'agent-worktree.json'),
|
|
642
|
+
JSON.stringify({
|
|
643
|
+
agent: agent || null,
|
|
644
|
+
member: member || null,
|
|
645
|
+
owner,
|
|
646
|
+
task,
|
|
647
|
+
branch,
|
|
648
|
+
base: shipBase,
|
|
649
|
+
checkout_base: checkoutBase,
|
|
650
|
+
resumed_from: resumedFrom,
|
|
651
|
+
skipped_resume: skippedResume,
|
|
652
|
+
workspace_root: root,
|
|
653
|
+
created_at: now.toISOString(),
|
|
654
|
+
}, null, 2) + '\n',
|
|
655
|
+
'utf8'
|
|
656
|
+
);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
discardFailedStart();
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
const seedDeps = deps !== false && env.ATRIS_WORKTREE_NO_DEP_CLONE !== '1' && (deps ?? !space.isScratchPath(target));
|
|
662
|
+
const depsResult = seedDeps ? seedCopyDeps({ root, target, options: deps && typeof deps === 'object' ? deps : {} }) : null;
|
|
663
|
+
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart, space: room, deps: depsResult };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function seedCopyDeps({ root, target, options = {} }) {
|
|
667
|
+
try {
|
|
668
|
+
const primaryRoot = findPrimaryRoot(root);
|
|
669
|
+
return space.seedNodeModules({
|
|
670
|
+
target,
|
|
671
|
+
workspace: path.dirname(primaryRoot),
|
|
672
|
+
repoName: path.basename(primaryRoot),
|
|
673
|
+
sources: [primaryRoot, ...listWorktrees(root).map((wt) => wt.path)],
|
|
674
|
+
isIgnored: (dir) => runGit(['check-ignore', '-q', '--', 'node_modules/'], { cwd: dir, check: false }).status === 0,
|
|
675
|
+
...options,
|
|
676
|
+
});
|
|
677
|
+
} catch (error) {
|
|
678
|
+
return { status: 'skipped', reason: `could not clone installs: ${String((error && error.message) || error)}` };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const LOW_DISK_IDLE_MS = 10 * 60 * 1000;
|
|
683
|
+
|
|
684
|
+
function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start', cleanupBudget = null }) {
|
|
685
|
+
const limits = space.spaceLimits(env);
|
|
686
|
+
const before = freeSpace(target);
|
|
687
|
+
const room = { before, free: before, removed: [], cleared: [], ...limits };
|
|
688
|
+
if (typeof before !== 'number' || !Number.isFinite(before)) return room;
|
|
689
|
+
if (before >= limits.reapBelow && before >= limits.refuseBelow) return room;
|
|
690
|
+
const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by, cleanupBudget });
|
|
691
|
+
for (const repo of swept.repos) {
|
|
692
|
+
room.removed.push(...(repo.removed || []));
|
|
693
|
+
room.cleared.push(...(repo.cachePruned || []));
|
|
694
|
+
}
|
|
695
|
+
const after = freeSpace(target);
|
|
696
|
+
room.free = typeof after === 'number' && Number.isFinite(after) ? after : before;
|
|
697
|
+
if (limits.refuseBelow > 0 && room.free < limits.refuseBelow) {
|
|
698
|
+
const count = room.removed.length;
|
|
699
|
+
const reaped = count
|
|
700
|
+
? `Removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} first, and that was not enough.`
|
|
701
|
+
: 'No finished build copies were safe to remove.';
|
|
702
|
+
const error = new Error(
|
|
703
|
+
`only ${space.formatGb(room.free)} free on this disk, and a new build copy needs at least ${space.formatGb(limits.refuseBelow)}. ${reaped} `
|
|
704
|
+
+ 'Free some space and retry; `atris worktree sweep` shows what is left, and ATRIS_WORKTREE_MIN_FREE_GB=0 skips this check.'
|
|
705
|
+
);
|
|
706
|
+
error.code = 'ATRIS_LOW_DISK';
|
|
707
|
+
error.space = room;
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
return room;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget = null }) {
|
|
714
|
+
return (spaceGuard ?? !space.isScratchPath(target))
|
|
715
|
+
? ensureRoomForCopy({ root, target, freeSpace, env, cleanupBudget })
|
|
716
|
+
: null;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function lowDiskRefusal({ root = repoRoot(), member = '', agent = '', task = 'flight', now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, target = '' } = {}) {
|
|
720
|
+
try {
|
|
721
|
+
checkRoomForCopy({ root, target: target ? path.resolve(target) : path.resolve(defaultWorktreePath(root, member || agent, task, now)), freeSpace, env, spaceGuard });
|
|
722
|
+
return null;
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (!error || error.code !== 'ATRIS_LOW_DISK') return null;
|
|
725
|
+
return { message: error.message, free: error.space.free, needs: error.space.refuseBelow };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function createOrFindPr(root, branch, targetRef, title, dryRun) {
|
|
730
|
+
const targetBranch = baseBranchName(targetRef);
|
|
731
|
+
const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url,state', '--jq', '"\\(.number) \\(.url) \\(.state)"'], {
|
|
732
|
+
cwd: root,
|
|
733
|
+
encoding: 'utf8',
|
|
734
|
+
});
|
|
735
|
+
if (existing.status === 0 && existing.stdout.trim()) {
|
|
736
|
+
const parts = existing.stdout.trim().split(/\s+/);
|
|
737
|
+
const state = parts.pop();
|
|
738
|
+
if (state === 'OPEN') return parts.join(' ');
|
|
739
|
+
}
|
|
740
|
+
const body = [
|
|
741
|
+
'Automated Atris worktree ship.',
|
|
742
|
+
'',
|
|
743
|
+
`Branch: ${branch}`,
|
|
744
|
+
`Target: ${targetBranch}`,
|
|
745
|
+
].join('\n');
|
|
746
|
+
if (dryRun) return `dry-run: gh pr create --base ${targetBranch} --head ${branch}`;
|
|
747
|
+
const ahead = runGit(['rev-list', '--count', `${targetRef}..HEAD`], { cwd: root, check: false });
|
|
748
|
+
if (ahead.status === 0 && String(ahead.stdout || '').trim() === '0') {
|
|
749
|
+
console.log('pr: skipped (no commits ahead of base)');
|
|
750
|
+
return '';
|
|
751
|
+
}
|
|
752
|
+
const created = spawnSync(
|
|
753
|
+
'gh',
|
|
754
|
+
['pr', 'create', '--base', targetBranch, '--head', branch, '--title', title, '--body', body],
|
|
755
|
+
{ cwd: root, encoding: 'utf8' }
|
|
756
|
+
);
|
|
757
|
+
if (created.status !== 0) {
|
|
758
|
+
throw new Error((created.stderr || created.stdout || 'gh pr create failed').trim());
|
|
759
|
+
}
|
|
760
|
+
return created.stdout.trim();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function prMergeRef(prOutput) {
|
|
764
|
+
const text = String(prOutput || '').trim();
|
|
765
|
+
if (!text || text.startsWith('dry-run:')) return '';
|
|
766
|
+
return text.split(/\s+/)[0];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function deniedLaneForShip(root, branch, message) {
|
|
770
|
+
try {
|
|
771
|
+
const { protectedLaneOfTask } = require('./fleet');
|
|
772
|
+
const refs = new Set();
|
|
773
|
+
for (const source of [String(branch || ''), String(message || '')]) {
|
|
774
|
+
for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
|
|
775
|
+
for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
|
|
776
|
+
}
|
|
777
|
+
const words = new Set(String(branch || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
778
|
+
const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
|
|
779
|
+
if (!fs.existsSync(projectionPath)) return null;
|
|
780
|
+
const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
|
|
781
|
+
for (const task of projection.tasks || []) {
|
|
782
|
+
const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
|
|
783
|
+
const rowId = typeof task.id === 'string' && /^[A-Za-z0-9]{16,}$/.test(task.id) ? task.id.toLowerCase() : '';
|
|
784
|
+
if (!ids.some(id => refs.has(id)) && !(rowId && words.has(rowId))) continue;
|
|
785
|
+
const hit = protectedLaneOfTask(task);
|
|
786
|
+
if (hit) return { taskId: ids[0] || task.id, tag: hit };
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
} catch {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const OPEN_PR_LIST_LIMIT = 100;
|
|
795
|
+
|
|
796
|
+
function protectedPushRefusal(root, branch, run = spawnSync) {
|
|
797
|
+
const unknown = (why) => `blocked: cannot tell whether ${branch} has a pull request with auto-merge on (${why}); nothing was pushed`;
|
|
798
|
+
const listed = run('gh', [
|
|
799
|
+
'pr', 'list', '--head', branch, '--state', 'open',
|
|
800
|
+
'--json', 'number,url,baseRefName,autoMergeRequest',
|
|
801
|
+
'--limit', String(OPEN_PR_LIST_LIMIT),
|
|
802
|
+
], { cwd: root, encoding: 'utf8', timeout: 60000 });
|
|
803
|
+
if (listed.error) {
|
|
804
|
+
return unknown(listed.error.code === 'ETIMEDOUT' ? 'gh took longer than a minute to answer' : `gh did not run: ${listed.error.message}`);
|
|
805
|
+
}
|
|
806
|
+
if (listed.status !== 0) {
|
|
807
|
+
const said = `${listed.stderr || ''}\n${listed.stdout || ''}`;
|
|
808
|
+
const firstLine = said.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
809
|
+
return unknown(firstLine || `gh exited ${listed.status}`);
|
|
810
|
+
}
|
|
811
|
+
let open;
|
|
812
|
+
try { open = JSON.parse(String(listed.stdout || '')); } catch { open = null; }
|
|
813
|
+
const readable = Array.isArray(open)
|
|
814
|
+
&& open.every((pr) => pr && typeof pr === 'object' && !Array.isArray(pr) && Object.prototype.hasOwnProperty.call(pr, 'autoMergeRequest'));
|
|
815
|
+
if (!readable) return unknown("gh's answer could not be read");
|
|
816
|
+
if (open.length >= OPEN_PR_LIST_LIMIT) return unknown(`${open.length} open pull requests may have been cut short`);
|
|
817
|
+
const armed = open.filter((pr) => pr.autoMergeRequest);
|
|
818
|
+
if (!armed.length) return '';
|
|
819
|
+
const name = (pr) => `${pr.url || (pr.number ? `#${pr.number}` : branch)}${pr.baseRefName ? ` (into ${pr.baseRefName})` : ''}`;
|
|
820
|
+
const off = (pr) => `gh pr merge ${pr.number || pr.url || branch} --disable-auto`;
|
|
821
|
+
if (armed.length === 1) {
|
|
822
|
+
return `blocked: pull request ${name(armed[0])} has auto-merge on, so pushing ${branch} would land it with no person; turn auto-merge off first (${off(armed[0])}), then ship again`;
|
|
823
|
+
}
|
|
824
|
+
const names = armed.map(name);
|
|
825
|
+
return `blocked: pull requests ${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} have auto-merge on, so pushing ${branch} would land them with no person; turn auto-merge off on each first (${armed.map(off).join(', ')}), then ship again`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function findPrimaryRoot(root) {
|
|
829
|
+
const worktrees = listWorktrees(root);
|
|
830
|
+
return worktrees[0]?.path || root;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function shellQuote(value) {
|
|
834
|
+
const text = String(value || '');
|
|
835
|
+
if (/^[A-Za-z0-9_./:@+-]+$/.test(text)) return text;
|
|
836
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function experimentHealthProbe(statePath) {
|
|
840
|
+
const script = [
|
|
841
|
+
"const fs=require('fs')",
|
|
842
|
+
'const state=JSON.parse(fs.readFileSync(process.argv[1],\'utf8\'))',
|
|
843
|
+
'const last=Date.parse(state.last_run_date)',
|
|
844
|
+
`process.exit(Number.isFinite(last)&&Date.now()-last<=${DAY_MS}?0:1)`,
|
|
845
|
+
].join(';');
|
|
846
|
+
return `${shellQuote(process.execPath)} -e ${shellQuote(script)} ${shellQuote(statePath)}`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function runShipHealthCheck(workspaceRoot, options = {}) {
|
|
850
|
+
try {
|
|
851
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
852
|
+
const statePath = path.join(workspaceRoot, '.atris', 'state', 'experiments-daily.json');
|
|
853
|
+
const state = fs.existsSync(statePath)
|
|
854
|
+
? JSON.parse(fs.readFileSync(statePath, 'utf8'))
|
|
855
|
+
: {};
|
|
856
|
+
const rawLastRunDate = state && state.last_run_date;
|
|
857
|
+
const lastRunMs = rawLastRunDate ? Date.parse(rawLastRunDate) : null;
|
|
858
|
+
if (rawLastRunDate && !Number.isFinite(lastRunMs)) {
|
|
859
|
+
throw new Error('experiment state date is unreadable');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const ageMs = Number.isFinite(lastRunMs) ? now.getTime() - lastRunMs : null;
|
|
863
|
+
if (ageMs !== null && ageMs <= DAY_MS) return { healthy: true, filed: false };
|
|
864
|
+
|
|
865
|
+
const days = ageMs === null ? 1 : Math.max(1, Math.floor(ageMs / DAY_MS));
|
|
866
|
+
console.log(`health check failed: no verified experiment in ${days} days. shipping still works, the metabolism does not.`);
|
|
867
|
+
const filed = close.upsertSourceFlag({
|
|
868
|
+
what: 'no verified experiment has run in over a day',
|
|
869
|
+
owner: 'operator',
|
|
870
|
+
lane: 'code',
|
|
871
|
+
ttlDays: 1,
|
|
872
|
+
source: SHIP_HEALTH_SOURCE,
|
|
873
|
+
closeCondition: 'a verified experiment ran within 24 hours',
|
|
874
|
+
probe: experimentHealthProbe(statePath),
|
|
875
|
+
}, workspaceRoot, { now });
|
|
876
|
+
return { healthy: false, ...filed };
|
|
877
|
+
} catch (error) {
|
|
878
|
+
console.log(`health check skipped: ${String(error && error.message || error).toLowerCase()}. shipping still works.`);
|
|
879
|
+
return { healthy: null, filed: false, error };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const MAP_REFRESH_COMMIT_MESSAGE = 'Refresh map references that moved';
|
|
884
|
+
const MAP_REFRESH_TRAILER = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
|
|
885
|
+
|
|
886
|
+
function refreshMovedMapRefs(root, args = []) {
|
|
887
|
+
if (hasFlag(args, '--no-map-refresh')) return { skipped: true, refreshed: 0, left: 0 };
|
|
888
|
+
try {
|
|
889
|
+
const { changes, left } = fixMapDocs(root);
|
|
890
|
+
if (changes.length) {
|
|
891
|
+
const docs = [...new Set(changes.map(change => change.doc))];
|
|
892
|
+
runGit(['add', '--', ...docs], { cwd: root });
|
|
893
|
+
runGit(['commit', '-m', MAP_REFRESH_COMMIT_MESSAGE, '-m', MAP_REFRESH_TRAILER], { cwd: root });
|
|
894
|
+
console.log(`map: refreshed ${changes.length} moved references`);
|
|
895
|
+
} else {
|
|
896
|
+
console.log('map: references current');
|
|
897
|
+
}
|
|
898
|
+
if (left.length) {
|
|
899
|
+
console.log(`map: ${left.length} references need a human; run atris doc-health --fix-refs to see them`);
|
|
900
|
+
}
|
|
901
|
+
return { skipped: false, refreshed: changes.length, left: left.length };
|
|
902
|
+
} catch (error) {
|
|
903
|
+
const message = String((error && error.message) || error).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
904
|
+
console.log(`map: refresh skipped (${message}); the ship continues`);
|
|
905
|
+
return { skipped: false, refreshed: 0, left: 0, error };
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const SHIP_SETTLE_MS = 10 * 60 * 1000;
|
|
910
|
+
const SHIP_REAP_POLL_MS = 60 * 1000;
|
|
911
|
+
const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
|
|
912
|
+
const SHIP_REAP_UNMERGED_GRACE_MS = 15 * 60 * 1000;
|
|
913
|
+
const SHIP_REAP_RETRY_REASONS = new Set([
|
|
914
|
+
'active_process', 'process_scan_failed', 'recent_activity', 'base_refresh_failed', 'base_unverified',
|
|
915
|
+
'ignored_scan_failed', 'changed_or_active_during_verification',
|
|
916
|
+
]);
|
|
917
|
+
|
|
918
|
+
function shippedCopyRemovalPlan({ root, primary, env = process.env, atrisBin = ATRIS_BIN }) {
|
|
919
|
+
const copy = path.resolve(root);
|
|
920
|
+
if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { skip: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1', announce: true };
|
|
921
|
+
if (!primary || canonicalPath(copy) === canonicalPath(primary)) return { skip: 'primary checkout' };
|
|
922
|
+
if (!space.isAgentCopyPath(copy)) return { skip: 'not an agent build copy' };
|
|
923
|
+
if (space.isScratchPath(copy)) return { skip: 'temp folder' };
|
|
924
|
+
if (space.isInside(atrisBin, copy)) return { skip: 'this atris runs from inside the copy', announce: true };
|
|
925
|
+
return { copy };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function scheduleShippedCopyRemoval({ root, primary, branch, base, env = process.env, spawnProcess = spawn }) {
|
|
929
|
+
const plan = shippedCopyRemovalPlan({ root, primary, env });
|
|
930
|
+
if (plan.skip) return plan;
|
|
931
|
+
try {
|
|
932
|
+
const child = spawnProcess(process.execPath, [ATRIS_BIN, 'worktree', 'reap-shipped'], {
|
|
933
|
+
cwd: primary,
|
|
934
|
+
detached: true,
|
|
935
|
+
stdio: 'ignore',
|
|
936
|
+
env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_REAP_WORKTREE: plan.copy, ATRIS_REAP_BRANCH: branch || '', ATRIS_REAP_BASE: base || '' },
|
|
937
|
+
});
|
|
938
|
+
if (child && typeof child.on === 'function') child.on('error', () => {});
|
|
939
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
940
|
+
return { ...plan, scheduled: true };
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return { skip: `could not start the removal: ${String((error && error.message) || error)}`, announce: true };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function announceShippedCopyRemoval(plan) {
|
|
947
|
+
if (plan.scheduled) {
|
|
948
|
+
console.log(`cleanup: this build copy is removed once nothing has run in it for ${Math.round(SHIP_SETTLE_MS / 60000)} minutes`);
|
|
949
|
+
} else if (plan.announce) {
|
|
950
|
+
console.log(`cleanup: kept this build copy (${plan.skip})`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function reapShippedCopy({
|
|
955
|
+
root,
|
|
956
|
+
target,
|
|
957
|
+
base = '',
|
|
958
|
+
settleMs = SHIP_SETTLE_MS,
|
|
959
|
+
pollMs = SHIP_REAP_POLL_MS,
|
|
960
|
+
giveUpMs = SHIP_REAP_GIVE_UP_MS,
|
|
961
|
+
unmergedGraceMs = SHIP_REAP_UNMERGED_GRACE_MS,
|
|
962
|
+
sleep = sleepSync,
|
|
963
|
+
clock = Date.now,
|
|
964
|
+
cleanup: cleanupFn = cleanupWorktrees,
|
|
965
|
+
} = {}) {
|
|
966
|
+
const startedAt = clock();
|
|
967
|
+
sleep(settleMs);
|
|
968
|
+
for (;;) {
|
|
969
|
+
const result = cleanupFn({
|
|
970
|
+
root, base, apply: true, onlyPaths: [target], freshGraceMs: 0, idleMs: settleMs, by: 'atris worktree ship', now: clock(),
|
|
971
|
+
});
|
|
972
|
+
if (result.removed.length) return { removed: true, reason: result.removed[0].reason };
|
|
973
|
+
const entry = result.kept.find((item) => canonicalPath(item.path) === canonicalPath(target));
|
|
974
|
+
if (!entry) return { removed: false, reason: 'not_registered' };
|
|
975
|
+
const waited = clock() - startedAt;
|
|
976
|
+
const retry = SHIP_REAP_RETRY_REASONS.has(entry.reason) || (entry.reason === 'unmerged' && waited < unmergedGraceMs);
|
|
977
|
+
if (!retry || waited >= giveUpMs) return { removed: false, reason: entry.reason };
|
|
978
|
+
sleep(pollMs);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function shipMergeRecord(state) {
|
|
983
|
+
const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
|
|
984
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/i.test(commit)) return null;
|
|
985
|
+
const record = { merge_commit: commit };
|
|
986
|
+
if (Number.isInteger(state.number) && state.number > 0) record.number = state.number;
|
|
987
|
+
if (typeof state.url === 'string' && state.url) record.url = state.url;
|
|
988
|
+
if (typeof state.mergedAt === 'string' && state.mergedAt) record.merged_at = state.mergedAt;
|
|
989
|
+
return record;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
993
|
+
const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
|
|
994
|
+
const GENERATED_WORKTREE_CACHE_DIRS = [
|
|
995
|
+
'.derivedData',
|
|
996
|
+
'.next',
|
|
997
|
+
'.swiftpm',
|
|
998
|
+
'.cache/swiftpm',
|
|
999
|
+
'node_modules/.cache',
|
|
1000
|
+
];
|
|
1001
|
+
|
|
1002
|
+
function worktreeWithinReapGrace(worktreePath, now = Date.now(), graceMs = WORKTREE_REAP_GRACE_MS) {
|
|
1003
|
+
try {
|
|
1004
|
+
return now - fs.statSync(worktreePath).mtimeMs < graceMs;
|
|
1005
|
+
} catch {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function lastCopyActivityMs(worktreePath) {
|
|
1011
|
+
const marks = [worktreePath, path.join(worktreePath, '.next'), path.join(worktreePath, '.codex-last-message.txt')];
|
|
1012
|
+
try {
|
|
1013
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(worktreePath, '.git'), 'utf8'));
|
|
1014
|
+
if (pointer) {
|
|
1015
|
+
const gitDir = path.resolve(worktreePath, pointer[1].trim());
|
|
1016
|
+
marks.push(path.join(gitDir, 'HEAD'), path.join(gitDir, 'logs', 'HEAD'));
|
|
1017
|
+
}
|
|
1018
|
+
} catch {}
|
|
1019
|
+
let newest = 0;
|
|
1020
|
+
for (const mark of marks) {
|
|
1021
|
+
try {
|
|
1022
|
+
newest = Math.max(newest, fs.statSync(mark).mtimeMs);
|
|
1023
|
+
} catch {}
|
|
1024
|
+
}
|
|
1025
|
+
return newest;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function canonicalPath(value) {
|
|
1029
|
+
try {
|
|
1030
|
+
return fs.realpathSync(value);
|
|
1031
|
+
} catch {
|
|
1032
|
+
return path.resolve(value);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function listActiveProcessFiles() {
|
|
1037
|
+
const result = spawnSync('lsof', ['-n', '-F', 'n'], {
|
|
1038
|
+
encoding: 'utf8',
|
|
1039
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1040
|
+
timeout: 20000,
|
|
1041
|
+
killSignal: 'SIGKILL',
|
|
1042
|
+
});
|
|
1043
|
+
if (result.error || result.status !== 0 || String(result.stderr || '').trim()) return null;
|
|
1044
|
+
const paths = String(result.stdout || '')
|
|
1045
|
+
.split(/\r?\n/)
|
|
1046
|
+
.filter((line) => line.startsWith('n/'))
|
|
1047
|
+
.map((line) => canonicalPath(line.slice(1)))
|
|
1048
|
+
.filter(Boolean);
|
|
1049
|
+
return paths.length ? paths : null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function parseProcessArgPaths(psOutput) {
|
|
1053
|
+
const paths = new Set();
|
|
1054
|
+
const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
|
|
1055
|
+
for (const raw of String(psOutput || '').split(/\r?\n/)) {
|
|
1056
|
+
const line = raw.trim();
|
|
1057
|
+
for (const match of line.matchAll(starts)) {
|
|
1058
|
+
const rest = line.slice(match.index + match[0].length);
|
|
1059
|
+
const token = rest.split(/\s/, 1)[0];
|
|
1060
|
+
if (token.length > 1) paths.add(token);
|
|
1061
|
+
if (rest.length > token.length) paths.add(rest);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return [...paths];
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function listActiveProcessArgPaths() {
|
|
1068
|
+
const result = spawnSync('ps', ['-axww', '-o', 'args='], {
|
|
1069
|
+
encoding: 'utf8',
|
|
1070
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1071
|
+
timeout: 5000,
|
|
1072
|
+
killSignal: 'SIGKILL',
|
|
1073
|
+
});
|
|
1074
|
+
if (result.error || result.status !== 0) return null;
|
|
1075
|
+
return parseProcessArgPaths(result.stdout);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function listActiveProcessPaths() {
|
|
1079
|
+
const files = listActiveProcessFiles();
|
|
1080
|
+
if (!Array.isArray(files)) return null;
|
|
1081
|
+
const args = listActiveProcessArgPaths();
|
|
1082
|
+
if (!Array.isArray(args)) return null;
|
|
1083
|
+
return files.concat(args);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
1087
|
+
if (!Array.isArray(activeCwds)) return true;
|
|
1088
|
+
const resolved = canonicalPath(worktreePath);
|
|
1089
|
+
const prefix = `${resolved}${path.sep}`;
|
|
1090
|
+
return activeCwds.some((cwd) => {
|
|
1091
|
+
const active = canonicalPath(cwd);
|
|
1092
|
+
return active === resolved || active.startsWith(prefix);
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason, ...details }) {
|
|
1097
|
+
try {
|
|
1098
|
+
const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
|
|
1099
|
+
const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
|
|
1100
|
+
const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
|
|
1101
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1102
|
+
fs.appendFileSync(file, `${JSON.stringify({
|
|
1103
|
+
at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, ...details, pid: process.pid,
|
|
1104
|
+
})}\n`);
|
|
1105
|
+
} catch {}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function webpackCacheSnapshot(worktreePath) {
|
|
1109
|
+
try {
|
|
1110
|
+
const root = fs.realpathSync(worktreePath);
|
|
1111
|
+
let current = root;
|
|
1112
|
+
for (const part of ['.next', 'cache', 'webpack']) {
|
|
1113
|
+
current = path.join(current, part);
|
|
1114
|
+
const stat = fs.lstatSync(current);
|
|
1115
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
1116
|
+
}
|
|
1117
|
+
if (fs.realpathSync(current) !== current) return null;
|
|
1118
|
+
const entries = [];
|
|
1119
|
+
function walk(dir) {
|
|
1120
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
1121
|
+
const file = path.join(dir, name);
|
|
1122
|
+
const stat = fs.lstatSync(file);
|
|
1123
|
+
if (stat.isSymbolicLink()) return false;
|
|
1124
|
+
if (stat.isDirectory()) {
|
|
1125
|
+
entries.push([file, 'd', stat.ino, stat.mtimeMs]);
|
|
1126
|
+
if (!walk(file)) return false;
|
|
1127
|
+
} else if (stat.isFile() && /^(?:index|\d+)\.pack(?:\.gz|\.old)?$/.test(name)) {
|
|
1128
|
+
entries.push([file, 'f', stat.ino, stat.size, stat.mtimeMs]);
|
|
1129
|
+
} else return false;
|
|
1130
|
+
}
|
|
1131
|
+
return true;
|
|
1132
|
+
}
|
|
1133
|
+
if (!walk(current)) return null;
|
|
1134
|
+
const size = spawnSync('du', ['-sk', current], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
|
|
1135
|
+
const match = size.status === 0 && String(size.stdout || '').match(/^(\d+)\s/);
|
|
1136
|
+
if (!match) return null;
|
|
1137
|
+
return { path: current, kib: Number(match[1]), fingerprint: JSON.stringify(entries) };
|
|
1138
|
+
} catch {
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function pruneGeneratedWorktreeCaches(worktreePath, { apply = false, budget } = {}) {
|
|
1144
|
+
const candidates = [];
|
|
1145
|
+
const pruned = [];
|
|
1146
|
+
for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
|
|
1147
|
+
const target = path.join(worktreePath, relative);
|
|
1148
|
+
if (!fs.existsSync(target)) continue;
|
|
1149
|
+
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1150
|
+
if (ignored.status !== 0) continue;
|
|
1151
|
+
const item = { worktree: worktreePath, path: target, relative, reason: 'manual_review_required' };
|
|
1152
|
+
candidates.push(item);
|
|
1153
|
+
}
|
|
1154
|
+
const relative = '.next/cache/webpack';
|
|
1155
|
+
const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1156
|
+
const snapshot = ignored.status === 0 ? webpackCacheSnapshot(worktreePath) : null;
|
|
1157
|
+
if (snapshot) {
|
|
1158
|
+
const item = { worktree: worktreePath, path: snapshot.path, relative, kib: snapshot.kib, reason: 'generated_webpack_cache' };
|
|
1159
|
+
candidates.push(item);
|
|
1160
|
+
if (apply && budget && budget.count < 20 && budget.kib + snapshot.kib <= 10 * 1024 * 1024) {
|
|
1161
|
+
const active = listActiveProcessPaths();
|
|
1162
|
+
const fresh = webpackCacheSnapshot(worktreePath);
|
|
1163
|
+
const stillIgnored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
|
|
1164
|
+
if (Array.isArray(active) && !worktreeHasActiveProcess(worktreePath, active)
|
|
1165
|
+
&& stillIgnored.status === 0 && fresh && fresh.path === snapshot.path
|
|
1166
|
+
&& fresh.kib === snapshot.kib && fresh.fingerprint === snapshot.fingerprint) {
|
|
1167
|
+
try {
|
|
1168
|
+
fs.rmSync(snapshot.path, { recursive: true });
|
|
1169
|
+
budget.count += 1;
|
|
1170
|
+
budget.kib += snapshot.kib;
|
|
1171
|
+
pruned.push(item);
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
item.error = String((error && error.message) || error);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return { candidates, pruned };
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function originBaseIsCurrent(root, base) {
|
|
1182
|
+
const prefix = base.startsWith('origin/') ? 'origin/'
|
|
1183
|
+
: base.startsWith('refs/remotes/origin/') ? 'refs/remotes/origin/' : '';
|
|
1184
|
+
if (!prefix) {
|
|
1185
|
+
if (base === 'HEAD') {
|
|
1186
|
+
const branch = runGit(['symbolic-ref', '--short', 'HEAD'], { cwd: root, check: false });
|
|
1187
|
+
return branch.status === 0 && PROTECTED_BRANCHES.has(branch.stdout.trim());
|
|
1188
|
+
}
|
|
1189
|
+
return PROTECTED_BRANCHES.has(base.replace(/^refs\/heads\//, ''));
|
|
1190
|
+
}
|
|
1191
|
+
const branch = base.slice(prefix.length);
|
|
1192
|
+
const local = runGit(['rev-parse', '--verify', base], { cwd: root, check: false });
|
|
1193
|
+
const remote = runGit(['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`], { cwd: root, check: false, timeout: 5000 });
|
|
1194
|
+
const match = String(remote.stdout || '').match(/^([a-f0-9]{40,64})\s+(refs\/heads\/[^\r\n]+)$/m);
|
|
1195
|
+
return local.status === 0 && remote.status === 0 && Boolean(match)
|
|
1196
|
+
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// The guarantee: a copy is removed only when every byte in it is proven saved
|
|
1200
|
+
// (committed and on origin) or proven throwaway. Anything unusual, or anything
|
|
1201
|
+
// the checks cannot read, keeps the copy for a person.
|
|
1202
|
+
//
|
|
1203
|
+
// Throwaway ignored folders are matched by exact place, never by name alone:
|
|
1204
|
+
// an install or cache folder right beside the package file that makes it
|
|
1205
|
+
// (package.json for node_modules, .next, .cache, .turbo; pyproject.toml,
|
|
1206
|
+
// setup.py or setup.cfg for the Python test caches). uploads/.cache/ and a
|
|
1207
|
+
// .cache/ with no package.json beside it are not throwaway.
|
|
1208
|
+
const JS_THROWAWAY_DIRS = new Set(['node_modules', '.next', '.cache', '.turbo']);
|
|
1209
|
+
const JS_PACKAGE_FILES = ['package.json'];
|
|
1210
|
+
const PY_THROWAWAY_DIRS = new Set(['.pytest_cache', '.mypy_cache', '.ruff_cache']);
|
|
1211
|
+
const PY_PACKAGE_FILES = ['pyproject.toml', 'setup.py', 'setup.cfg'];
|
|
1212
|
+
// Written by atris itself at the top of a copy: the start sidecar, prompt
|
|
1213
|
+
// files, watchdogs, its scratch and cache folders, and command usage counts.
|
|
1214
|
+
const ATRIS_PLUMBING_IGNORED = /^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|codex-watchdog\.js|codex-watchdog-[^/]+\.json|runtime-tmp\/.*|cache\/.*|state\/usage\.jsonl)$/;
|
|
1215
|
+
const THROWAWAY_IGNORED_FILE = /^(?:(?:.*\/)?\.DS_Store|tsconfig\.tsbuildinfo|next-env\.d\.ts)$/;
|
|
1216
|
+
const IGNORED_WALK_LIMIT = 20000;
|
|
1217
|
+
const RESCUE_IDLE_MS = 7 * DAY_MS;
|
|
1218
|
+
|
|
1219
|
+
function throwawayIgnoredDir(wtPath, relative) {
|
|
1220
|
+
const name = path.basename(relative);
|
|
1221
|
+
const parent = path.dirname(relative);
|
|
1222
|
+
// Even at the top of the copy, a cache counts only beside the package file that makes it.
|
|
1223
|
+
const besidePackage = (files) => files.some((file) => {
|
|
1224
|
+
try { return fs.lstatSync(path.join(wtPath, parent, file)).isFile(); } catch { return false; }
|
|
1225
|
+
});
|
|
1226
|
+
if (JS_THROWAWAY_DIRS.has(name)) return besidePackage(JS_PACKAGE_FILES);
|
|
1227
|
+
if (PY_THROWAWAY_DIRS.has(name)) return besidePackage(PY_PACKAGE_FILES);
|
|
1228
|
+
// Python bytecode folders sit beside every module; one counts only when it
|
|
1229
|
+
// holds nothing but .pyc files.
|
|
1230
|
+
if (name === '__pycache__') {
|
|
1231
|
+
try {
|
|
1232
|
+
return fs.readdirSync(path.join(wtPath, relative), { withFileTypes: true })
|
|
1233
|
+
.every((entry) => entry.isFile() && entry.name.endsWith('.pyc'));
|
|
1234
|
+
} catch { return false; }
|
|
1235
|
+
}
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function throwawayIgnoredFile(relative) {
|
|
1240
|
+
return THROWAWAY_IGNORED_FILE.test(relative) || ATRIS_PLUMBING_IGNORED.test(relative);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Ignored files in a copy that removal would lose: everything git ignores that
|
|
1244
|
+
// is not proven throwaway. null when the copy cannot be read or holds too many
|
|
1245
|
+
// files to check.
|
|
1246
|
+
function ignoredFilesToKeep(wtPath) {
|
|
1247
|
+
const listed = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wtPath, check: false });
|
|
1248
|
+
if (listed.status !== 0 || listed.error) return null;
|
|
1249
|
+
const keepers = [];
|
|
1250
|
+
let walked = 0;
|
|
1251
|
+
for (const entry of String(listed.stdout || '').split('\0').filter(Boolean)) {
|
|
1252
|
+
const clean = entry.replace(/\/+$/, '');
|
|
1253
|
+
if (!entry.endsWith('/')) {
|
|
1254
|
+
if (!throwawayIgnoredFile(clean)) keepers.push(clean);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
if (throwawayIgnoredDir(wtPath, clean)) continue;
|
|
1258
|
+
const stack = [clean];
|
|
1259
|
+
while (stack.length) {
|
|
1260
|
+
const rel = stack.pop();
|
|
1261
|
+
let children;
|
|
1262
|
+
try { children = fs.readdirSync(path.join(wtPath, rel), { withFileTypes: true }); } catch { return null; }
|
|
1263
|
+
for (const child of children) {
|
|
1264
|
+
walked += 1;
|
|
1265
|
+
if (walked > IGNORED_WALK_LIMIT) return null;
|
|
1266
|
+
const childRel = `${rel}/${child.name}`;
|
|
1267
|
+
if (child.isDirectory()) {
|
|
1268
|
+
if (!throwawayIgnoredDir(wtPath, childRel)) stack.push(childRel);
|
|
1269
|
+
} else if (!throwawayIgnoredFile(childRel)) {
|
|
1270
|
+
keepers.push(childRel);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return keepers;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Why a copy holds work the snapshot cannot prove it saved, or '' when it
|
|
1279
|
+
// holds none: index entries told to hide their edits, conflicts, sparse
|
|
1280
|
+
// checkouts, and nested repositories other than a registered submodule that
|
|
1281
|
+
// sits clean at a commit its own origin already has.
|
|
1282
|
+
function unprovableReason(wtPath) {
|
|
1283
|
+
const flags = runGit(['ls-files', '-v', '-z'], { cwd: wtPath, check: false });
|
|
1284
|
+
if (flags.status !== 0) return 'could not read the index';
|
|
1285
|
+
const hidden = String(flags.stdout || '').split('\0').filter(Boolean)
|
|
1286
|
+
.filter((row) => /^[a-zS]/.test(row)).map((row) => row.slice(2));
|
|
1287
|
+
if (hidden.length) return `index entries hide their edits (assume-unchanged or skip-worktree): ${hidden.slice(0, 3).join(', ')}`;
|
|
1288
|
+
const conflicts = runGit(['ls-files', '-u'], { cwd: wtPath, check: false });
|
|
1289
|
+
if (conflicts.status !== 0 || String(conflicts.stdout || '').trim()) return 'unresolved merge conflicts';
|
|
1290
|
+
const sparse = runGit(['config', '--bool', 'core.sparseCheckout'], { cwd: wtPath, check: false });
|
|
1291
|
+
if (String(sparse.stdout || '').trim() === 'true') return 'sparse checkout';
|
|
1292
|
+
// git lists an untracked folder that is its own repository as `name/`;
|
|
1293
|
+
// committing it would save only a pointer, not its files or commits.
|
|
1294
|
+
const untracked = runGit(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: wtPath, check: false });
|
|
1295
|
+
if (untracked.status !== 0) return 'could not list untracked files';
|
|
1296
|
+
const nested = String(untracked.stdout || '').split('\0').filter((entry) => entry.endsWith('/'));
|
|
1297
|
+
if (nested.length) return `nested git repository ${nested[0].replace(/\/$/, '')} is not saved by a commit`;
|
|
1298
|
+
const listed = runGit(['ls-files', '-s', '-z'], { cwd: wtPath, check: false });
|
|
1299
|
+
if (listed.status !== 0) return 'could not read the index';
|
|
1300
|
+
const links = new Map();
|
|
1301
|
+
for (const row of String(listed.stdout || '').split('\0').filter(Boolean)) {
|
|
1302
|
+
const match = row.match(/^160000 ([0-9a-f]+) \d\t(.+)$/);
|
|
1303
|
+
if (match) links.set(match[2], match[1]);
|
|
1304
|
+
}
|
|
1305
|
+
if (!links.size) return '';
|
|
1306
|
+
const registered = runGit(['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], { cwd: wtPath, check: false });
|
|
1307
|
+
const paths = new Set(String(registered.stdout || '').split('\n').map((line) => line.split(' ').slice(1).join(' ')).filter(Boolean));
|
|
1308
|
+
for (const [link, oid] of links) {
|
|
1309
|
+
if (!paths.has(link)) return `nested git repository ${link} is not a registered submodule`;
|
|
1310
|
+
const dir = path.join(wtPath, link);
|
|
1311
|
+
let entries = [];
|
|
1312
|
+
try { entries = fs.readdirSync(dir); } catch {}
|
|
1313
|
+
if (!entries.length) continue;
|
|
1314
|
+
const head = runGit(['rev-parse', 'HEAD'], { cwd: dir, check: false });
|
|
1315
|
+
const status = runGit(['status', '--porcelain', '--ignored=matching'], { cwd: dir, check: false });
|
|
1316
|
+
const pushed = runGit(['branch', '-r', '--contains', oid], { cwd: dir, check: false });
|
|
1317
|
+
if (head.status !== 0 || head.stdout.trim() !== oid || status.status !== 0 || String(status.stdout || '').trim()
|
|
1318
|
+
|| pushed.status !== 0 || !String(pushed.stdout || '').trim()) {
|
|
1319
|
+
return `submodule ${link} has work not proven on its origin`;
|
|
1320
|
+
}
|
|
1321
|
+
// A clean status proves nothing if the submodule hides edits too.
|
|
1322
|
+
const inner = unprovableReason(dir);
|
|
1323
|
+
if (inner) return `submodule ${link}: ${inner}`;
|
|
1324
|
+
}
|
|
1325
|
+
return '';
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Settings that let git change bytes between disk and a commit. A content
|
|
1329
|
+
// filter (clean filters, LFS and the like) or the ident attribute (where
|
|
1330
|
+
// `$Id: any text $` hashes the same as `$Id$`) can hide local text even from
|
|
1331
|
+
// `git status`, so either keeps every copy, clean or dirty, on every removal
|
|
1332
|
+
// path. Line-ending and encoding conversion also keep a dirty copy, where the
|
|
1333
|
+
// rescue snapshot must match the disk byte for byte; on a clean copy they only
|
|
1334
|
+
// differ by reversible line endings or encoding, an accepted limit. A filter defined only in global config and named by no
|
|
1335
|
+
// attribute changes nothing and is allowed.
|
|
1336
|
+
const FILTER_ATTRIBUTE = /(?:^|\s)(?:filter|ident)(?:[=\s]|$)/m;
|
|
1337
|
+
const BYTE_CHANGING_ATTRIBUTE = /(?:^|\s)(?:filter|working-tree-encoding|eol|text|crlf|ident)(?:[=\s]|$)/m;
|
|
1338
|
+
|
|
1339
|
+
function attributeFiles(wtPath) {
|
|
1340
|
+
const files = [];
|
|
1341
|
+
const listed = runGit(['ls-files', '--cached', '--others', '-z', '--', '.gitattributes', '*/.gitattributes'], { cwd: wtPath, check: false });
|
|
1342
|
+
if (listed.status !== 0) return null;
|
|
1343
|
+
for (const rel of String(listed.stdout || '').split('\0').filter(Boolean)) files.push(path.join(wtPath, rel));
|
|
1344
|
+
const info = runGit(['rev-parse', '--path-format=absolute', '--git-path', 'info/attributes'], { cwd: wtPath, check: false });
|
|
1345
|
+
if (info.status === 0 && info.stdout.trim()) files.push(info.stdout.trim());
|
|
1346
|
+
const configured = runGit(['config', '--path', 'core.attributesFile'], { cwd: wtPath, check: false });
|
|
1347
|
+
const fallback = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'git', 'attributes');
|
|
1348
|
+
files.push(configured.status === 0 && configured.stdout.trim() ? configured.stdout.trim() : fallback);
|
|
1349
|
+
return files;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function byteChangingSetting(wtPath, { filtersOnly = false } = {}) {
|
|
1353
|
+
const local = runGit(['config', '--local', '--get-regexp', '^filter\\.'], { cwd: wtPath, check: false });
|
|
1354
|
+
if (String(local.stdout || '').trim()) return 'a content filter is set in this repo';
|
|
1355
|
+
const worktreeScope = runGit(['config', '--worktree', '--get-regexp', '^filter\\.'], { cwd: wtPath, check: false });
|
|
1356
|
+
if (String(worktreeScope.stdout || '').trim()) return 'a content filter is set in this copy';
|
|
1357
|
+
for (const key of filtersOnly ? [] : ['core.autocrlf', 'core.eol']) {
|
|
1358
|
+
const value = String(runGit(['config', key], { cwd: wtPath, check: false }).stdout || '').trim().toLowerCase();
|
|
1359
|
+
if (value && value !== 'false' && !(key === 'core.eol' && value === 'native')) return `${key} converts line endings`;
|
|
1360
|
+
}
|
|
1361
|
+
const files = attributeFiles(wtPath);
|
|
1362
|
+
if (!files) return 'could not list attribute files';
|
|
1363
|
+
for (const file of files) {
|
|
1364
|
+
let text = '';
|
|
1365
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
1366
|
+
if (error && error.code === 'ENOENT') continue;
|
|
1367
|
+
return `could not read ${file}`;
|
|
1368
|
+
}
|
|
1369
|
+
const rows = text.split(/\r?\n/).filter((row) => !/^\s*(?:#|$)/.test(row));
|
|
1370
|
+
const hit = rows.find((row) => (filtersOnly ? FILTER_ATTRIBUTE : BYTE_CHANGING_ATTRIBUTE).test(row));
|
|
1371
|
+
if (hit) return `git attributes can change bytes on commit (${path.basename(file)}: ${hit.trim().slice(0, 60)})`;
|
|
1372
|
+
}
|
|
1373
|
+
return '';
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function sizeKib(target) {
|
|
1377
|
+
const measured = spawnSync('du', ['-sk', target], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1378
|
+
const match = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
|
|
1379
|
+
return match ? Number(match[1]) : 0;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function formatKib(kib) {
|
|
1383
|
+
if (!Number.isFinite(kib) || kib <= 0) return 'an unknown amount';
|
|
1384
|
+
if (kib >= 1024 * 1024) return `${(kib / (1024 * 1024)).toFixed(1)} GB`;
|
|
1385
|
+
if (kib >= 1024) return `${Math.round(kib / 1024)} MB`;
|
|
1386
|
+
return `${kib} KB`;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function worktreeEntry(root, wtPath) {
|
|
1390
|
+
return listWorktrees(root).find((wt) => canonicalPath(wt.path) === canonicalPath(wtPath)) || null;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function remoteBranchOid(root, branch) {
|
|
1394
|
+
const remote = runGit(['ls-remote', 'origin', `refs/heads/${branch}`], { cwd: root, check: false });
|
|
1395
|
+
if (remote.status !== 0 || remote.error) return null;
|
|
1396
|
+
const match = String(remote.stdout || '').match(/^([0-9a-f]{40,64})\s+refs\/heads\/(\S+)$/m);
|
|
1397
|
+
return match && match[2] === branch ? match[1] : '';
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Where the copy's last commit is safe outside the copy: merged into the
|
|
1401
|
+
// branch it landed on, or sitting on the same branch at origin.
|
|
1402
|
+
function shippedWorkHome(root, { head, branch, landedOn }) {
|
|
1403
|
+
if (landedOn) {
|
|
1404
|
+
if (landedOn.startsWith('origin/')) refreshRemoteRef(root, landedOn);
|
|
1405
|
+
if (runGit(['merge-base', '--is-ancestor', head, landedOn], { cwd: root, check: false }).status === 0) {
|
|
1406
|
+
return baseBranchName(landedOn);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (branch && remoteBranchOid(root, branch) === head) return `origin/${branch}`;
|
|
1410
|
+
return '';
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// Remove a copy right after ship lands or pushes it. Runs from the main
|
|
1414
|
+
// checkout, never from inside the copy. A copy with changes, files that cannot
|
|
1415
|
+
// come back, or another program inside it is kept and the reason returned.
|
|
1416
|
+
function removeShippedCopyNow({ root, primary, branch = '', landedOn = '', keep = false, env = process.env, atrisBin = ATRIS_BIN, activePaths = listActiveProcessPaths } = {}) {
|
|
1417
|
+
if (keep) return { removed: false, reason: 'kept on request (--keep-copy)' };
|
|
1418
|
+
if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { removed: false, reason: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1' };
|
|
1419
|
+
const copy = canonicalPath(root);
|
|
1420
|
+
if (!primary || copy === canonicalPath(primary)) return { removed: false, reason: 'primary checkout', quiet: true };
|
|
1421
|
+
if (!space.isAgentCopyPath(copy)) return { removed: false, reason: 'not an agent build copy', quiet: true };
|
|
1422
|
+
if (space.isInside(atrisBin, copy)) return { removed: false, reason: 'this atris runs from inside the copy' };
|
|
1423
|
+
const mainRoot = canonicalPath(primary);
|
|
1424
|
+
const wt = worktreeEntry(mainRoot, copy);
|
|
1425
|
+
if (!wt) return { removed: false, reason: 'not registered' };
|
|
1426
|
+
if (wt.locked) return { removed: false, reason: 'locked' };
|
|
1427
|
+
const counts = statusCounts(copy);
|
|
1428
|
+
if (!counts) return { removed: false, reason: 'unreadable' };
|
|
1429
|
+
if (counts.staged || counts.unstaged || counts.untracked) {
|
|
1430
|
+
return { removed: false, reason: `it still has ${counts.staged + counts.unstaged + counts.untracked} unsaved changes` };
|
|
1431
|
+
}
|
|
1432
|
+
const keepers = ignoredFilesToKeep(copy);
|
|
1433
|
+
if (!keepers) return { removed: false, reason: 'could not check the files git ignores', retry: true };
|
|
1434
|
+
if (keepers.length) return { removed: false, reason: `it holds ignored files atris cannot rebuild: ${keepers.slice(0, 3).join(', ')}` };
|
|
1435
|
+
const unprovable = byteChangingSetting(copy, { filtersOnly: true }) || unprovableReason(copy);
|
|
1436
|
+
if (unprovable) return { removed: false, reason: unprovable };
|
|
1437
|
+
const head = runGit(['rev-parse', 'HEAD'], { cwd: copy, check: false });
|
|
1438
|
+
const headOid = head.status === 0 ? head.stdout.trim() : '';
|
|
1439
|
+
if (!headOid) return { removed: false, reason: 'no commit' };
|
|
1440
|
+
const home = shippedWorkHome(mainRoot, { head: headOid, branch, landedOn });
|
|
1441
|
+
if (!home) return { removed: false, reason: 'could not confirm the work reached origin', retry: true };
|
|
1442
|
+
// Step out first so this process is not one of the programs inside. The
|
|
1443
|
+
// shell that ran ship still is, so a copy someone stands in waits for the
|
|
1444
|
+
// delayed remover instead of vanishing under them.
|
|
1445
|
+
if (space.isInside(process.cwd(), copy)) {
|
|
1446
|
+
try { process.chdir(mainRoot); } catch { return { removed: false, reason: 'could not step out of the copy', retry: true }; }
|
|
1447
|
+
}
|
|
1448
|
+
const active = activePaths();
|
|
1449
|
+
if (!Array.isArray(active)) return { removed: false, reason: 'could not list running programs', retry: true };
|
|
1450
|
+
if (worktreeHasActiveProcess(copy, active)) return { removed: false, reason: 'a program is still working in it', retry: true };
|
|
1451
|
+
const kib = sizeKib(copy);
|
|
1452
|
+
const removed = runGit(['worktree', 'remove', copy], { cwd: mainRoot, check: false });
|
|
1453
|
+
if (removed.status !== 0) return { removed: false, reason: `git refused: ${gitFailureDetail(removed) || 'remove failed'}`, retry: true };
|
|
1454
|
+
recordWorktreeRemoval(mainRoot, { path: copy, branch, by: 'atris worktree ship', reason: 'shipped', saved_on: home, size_kib: kib });
|
|
1455
|
+
return { removed: true, kib, home, path: copy };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// After a successful ship: remove the copy now, or hand a landed copy to the
|
|
1459
|
+
// delayed remover when something still runs inside it.
|
|
1460
|
+
function finishShippedCopy({ root, primary, branch, landedOn = '', keep = false, log = console.log, schedule = scheduleShippedCopyRemoval }) {
|
|
1461
|
+
const result = removeShippedCopyNow({ root, primary, branch, landedOn, keep });
|
|
1462
|
+
if (result.removed) {
|
|
1463
|
+
log(`cleanup: removed this build copy and freed ${formatKib(result.kib)}; the work is safe on ${result.home}`);
|
|
1464
|
+
return result;
|
|
1465
|
+
}
|
|
1466
|
+
if (result.retry && !keep) {
|
|
1467
|
+
log(`cleanup: not yet (${result.reason})`);
|
|
1468
|
+
announceShippedCopyRemoval(schedule({ root, primary, branch, base: landedOn }));
|
|
1469
|
+
return result;
|
|
1470
|
+
}
|
|
1471
|
+
if (!result.quiet) log(`cleanup: kept this build copy (${result.reason})`);
|
|
1472
|
+
return result;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// The delayed remover for a copy whose branch was pushed but not merged:
|
|
1476
|
+
// wait for quiet, then retry the same checks ship ran until the copy is free.
|
|
1477
|
+
function reapPushedCopy({
|
|
1478
|
+
primary,
|
|
1479
|
+
target,
|
|
1480
|
+
branch = '',
|
|
1481
|
+
settleMs = SHIP_SETTLE_MS,
|
|
1482
|
+
pollMs = SHIP_REAP_POLL_MS,
|
|
1483
|
+
giveUpMs = SHIP_REAP_GIVE_UP_MS,
|
|
1484
|
+
sleep = sleepSync,
|
|
1485
|
+
clock = Date.now,
|
|
1486
|
+
remove = removeShippedCopyNow,
|
|
1487
|
+
} = {}) {
|
|
1488
|
+
const startedAt = clock();
|
|
1489
|
+
sleep(settleMs);
|
|
1490
|
+
for (;;) {
|
|
1491
|
+
const result = remove({ root: target, primary, branch });
|
|
1492
|
+
if (result.removed) return { removed: true, reason: 'pushed' };
|
|
1493
|
+
if (!result.retry || clock() - startedAt >= giveUpMs) return { removed: false, reason: result.reason };
|
|
1494
|
+
sleep(pollMs);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Snapshot a copy as two git trees through a throwaway copy of its index, so
|
|
1499
|
+
// the copy and its own index stay untouched: what is staged, and everything on
|
|
1500
|
+
// disk including untracked files. Keeping both means a staged version that
|
|
1501
|
+
// differs from the file on disk survives too.
|
|
1502
|
+
function snapshotTrees(wtPath) {
|
|
1503
|
+
const realIndex = String(runGit(['rev-parse', '--path-format=absolute', '--git-path', 'index'], { cwd: wtPath, check: false }).stdout || '').trim();
|
|
1504
|
+
const index = path.join(os.tmpdir(), `atris-rescue-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.index`);
|
|
1505
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
1506
|
+
const run = (args) => spawnSync('git', args, { cwd: wtPath, encoding: 'utf8', env, maxBuffer: COMMAND_MAX_BUFFER_BYTES, timeout: 120000, killSignal: 'SIGKILL' });
|
|
1507
|
+
try {
|
|
1508
|
+
if (realIndex && fs.existsSync(realIndex)) fs.copyFileSync(realIndex, index);
|
|
1509
|
+
else if (run(['read-tree', 'HEAD']).status !== 0) return null;
|
|
1510
|
+
const staged = run(['write-tree']);
|
|
1511
|
+
if (staged.status !== 0) return null;
|
|
1512
|
+
if (run(['add', '-A']).status !== 0) return null;
|
|
1513
|
+
const disk = run(['write-tree']);
|
|
1514
|
+
if (disk.status !== 0) return null;
|
|
1515
|
+
return { staged: staged.stdout.trim(), disk: disk.stdout.trim() };
|
|
1516
|
+
} finally {
|
|
1517
|
+
try { fs.rmSync(index, { force: true }); } catch {}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function newestChangeMs(wtPath) {
|
|
1522
|
+
let newest = lastCopyActivityMs(wtPath);
|
|
1523
|
+
const status = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: wtPath, check: false });
|
|
1524
|
+
if (status.status !== 0) return newest;
|
|
1525
|
+
const fields = String(status.stdout || '').split('\0');
|
|
1526
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
1527
|
+
const row = fields[i];
|
|
1528
|
+
if (row.length < 4) continue;
|
|
1529
|
+
if (row[0] === 'R' || row[0] === 'C') i += 1;
|
|
1530
|
+
try { newest = Math.max(newest, fs.lstatSync(path.join(wtPath, row.slice(3))).mtimeMs); } catch {}
|
|
1531
|
+
}
|
|
1532
|
+
return newest;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function commitIdentityEnv(wtPath) {
|
|
1536
|
+
const email = runGit(['config', 'user.email'], { cwd: wtPath, check: false });
|
|
1537
|
+
const name = runGit(['config', 'user.name'], { cwd: wtPath, check: false });
|
|
1538
|
+
const env = { ...process.env };
|
|
1539
|
+
if (email.status !== 0 || !email.stdout.trim()) {
|
|
1540
|
+
env.GIT_AUTHOR_EMAIL = '299057014+atris-builder[bot]@users.noreply.github.com';
|
|
1541
|
+
env.GIT_COMMITTER_EMAIL = env.GIT_AUTHOR_EMAIL;
|
|
1542
|
+
}
|
|
1543
|
+
if (name.status !== 0 || !name.stdout.trim()) {
|
|
1544
|
+
env.GIT_AUTHOR_NAME = 'atris worktree sweep';
|
|
1545
|
+
env.GIT_COMMITTER_NAME = env.GIT_AUTHOR_NAME;
|
|
1546
|
+
}
|
|
1547
|
+
return env;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Rescue of abandoned dirty copies. Off unless the sweep runs with --rescue;
|
|
1551
|
+
// the timer never passes it, and the default sweep only reports these copies.
|
|
1552
|
+
//
|
|
1553
|
+
// Guarantee: a dirty copy is removed only when every byte in it is proven
|
|
1554
|
+
// saved on origin or proven throwaway. The staged and on-disk versions are
|
|
1555
|
+
// committed onto a brand-new atris-rescue/<copy>-<time> branch, created only
|
|
1556
|
+
// if the name is free here and on origin, pushed, confirmed on origin, and
|
|
1557
|
+
// the copy is checked again before removal. No existing branch ever moves.
|
|
1558
|
+
// Anything unusual keeps the copy for a person, with the reason.
|
|
1559
|
+
//
|
|
1560
|
+
// Accepted limits, each of which keeps the copy instead of risking it:
|
|
1561
|
+
// - ignored files other than installs and caches beside their package file,
|
|
1562
|
+
// or atris's own plumbing
|
|
1563
|
+
// - nested repositories other than a submodule clean at a pushed commit
|
|
1564
|
+
// - index flags that hide edits, conflicts, sparse checkouts
|
|
1565
|
+
// - content filters and the ident attribute (on every removal path, clean
|
|
1566
|
+
// copies too), and for dirty copies line-ending or encoding conversion
|
|
1567
|
+
// - a push the remote or its hooks refuse
|
|
1568
|
+
// What it cannot see: a program that writes into the copy between the last
|
|
1569
|
+
// check and the removal without holding a file open or standing in it.
|
|
1570
|
+
//
|
|
1571
|
+
// Returns { kept } with a reason, { candidate } on a dry run, or { removed }.
|
|
1572
|
+
function rescueAbandonedCopy({ root, wt, wtPath, item, counts, apply, now, idleMs, rescueIdleMs, rescue = false, activeCwdsOverride, budget, by }) {
|
|
1573
|
+
const dirty = Boolean(counts && (counts.staged || counts.unstaged || counts.untracked));
|
|
1574
|
+
const baseReason = dirty ? 'dirty' : (wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged');
|
|
1575
|
+
const keep = (reason, extra = {}) => ({ kept: { ...item, reason, ...(dirty ? { staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked } : {}), ...extra } });
|
|
1576
|
+
const named = Boolean(wt.branch && wt.branch !== 'detached');
|
|
1577
|
+
if (runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false }).status !== 0) return keep(baseReason, { rescue: 'no origin to push to' });
|
|
1578
|
+
const keepers = ignoredFilesToKeep(wtPath);
|
|
1579
|
+
if (!keepers) return keep('ignored_scan_failed');
|
|
1580
|
+
if (keepers.length) return keep('ignored_data', { files: keepers.slice(0, 5) });
|
|
1581
|
+
const unprovable = byteChangingSetting(wtPath, { filtersOnly: !dirty }) || unprovableReason(wtPath);
|
|
1582
|
+
if (unprovable) return keep('unprovable', { why: unprovable });
|
|
1583
|
+
const headOid = String(runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false }).stdout || '').trim();
|
|
1584
|
+
if (!headOid) return keep('missing_head');
|
|
1585
|
+
const ownRemote = named ? remoteBranchOid(root, wt.branch) : '';
|
|
1586
|
+
if (ownRemote === null) return keep(baseReason, { rescue: 'origin unreachable' });
|
|
1587
|
+
const alreadySaved = !dirty && named && ownRemote === headOid;
|
|
1588
|
+
const idleFor = now - newestChangeMs(wtPath);
|
|
1589
|
+
const limit = alreadySaved ? idleMs : rescueIdleMs;
|
|
1590
|
+
const idleDays = Math.round((idleFor / DAY_MS) * 10) / 10;
|
|
1591
|
+
if (idleFor < limit) return keep(baseReason, { idle_days: idleDays });
|
|
1592
|
+
// Without --rescue the sweep only reports a stale copy that needs saving.
|
|
1593
|
+
if (!alreadySaved && !rescue) return keep(dirty ? 'stale_dirty' : 'stale_unpushed', { idle_days: idleDays, next: 'atris worktree sweep --rescue' });
|
|
1594
|
+
const kib = sizeKib(wtPath);
|
|
1595
|
+
if (!kib || budget.count >= 20 || budget.kib + kib > 10 * 1024 * 1024) return keep('size_unknown_or_cleanup_limit');
|
|
1596
|
+
const fileCount = dirty ? counts.staged + counts.unstaged + counts.untracked : 0;
|
|
1597
|
+
const rescueBranch = alreadySaved ? wt.branch
|
|
1598
|
+
: `atris-rescue/${slugify(path.basename(wtPath), 'copy', 60)}-${stamp(new Date(now))}-${Math.random().toString(16).slice(2, 6)}`;
|
|
1599
|
+
const candidate = {
|
|
1600
|
+
...item,
|
|
1601
|
+
reason: alreadySaved ? 'pushed_branch' : 'rescued_to_branch',
|
|
1602
|
+
rescue_branch: rescueBranch,
|
|
1603
|
+
changed_files: fileCount,
|
|
1604
|
+
idle_days: idleDays,
|
|
1605
|
+
size_kib: kib,
|
|
1606
|
+
};
|
|
1607
|
+
budget.count += 1;
|
|
1608
|
+
budget.kib += kib;
|
|
1609
|
+
if (!apply) return { candidate };
|
|
1610
|
+
|
|
1611
|
+
const headTree = String(runGit(['rev-parse', 'HEAD^{tree}'], { cwd: wtPath, check: false }).stdout || '').trim();
|
|
1612
|
+
const trees = dirty ? snapshotTrees(wtPath) : null;
|
|
1613
|
+
if (dirty && !trees) return keep(baseReason, { rescue: 'could not snapshot the changes' });
|
|
1614
|
+
let commit = headOid;
|
|
1615
|
+
if (dirty) {
|
|
1616
|
+
const trailer = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
|
|
1617
|
+
const note = `The sweep found ${path.basename(wtPath)} idle for ${idleDays} days with changes nobody committed. It saved them here before removing the copy. Nothing was reviewed or merged.`;
|
|
1618
|
+
const identity = commitIdentityEnv(wtPath);
|
|
1619
|
+
const commitTree = (tree, parent, title) => {
|
|
1620
|
+
const made = spawnSync('git', ['commit-tree', tree, '-p', parent, '-m', [title, '', note, '', trailer].join('\n')], {
|
|
1621
|
+
cwd: wtPath, encoding: 'utf8', env: identity, timeout: 30000, killSignal: 'SIGKILL',
|
|
1622
|
+
});
|
|
1623
|
+
return made.status === 0 ? made.stdout.trim() : '';
|
|
1624
|
+
};
|
|
1625
|
+
// Staged work first, then what was on disk on top, so neither version is lost.
|
|
1626
|
+
if (trees.staged !== headTree) {
|
|
1627
|
+
commit = commitTree(trees.staged, commit, 'Rescue staged work from an abandoned build copy');
|
|
1628
|
+
if (!commit) return keep(baseReason, { rescue: 'commit of the staged work failed' });
|
|
1629
|
+
}
|
|
1630
|
+
if (trees.disk !== trees.staged) {
|
|
1631
|
+
commit = commitTree(trees.disk, commit, `Rescue unsaved work from an abandoned build copy (${fileCount} changed files)`);
|
|
1632
|
+
if (!commit) return keep(baseReason, { rescue: 'commit of the work on disk failed' });
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (!alreadySaved) {
|
|
1636
|
+
// Fresh name only: refuse if origin or this repo already has it.
|
|
1637
|
+
const taken = remoteBranchOid(root, rescueBranch);
|
|
1638
|
+
if (taken !== '') return keep('rescue_push_failed', { rescue_branch: rescueBranch, error: taken === null ? 'origin unreachable' : 'rescue branch name already on origin' });
|
|
1639
|
+
const zero = '0'.repeat(commit.length);
|
|
1640
|
+
const created = runGit(['update-ref', `refs/heads/${rescueBranch}`, commit, zero], { cwd: root, check: false });
|
|
1641
|
+
if (created.status !== 0) return keep('rescue_push_failed', { rescue_branch: rescueBranch, error: `could not create the rescue branch: ${gitFailureDetail(created)}` });
|
|
1642
|
+
const pushed = runGit(['push', 'origin', `refs/heads/${rescueBranch}:refs/heads/${rescueBranch}`], { cwd: root, check: false });
|
|
1643
|
+
if (remoteBranchOid(root, rescueBranch) !== commit) {
|
|
1644
|
+
return keep('rescue_push_failed', { rescue_branch: rescueBranch, rescue_commit: commit, error: (gitFailureDetail(pushed) || 'origin does not show the pushed commit').slice(0, 300) });
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
const freshActive = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1648
|
+
const fresh = worktreeEntry(root, wtPath);
|
|
1649
|
+
const freshHead = String(runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false }).stdout || '').trim();
|
|
1650
|
+
const freshTrees = dirty ? snapshotTrees(wtPath) : null;
|
|
1651
|
+
const freshKeepers = ignoredFilesToKeep(wtPath);
|
|
1652
|
+
const freshCounts = statusCounts(wtPath);
|
|
1653
|
+
if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
|
|
1654
|
+
|| !fresh || fresh.locked || freshHead !== headOid
|
|
1655
|
+
|| (dirty && (!freshTrees || freshTrees.staged !== trees.staged || freshTrees.disk !== trees.disk))
|
|
1656
|
+
|| !freshKeepers || freshKeepers.length || unprovableReason(wtPath) || byteChangingSetting(wtPath, { filtersOnly: !dirty }) || !freshCounts
|
|
1657
|
+
|| Boolean(freshCounts.staged || freshCounts.unstaged || freshCounts.untracked) !== dirty) {
|
|
1658
|
+
return keep('changed_or_active_during_verification', { rescue_branch: rescueBranch, rescue_commit: commit });
|
|
1659
|
+
}
|
|
1660
|
+
const removeArgs = dirty ? ['worktree', 'remove', '--force', wtPath] : ['worktree', 'remove', wtPath];
|
|
1661
|
+
const removed = runGit(removeArgs, { cwd: root, check: false });
|
|
1662
|
+
if (removed.status !== 0) {
|
|
1663
|
+
return keep('remove_failed', { rescue_branch: rescueBranch, rescue_commit: commit, error: gitFailureDetail(removed) });
|
|
1664
|
+
}
|
|
1665
|
+
const originUrl = String(runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false }).stdout || '').trim();
|
|
1666
|
+
const rescueRecord = { branch: rescueBranch, commit, remote: originUrl, copy_branch: named ? wt.branch : null, changed_files: fileCount };
|
|
1667
|
+
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason, rescue: rescueRecord, size_kib: kib });
|
|
1668
|
+
return { removed: { ...candidate, rescue_commit: commit } };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function cleanupWorktrees({
|
|
1672
|
+
root = repoRoot(),
|
|
1673
|
+
base: baseOverride = '',
|
|
1674
|
+
apply = false,
|
|
1675
|
+
activeCwds: activeCwdsOverride,
|
|
1676
|
+
by = 'atris worktree cleanup',
|
|
1677
|
+
onlyUnder = '',
|
|
1678
|
+
onlyPaths = null,
|
|
1679
|
+
idleMs = 0,
|
|
1680
|
+
freshGraceMs = WORKTREE_REAP_GRACE_MS,
|
|
1681
|
+
now = Date.now(),
|
|
1682
|
+
cleanupBudget = null,
|
|
1683
|
+
rescueIdleMs = 0,
|
|
1684
|
+
rescue = false,
|
|
1685
|
+
} = {}) {
|
|
1686
|
+
const worktrees = listWorktrees(root);
|
|
1687
|
+
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1688
|
+
const current = path.resolve(root);
|
|
1689
|
+
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1690
|
+
const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
|
|
1691
|
+
const refreshError = refreshRemoteRef(root, base);
|
|
1692
|
+
const candidates = [];
|
|
1693
|
+
const kept = [];
|
|
1694
|
+
const removed = [];
|
|
1695
|
+
const cacheCandidates = [];
|
|
1696
|
+
const cachePruned = [];
|
|
1697
|
+
const rescued = [];
|
|
1698
|
+
const rescueCandidates = [];
|
|
1699
|
+
const cacheBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1700
|
+
const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
|
|
1701
|
+
const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
|
|
1702
|
+
&& (!onlyUnder || space.isInside(wtPath, onlyUnder));
|
|
1703
|
+
const recentlyUsed = (wtPath) => Boolean(idleMs) && now - lastCopyActivityMs(wtPath) < idleMs;
|
|
1704
|
+
const pruneCachesForKept = (wtPath) => {
|
|
1705
|
+
if (worktreeWithinReapGrace(wtPath, now)) return;
|
|
1706
|
+
if (recentlyUsed(wtPath)) return;
|
|
1707
|
+
const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
|
|
1708
|
+
cacheCandidates.push(...caches.candidates);
|
|
1709
|
+
cachePruned.push(...caches.pruned);
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
for (const wt of worktrees) {
|
|
1713
|
+
const wtPath = path.resolve(wt.path);
|
|
1714
|
+
const item = { path: wt.path, branch: wt.branch, head: wt.head };
|
|
1715
|
+
if (wtPath === primary) {
|
|
1716
|
+
kept.push({ ...item, reason: 'primary_checkout' });
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
if (wtPath === current) {
|
|
1720
|
+
kept.push({ ...item, reason: 'current_checkout' });
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
if (!inScope(wtPath)) {
|
|
1724
|
+
kept.push({ ...item, reason: 'out_of_scope' });
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (wt.locked) {
|
|
1728
|
+
kept.push({ ...item, reason: 'locked' });
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
if (PROTECTED_BRANCHES.has(wt.branch)) {
|
|
1732
|
+
kept.push({ ...item, reason: 'protected_branch' });
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
if (worktreeHasActiveProcess(wtPath, activeCwds)) {
|
|
1736
|
+
kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
const counts = statusCounts(wt.path);
|
|
1740
|
+
if (!counts) {
|
|
1741
|
+
kept.push({ ...item, reason: 'missing_or_unreadable' });
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
const tryRescue = (dirtyCounts) => {
|
|
1745
|
+
const outcome = rescueAbandonedCopy({
|
|
1746
|
+
root, wt, wtPath, item, counts: dirtyCounts, apply, now, idleMs, rescueIdleMs, rescue, activeCwdsOverride, budget: cacheBudget, by,
|
|
1747
|
+
});
|
|
1748
|
+
if (outcome.removed) {
|
|
1749
|
+
removed.push(outcome.removed);
|
|
1750
|
+
rescued.push(outcome.removed);
|
|
1751
|
+
} else if (outcome.candidate) {
|
|
1752
|
+
candidates.push(outcome.candidate);
|
|
1753
|
+
rescueCandidates.push(outcome.candidate);
|
|
1754
|
+
} else {
|
|
1755
|
+
if (outcome.kept.reason !== 'changed_or_active_during_verification') pruneCachesForKept(wtPath);
|
|
1756
|
+
kept.push(outcome.kept);
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
if (counts.staged || counts.unstaged || counts.untracked) {
|
|
1760
|
+
if (rescueIdleMs > 0) {
|
|
1761
|
+
tryRescue(counts);
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
pruneCachesForKept(wtPath);
|
|
1765
|
+
kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
const keepers = ignoredFilesToKeep(wt.path);
|
|
1769
|
+
if (!keepers) {
|
|
1770
|
+
kept.push({ ...item, reason: 'ignored_scan_failed' });
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
if (keepers.length) {
|
|
1774
|
+
pruneCachesForKept(wtPath);
|
|
1775
|
+
kept.push({ ...item, reason: 'ignored_data', files: keepers.slice(0, 5) });
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
const unprovable = byteChangingSetting(wt.path, { filtersOnly: true }) || unprovableReason(wt.path);
|
|
1779
|
+
if (unprovable) {
|
|
1780
|
+
pruneCachesForKept(wtPath);
|
|
1781
|
+
kept.push({ ...item, reason: 'unprovable', why: unprovable });
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
if (!wt.head) {
|
|
1785
|
+
pruneCachesForKept(wtPath);
|
|
1786
|
+
kept.push({ ...item, reason: 'missing_head' });
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
if (refreshError) {
|
|
1790
|
+
kept.push({ ...item, reason: 'base_refresh_failed' });
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
|
|
1794
|
+
if (!merged && rescueIdleMs > 0) {
|
|
1795
|
+
tryRescue(null);
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
if (!merged) {
|
|
1799
|
+
pruneCachesForKept(wtPath);
|
|
1800
|
+
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
if (freshGraceMs > 0 && worktreeWithinReapGrace(wtPath, now, freshGraceMs)) {
|
|
1804
|
+
kept.push({ ...item, reason: 'fresh_worktree_grace' });
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
if (recentlyUsed(wtPath)) {
|
|
1808
|
+
kept.push({ ...item, reason: 'recent_activity' });
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1811
|
+
if (!originBaseIsCurrent(root, base)) {
|
|
1812
|
+
kept.push({ ...item, reason: 'base_unverified' });
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
let real;
|
|
1816
|
+
try { real = fs.realpathSync(wtPath); } catch { real = ''; }
|
|
1817
|
+
if (!real || real !== wtPath) {
|
|
1818
|
+
kept.push({ ...item, reason: 'path_not_canonical' });
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
const measured = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1822
|
+
const sizeMatch = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
|
|
1823
|
+
const kib = sizeMatch ? Number(sizeMatch[1]) : 0;
|
|
1824
|
+
if (!kib || cacheBudget.count >= 20 || cacheBudget.kib + kib > 10 * 1024 * 1024) {
|
|
1825
|
+
kept.push({ ...item, reason: 'size_unknown_or_cleanup_limit' });
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
const candidate = { ...item, reason: 'merged_into_base', size_kib: kib };
|
|
1829
|
+
candidates.push(candidate);
|
|
1830
|
+
cacheBudget.count += 1;
|
|
1831
|
+
cacheBudget.kib += kib;
|
|
1832
|
+
if (!apply) continue;
|
|
1833
|
+
const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
|
|
1834
|
+
const freshCounts = statusCounts(wt.path);
|
|
1835
|
+
const freshKeepers = ignoredFilesToKeep(wt.path);
|
|
1836
|
+
const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1837
|
+
const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
|
|
1838
|
+
const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
|
|
1839
|
+
const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
|
|
1840
|
+
if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
|
|
1841
|
+
|| !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
|
|
1842
|
+
|| !freshKeepers || freshKeepers.length || unprovableReason(wt.path) || byteChangingSetting(wt.path, { filtersOnly: true }) || !freshMatch || Number(freshMatch[1]) !== kib
|
|
1843
|
+
|| gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
|
|
1844
|
+
|| freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
|
|
1845
|
+
|| runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
|
|
1846
|
+
|| !originBaseIsCurrent(root, base)
|
|
1847
|
+
|| canonicalPath(wtPath) !== wtPath) {
|
|
1848
|
+
kept.push({ ...item, reason: 'changed_or_active_during_verification' });
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
|
|
1852
|
+
if (removedResult.status === 0) {
|
|
1853
|
+
removed.push(candidate);
|
|
1854
|
+
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
1855
|
+
} else {
|
|
1856
|
+
kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned, rescued, rescueCandidates };
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
1864
|
+
const SWEEP_EVERY_HOURS = 3;
|
|
1865
|
+
const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
|
|
1866
|
+
|
|
1867
|
+
function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, rescueIdleMs = RESCUE_IDLE_MS, rescue = false, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
|
|
1868
|
+
const root = path.resolve(workspace);
|
|
1869
|
+
const before = freeSpace(root);
|
|
1870
|
+
const report = { workspace: root, apply, rescue, idle_ms: idleMs, rescue_idle_ms: rescueIdleMs, before, after: before, repos: [] };
|
|
1871
|
+
const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
|
|
1872
|
+
if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
|
|
1873
|
+
const scope = path.join(root, space.AGENT_COPIES_DIR);
|
|
1874
|
+
cleanupBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1875
|
+
for (const repo of space.agentCopyRepos(root)) {
|
|
1876
|
+
const entry = { name: repo.name, root: repo.root };
|
|
1877
|
+
try {
|
|
1878
|
+
const base = defaultMainlineBase(repo.root);
|
|
1879
|
+
if (!base.startsWith('origin/')) {
|
|
1880
|
+
report.repos.push({ ...entry, skipped: 'no origin main branch to compare against' });
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
|
|
1884
|
+
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, rescueIdleMs, rescue, cleanupBudget });
|
|
1885
|
+
report.repos.push({
|
|
1886
|
+
...entry,
|
|
1887
|
+
base: result.base,
|
|
1888
|
+
removed: result.removed,
|
|
1889
|
+
candidates: result.candidates,
|
|
1890
|
+
rescued: result.rescued,
|
|
1891
|
+
rescueCandidates: result.rescueCandidates,
|
|
1892
|
+
cachePruned: result.cachePruned,
|
|
1893
|
+
cacheCandidates: result.cacheCandidates,
|
|
1894
|
+
kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
|
|
1895
|
+
installsPruned: [],
|
|
1896
|
+
});
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
report.repos.push({ ...entry, error: String((error && error.message) || error) });
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
report.after = freeSpace(root);
|
|
1902
|
+
return report;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
const SWEEP_KEPT_LABELS = {
|
|
1906
|
+
active_process: 'in use',
|
|
1907
|
+
process_scan_failed: 'in use (could not check)',
|
|
1908
|
+
dirty: 'with changes',
|
|
1909
|
+
unmerged: 'not merged',
|
|
1910
|
+
detached_unmerged: 'not merged',
|
|
1911
|
+
ignored_data: 'holding ignored files that cannot be rebuilt (kept for review)',
|
|
1912
|
+
ignored_scan_failed: 'unreadable',
|
|
1913
|
+
fresh_worktree_grace: 'new',
|
|
1914
|
+
recent_activity: 'used recently',
|
|
1915
|
+
locked: 'locked',
|
|
1916
|
+
protected_branch: 'on a main branch',
|
|
1917
|
+
missing_or_unreadable: 'unreadable',
|
|
1918
|
+
missing_head: 'without a commit',
|
|
1919
|
+
remove_failed: 'failed to remove',
|
|
1920
|
+
base_refresh_failed: 'main branch unreachable',
|
|
1921
|
+
base_unverified: 'main branch moved during the check',
|
|
1922
|
+
path_not_canonical: 'behind a link',
|
|
1923
|
+
size_unknown_or_cleanup_limit: 'over the per-run limit',
|
|
1924
|
+
changed_or_active_during_verification: 'changed during the check',
|
|
1925
|
+
rescue_push_failed: 'push of its saved work failed',
|
|
1926
|
+
unprovable: 'holding work atris cannot prove it saved',
|
|
1927
|
+
stale_dirty: 'stale with unsaved changes (save with --rescue)',
|
|
1928
|
+
stale_unpushed: 'stale with commits not on origin (save with --rescue)',
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
function plural(count, one, many) {
|
|
1932
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function defaultSweepWorkspace(cwd = process.cwd()) {
|
|
1936
|
+
if (fs.existsSync(path.join(cwd, space.AGENT_COPIES_DIR))) return cwd;
|
|
1937
|
+
try {
|
|
1938
|
+
return path.dirname(findPrimaryRoot(repoRoot(cwd)));
|
|
1939
|
+
} catch {
|
|
1940
|
+
return cwd;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir = os.homedir(), platform = process.platform, atrisBin = ATRIS_BIN, envPath = process.env.PATH || '' }) {
|
|
1945
|
+
return planKeep({
|
|
1946
|
+
root: workspace,
|
|
1947
|
+
home: path.join(homeDir, '.atris', 'worktree-sweep'),
|
|
1948
|
+
platform,
|
|
1949
|
+
homeDir,
|
|
1950
|
+
atrisBin,
|
|
1951
|
+
envPath,
|
|
1952
|
+
job: {
|
|
1953
|
+
name: 'worktree-sweep',
|
|
1954
|
+
args: ['worktree', 'sweep', '--apply', '--workspace', workspace],
|
|
1955
|
+
every_seconds: Math.round(everyHours * 3600),
|
|
1956
|
+
marker: 'ATRIS_WORKTREE_SWEEP',
|
|
1957
|
+
},
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
module.exports = {
|
|
1962
|
+
ATRIS_BIN,
|
|
1963
|
+
reapPushedCopy,
|
|
1964
|
+
RESCUE_IDLE_MS,
|
|
1965
|
+
finishShippedCopy,
|
|
1966
|
+
formatKib,
|
|
1967
|
+
SWEEP_IDLE_MS,
|
|
1968
|
+
SWEEP_EVERY_HOURS,
|
|
1969
|
+
SWEEP_KEPT_LABELS,
|
|
1970
|
+
assertOneLapProofTree,
|
|
1971
|
+
baseBranchName,
|
|
1972
|
+
branchName,
|
|
1973
|
+
canonicalPath,
|
|
1974
|
+
changedFiles,
|
|
1975
|
+
cleanupWorktrees,
|
|
1976
|
+
collidingFlights,
|
|
1977
|
+
createAgentWorktree,
|
|
1978
|
+
createDetachedShipBranch,
|
|
1979
|
+
createOrFindPr,
|
|
1980
|
+
currentBranch,
|
|
1981
|
+
defaultMainlineBase,
|
|
1982
|
+
defaultShipTarget,
|
|
1983
|
+
defaultStartBase,
|
|
1984
|
+
defaultSweepWorkspace,
|
|
1985
|
+
deniedLaneForShip,
|
|
1986
|
+
describeFlightAge,
|
|
1987
|
+
detachedShipBranch,
|
|
1988
|
+
findPrimaryRoot,
|
|
1989
|
+
flightStampMs,
|
|
1990
|
+
gitFailureDetail,
|
|
1991
|
+
gitNetworkEnvironment,
|
|
1992
|
+
inFlightAgentFlights,
|
|
1993
|
+
listActiveProcessPaths,
|
|
1994
|
+
listWorktrees,
|
|
1995
|
+
lowDiskRefusal,
|
|
1996
|
+
mergePrWithRetry,
|
|
1997
|
+
newStatusRows,
|
|
1998
|
+
normalizeTargetRef,
|
|
1999
|
+
parseAgentFlightName,
|
|
2000
|
+
parseProcessArgPaths,
|
|
2001
|
+
parseWorktrees,
|
|
2002
|
+
plural,
|
|
2003
|
+
prMergeRef,
|
|
2004
|
+
protectedPushRefusal,
|
|
2005
|
+
readOneLapProofBinding,
|
|
2006
|
+
reapShippedCopy,
|
|
2007
|
+
recordWorktreeRemoval,
|
|
2008
|
+
refExists,
|
|
2009
|
+
refreshMovedMapRefs,
|
|
2010
|
+
refreshRemoteRef,
|
|
2011
|
+
remoteHead,
|
|
2012
|
+
repoRoot,
|
|
2013
|
+
restoreRegeneratedAdapterChurn,
|
|
2014
|
+
runCommand,
|
|
2015
|
+
runGit,
|
|
2016
|
+
runShipHealthCheck,
|
|
2017
|
+
scheduleShippedCopyRemoval,
|
|
2018
|
+
shellQuote,
|
|
2019
|
+
shipMergeRecord,
|
|
2020
|
+
shippedCopyRemovalPlan,
|
|
2021
|
+
slugify,
|
|
2022
|
+
stamp,
|
|
2023
|
+
statusCounts,
|
|
2024
|
+
statusSnapshot,
|
|
2025
|
+
swarloClaim,
|
|
2026
|
+
sweepSchedulePlan,
|
|
2027
|
+
sweepWorkspace,
|
|
2028
|
+
taskTokens,
|
|
2029
|
+
unstageConductorArtifacts,
|
|
2030
|
+
worktreeHasActiveProcess,
|
|
2031
|
+
worktreeWithinReapGrace,
|
|
2032
|
+
};
|