atris 3.68.8 → 3.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/atris/team/opus-overnight/MEMBER.md +1 -0
- package/commands/init.js +9 -0
- package/commands/member.js +359 -32
- package/commands/mission.js +77 -9
- package/commands/sync.js +54 -3
- package/lib/auto-accept-certified.js +1 -0
- package/lib/engine-registry.js +5 -4
- package/lib/gitignore.js +56 -0
- package/lib/member-autoland.js +350 -0
- package/lib/member-board.js +181 -23
- package/lib/member-progress.js +68 -4
- package/lib/member-task-pick.js +116 -0
- package/lib/member-tidy.js +235 -0
- package/lib/member-up.js +286 -0
- package/lib/member-verbs.js +84 -0
- package/lib/runner-command.js +29 -0
- package/lib/task-db.js +5 -1
- package/package.json +1 -1
package/lib/member-progress.js
CHANGED
|
@@ -50,6 +50,45 @@ function formatToolStep(block, cwd) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Codex wraps each command in a login shell ("/bin/zsh -lc 'grep -n x a.txt'");
|
|
54
|
+
// the line shows only what ran inside it.
|
|
55
|
+
function unwrapShellCommand(command) {
|
|
56
|
+
const text = String(command || '').trim();
|
|
57
|
+
const match = text.match(/^(?:\S*\/)?(?:ba|z)?sh\s+-l?c\s+(['"])([\s\S]*)\1$/);
|
|
58
|
+
return match ? match[2] : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function searchPattern(command) {
|
|
62
|
+
const words = command.split(/\s+/).slice(1);
|
|
63
|
+
const word = words.find((w) => w && !w.startsWith('-')) || '';
|
|
64
|
+
return word.replace(/^(['"])(.*)\1$/, '$2');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One plain line for a codex `exec --json` event, or null when the event is
|
|
68
|
+
// not a step (messages, reasoning, turn bookkeeping). Commands show when they
|
|
69
|
+
// start; file edits only arrive once done.
|
|
70
|
+
function formatCodexEvent(event, cwd) {
|
|
71
|
+
if (!event || (event.type !== 'item.started' && event.type !== 'item.completed')) return null;
|
|
72
|
+
const item = event.item || {};
|
|
73
|
+
switch (item.type) {
|
|
74
|
+
case 'command_execution': {
|
|
75
|
+
const first = unwrapShellCommand(item.command).split('\n')[0].trim();
|
|
76
|
+
if (/^(rg|grep)\s/.test(first)) return `searching for ${searchPattern(first)}`;
|
|
77
|
+
return `running: ${first.slice(0, 70)}`;
|
|
78
|
+
}
|
|
79
|
+
case 'file_change': {
|
|
80
|
+
const files = (Array.isArray(item.changes) ? item.changes : [])
|
|
81
|
+
.map((change) => relativeTo(cwd, change && change.path))
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
return files.length ? `editing ${files.join(', ')}` : null;
|
|
84
|
+
}
|
|
85
|
+
case 'web_search':
|
|
86
|
+
return `searching for ${item.query || ''}`;
|
|
87
|
+
default:
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
function progressDir() {
|
|
54
93
|
return process.env.ATRIS_PROGRESS_DIR || path.join(os.homedir(), '.atris', 'progress');
|
|
55
94
|
}
|
|
@@ -62,13 +101,38 @@ function hhmm(date = new Date()) {
|
|
|
62
101
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
63
102
|
}
|
|
64
103
|
|
|
104
|
+
// A started line can name the run's process and engine up front:
|
|
105
|
+
// "started: [pid 4242 engine claude] <objective>". The board reads the pid to
|
|
106
|
+
// tell a live run from a dead one, and `atris member stop` kills its group.
|
|
107
|
+
const RUN_TAG = /^\[pid (\d+)(?: engine (\S+))?(?: run ([0-9a-f]{16}))?\]\s*/;
|
|
108
|
+
|
|
109
|
+
function startedLine(objective, { pid, engine, run } = {}) {
|
|
110
|
+
const runPart = /^[0-9a-f]{16}$/.test(String(run || '')) ? ` run ${run}` : '';
|
|
111
|
+
const tag = Number.isInteger(pid) && pid > 0 ? `[pid ${pid}${engine ? ` engine ${engine}` : ''}${runPart}] ` : '';
|
|
112
|
+
return `started: ${tag}${objective || ''}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A one-time token for one engine run. It goes on the first line of the
|
|
116
|
+
// engine's prompt (visible in its command line) and in the started line, so
|
|
117
|
+
// member stop can prove a live process is that member's run.
|
|
118
|
+
function newRunToken() {
|
|
119
|
+
return require('crypto').randomBytes(8).toString('hex');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runMarker(member, token) {
|
|
123
|
+
return `atris-run ${member} ${token}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
65
126
|
// One progress line stays one short line: a 'started:' objective keeps its
|
|
66
|
-
// first line and at most 80 chars
|
|
127
|
+
// first line and at most 80 chars (the run tag does not count); any other
|
|
128
|
+
// line caps at 100.
|
|
67
129
|
function shortenLine(line) {
|
|
68
130
|
const first = String(line || '').split('\n')[0];
|
|
69
131
|
if (first.startsWith('started: ')) {
|
|
70
|
-
const
|
|
71
|
-
|
|
132
|
+
const body = first.slice('started: '.length);
|
|
133
|
+
const tag = (RUN_TAG.exec(body) || [''])[0];
|
|
134
|
+
const objective = body.slice(tag.length);
|
|
135
|
+
return `started: ${tag}${objective.length > 80 ? `${objective.slice(0, 80)}...` : objective}`;
|
|
72
136
|
}
|
|
73
137
|
return first.length > 100 ? `${first.slice(0, 100)}...` : first;
|
|
74
138
|
}
|
|
@@ -91,4 +155,4 @@ function reportProgress(member, line, missionId) {
|
|
|
91
155
|
appendProgress(member, short, missionId);
|
|
92
156
|
}
|
|
93
157
|
|
|
94
|
-
module.exports = { formatToolStep, appendProgress, reportProgress, shortenLine };
|
|
158
|
+
module.exports = { formatToolStep, formatCodexEvent, appendProgress, reportProgress, shortenLine, startedLine, RUN_TAG, newRunToken, runMarker };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The next real job for a member with no idea given: the oldest open,
|
|
4
|
+
// unclaimed task in this workspace whose job (build, review, search) matches
|
|
5
|
+
// the member's, held neither for a human nor in a protected lane. Callers
|
|
6
|
+
// fall back to the member's own goal when this returns null, unless they
|
|
7
|
+
// ask for requireCheck: then only tasks with a runnable check count, and
|
|
8
|
+
// nothing found means the member stays idle.
|
|
9
|
+
|
|
10
|
+
const { autoJobForMember } = require('./member-engine');
|
|
11
|
+
const { deniedTagOf, declaredProtectedLane, sniffedProtectedLane, isAutoCertifyVerifyCommandAllowed } = require('./auto-accept-certified');
|
|
12
|
+
const { isDecisionHoldTag, isDecisionTask, taskTagTokens } = require('./task-decision');
|
|
13
|
+
|
|
14
|
+
// A task's job uses the member rule with its tags in the name slot and its
|
|
15
|
+
// title in the role slot: tags decide first, then the title, else build.
|
|
16
|
+
function jobForTask(task) {
|
|
17
|
+
return autoJobForMember(taskTagTokens(task).join(' '), task && task.title);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function taskBody(task) {
|
|
21
|
+
const meta = (task && task.metadata) || {};
|
|
22
|
+
return ['objective', 'body', 'description', 'details']
|
|
23
|
+
.map((key) => (typeof meta[key] === 'string' ? meta[key].trim() : ''))
|
|
24
|
+
.find(Boolean) || '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The objective a member run gets: the title, then the body when there is one,
|
|
28
|
+
// on one line. Mission start refuses objectives with line breaks.
|
|
29
|
+
function taskObjective(task) {
|
|
30
|
+
const oneLine = (text) => String(text || '').replace(/\s+/g, ' ').trim();
|
|
31
|
+
const title = oneLine(task && task.title);
|
|
32
|
+
const body = oneLine(taskBody(task));
|
|
33
|
+
if (!body) return title;
|
|
34
|
+
return /[.!?]$/.test(title) ? `${title} ${body}` : `${title}. ${body}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Held for a person: a denied tag on the row or in its metadata, a protected
|
|
38
|
+
// lane declared anywhere in its text, or lane terms in its title or body.
|
|
39
|
+
// Tags written in metadata, which the shared tag parser does not read.
|
|
40
|
+
function metadataTags(task) {
|
|
41
|
+
const meta = (task && task.metadata) || {};
|
|
42
|
+
return [meta.tag, ...(Array.isArray(meta.tags) ? meta.tags : [])]
|
|
43
|
+
.filter((t) => typeof t === 'string' && t.trim())
|
|
44
|
+
.map((t) => t.trim().toLowerCase().replace(/^#/, ''));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function protectedTask(task) {
|
|
48
|
+
const meta = (task && task.metadata) || {};
|
|
49
|
+
const tags = [...metadataTags(task), ...taskTagTokens(task)];
|
|
50
|
+
if (deniedTagOf(task) || tags.some((tag) => deniedTagOf({ tag }))) return true;
|
|
51
|
+
// Every text field, not just the one the objective uses.
|
|
52
|
+
const allText = ['objective', 'body', 'description', 'details']
|
|
53
|
+
.map((key) => (typeof meta[key] === 'string' ? meta[key] : ''))
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join('\n');
|
|
56
|
+
const probe = { title: task.title, objective: allText, metadata: meta };
|
|
57
|
+
return Boolean(declaredProtectedLane(probe) || sniffedProtectedLane(probe));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The check a task requires, when it names one. A check written as prose
|
|
61
|
+
// that cannot be run means the task cannot be proven here.
|
|
62
|
+
function taskCheck(task) {
|
|
63
|
+
const verify = task && task.metadata && typeof task.metadata.verify === 'string'
|
|
64
|
+
? task.metadata.verify.trim() : '';
|
|
65
|
+
if (!verify) return { command: '', runnable: true };
|
|
66
|
+
return { command: verify, runnable: isAutoCertifyVerifyCommandAllowed(verify) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function skipReason(task, job, { requireCheck = false } = {}) {
|
|
70
|
+
if (!task || task.status !== 'open') return 'not_open';
|
|
71
|
+
if (task.claimed_by) return 'claimed';
|
|
72
|
+
if (isDecisionTask(task) || metadataTags(task).some(isDecisionHoldTag)) return 'needs_human';
|
|
73
|
+
if (protectedTask(task)) return 'protected_lane';
|
|
74
|
+
const check = taskCheck(task);
|
|
75
|
+
if (!check.runnable) return 'check_not_runnable';
|
|
76
|
+
if (requireCheck && !check.command) return 'no_check';
|
|
77
|
+
if (jobForTask(task) !== job) return 'other_job';
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
|
|
82
|
+
return taskDb.listTasks(db, { workspaceRoot, status: 'open' })
|
|
83
|
+
.filter((task) => !skipReason(task, job, { requireCheck }))
|
|
84
|
+
.sort((a, b) => (a.created_at - b.created_at) || String(a.id).localeCompare(String(b.id)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pickTaskForMember(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
|
|
88
|
+
return fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })[0] || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Claim the oldest fitting task for the member. A lost race moves on to the
|
|
92
|
+
// next candidate; null when nothing fits or every claim lost.
|
|
93
|
+
function claimTaskForMember(taskDb, db, { workspaceRoot, member, job, requireCheck = false }) {
|
|
94
|
+
for (const task of fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })) {
|
|
95
|
+
let result;
|
|
96
|
+
try {
|
|
97
|
+
result = taskDb.claimTask(db, { id: task.id, claimedBy: member });
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A claim that half-wrote must not strand the task.
|
|
100
|
+
try { taskDb.releaseTask(db, { id: task.id, actor: member }); } catch {}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (result.claimed) return { task: result.row, objective: taskObjective(result.row) };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
taskCheck,
|
|
110
|
+
claimTaskForMember,
|
|
111
|
+
fittingTasks,
|
|
112
|
+
jobForTask,
|
|
113
|
+
pickTaskForMember,
|
|
114
|
+
skipReason,
|
|
115
|
+
taskObjective,
|
|
116
|
+
};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `atris member tidy`: put away member copies of the repo (git worktrees under
|
|
4
|
+
// <arena>/.agent-worktrees/<repo>) whose work already landed. There is no
|
|
5
|
+
// deletion logic here on purpose. git does the removing with
|
|
6
|
+
// `git worktree remove` (never --force), which refuses modified or untracked
|
|
7
|
+
// files, then `git branch -d` (never -D). Everything this file decides is
|
|
8
|
+
// about which copies to leave alone:
|
|
9
|
+
// - work not landed: branch tip is not an ancestor of origin's default
|
|
10
|
+
// branch tip, asked of origin itself on every run. Listed with its age, never touched.
|
|
11
|
+
// - any git error while deciding: skipped.
|
|
12
|
+
// - a live process inside the copy (lsof). If the scan itself fails, every
|
|
13
|
+
// copy is skipped.
|
|
14
|
+
// - a copy whose real path is outside the real worktrees folder (a symlink).
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
function git(args, cwd) {
|
|
21
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60000 });
|
|
22
|
+
return {
|
|
23
|
+
ok: result.status === 0,
|
|
24
|
+
status: result.status,
|
|
25
|
+
stdout: String(result.stdout || '').trim(),
|
|
26
|
+
stderr: String(result.stderr || result.error?.message || '').trim(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isUnder(child, parent) {
|
|
31
|
+
const rel = path.relative(parent, child);
|
|
32
|
+
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function firstLine(text) {
|
|
36
|
+
return String(text || '').split('\n').map((line) => line.trim()).find(Boolean) || 'no reason given';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatAge(ms) {
|
|
40
|
+
const minutes = Math.max(0, Math.floor(ms / 60000));
|
|
41
|
+
if (minutes < 60) return `${minutes}m old`;
|
|
42
|
+
const hours = Math.floor(minutes / 60);
|
|
43
|
+
if (hours < 48) return `${hours}h old`;
|
|
44
|
+
return `${Math.floor(hours / 24)}d old`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mainRepoRoot(cwd) {
|
|
48
|
+
const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
49
|
+
if (!common.ok) return null;
|
|
50
|
+
return path.basename(common.stdout) === '.git' ? path.dirname(common.stdout) : common.stdout;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultWorktreesDir(repoRoot) {
|
|
54
|
+
return path.join(path.dirname(repoRoot), '.agent-worktrees', path.basename(repoRoot));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseWorktreeList(text) {
|
|
58
|
+
const entries = [];
|
|
59
|
+
let current = null;
|
|
60
|
+
for (const line of String(text || '').split('\n')) {
|
|
61
|
+
if (line.startsWith('worktree ')) {
|
|
62
|
+
current = { path: line.slice('worktree '.length), branch: null };
|
|
63
|
+
entries.push(current);
|
|
64
|
+
} else if (current && line.startsWith('branch refs/heads/')) {
|
|
65
|
+
current.branch = line.slice('branch refs/heads/'.length);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ask origin itself which branch it ships from and where that branch is right
|
|
72
|
+
// now. The local origin/HEAD note goes stale when the default branch changes,
|
|
73
|
+
// so it is never trusted. Returns the exact commit to compare against.
|
|
74
|
+
function originDefaultTip(repoRoot) {
|
|
75
|
+
const remote = git(['ls-remote', '--symref', 'origin', 'HEAD'], repoRoot);
|
|
76
|
+
if (!remote.ok) return { ok: false, reason: `could not ask origin for its main branch: ${firstLine(remote.stderr)}` };
|
|
77
|
+
const branch = remote.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m);
|
|
78
|
+
if (!branch) return { ok: false, reason: 'could not tell which branch origin ships from' };
|
|
79
|
+
const fetched = git(['fetch', '--quiet', 'origin', `refs/heads/${branch[1]}`], repoRoot);
|
|
80
|
+
if (!fetched.ok) return { ok: false, reason: `could not fetch origin: ${firstLine(fetched.stderr)}` };
|
|
81
|
+
// Judge against what was actually fetched, not what origin advertised a
|
|
82
|
+
// moment earlier: the branch can move in between.
|
|
83
|
+
const tip = git(['rev-parse', '--verify', 'FETCH_HEAD^{commit}'], repoRoot);
|
|
84
|
+
if (!tip.ok || !/^[0-9a-f]{40,64}$/.test(tip.stdout.trim())) return { ok: false, reason: 'could not read the fetched main branch' };
|
|
85
|
+
return { ok: true, branch: branch[1], tip: tip.stdout.trim() };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Every path any process has open (cwd included). Any failure means we cannot
|
|
89
|
+
// tell what is busy, so the caller skips all copies.
|
|
90
|
+
function scanOpenPaths(lsofBin = 'lsof') {
|
|
91
|
+
const result = spawnSync(lsofBin, ['-n', '-P', '-w', '-F', 'n'], {
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
timeout: 60000,
|
|
94
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
95
|
+
});
|
|
96
|
+
if (result.error || result.status !== 0) {
|
|
97
|
+
return { ok: false, reason: firstLine(result.error?.message || result.stderr || `lsof exited ${result.status}`) };
|
|
98
|
+
}
|
|
99
|
+
const paths = [];
|
|
100
|
+
for (const line of String(result.stdout || '').split('\n')) {
|
|
101
|
+
if (line.startsWith('n/')) paths.push(line.slice(1));
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, paths };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasLiveProcess(realCopy, openPaths) {
|
|
107
|
+
return openPaths.some((p) => p === realCopy || p.startsWith(realCopy + path.sep));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tidyMemberCopies(options = {}) {
|
|
111
|
+
const now = options.now || Date.now();
|
|
112
|
+
const dryRun = Boolean(options.dryRun);
|
|
113
|
+
const repoRoot = options.repoRoot || mainRepoRoot(options.cwd || process.cwd());
|
|
114
|
+
const result = { dryRun, worktreesDir: null, copies: [], removed: 0, kept: 0, aborted: null };
|
|
115
|
+
if (!repoRoot) {
|
|
116
|
+
result.aborted = 'not inside a git repo';
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
const worktreesDir = path.resolve(options.worktreesDir || defaultWorktreesDir(repoRoot));
|
|
120
|
+
result.worktreesDir = worktreesDir;
|
|
121
|
+
let realDir;
|
|
122
|
+
try {
|
|
123
|
+
realDir = fs.realpathSync(worktreesDir);
|
|
124
|
+
} catch {
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const listed = git(['worktree', 'list', '--porcelain'], repoRoot);
|
|
129
|
+
if (!listed.ok) {
|
|
130
|
+
result.aborted = `git could not list copies: ${firstLine(listed.stderr)}`;
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
const copies = parseWorktreeList(listed.stdout)
|
|
134
|
+
.filter((entry) => isUnder(entry.path, worktreesDir) || isUnder(entry.path, realDir));
|
|
135
|
+
|
|
136
|
+
const keep = (copy, reason) => {
|
|
137
|
+
result.copies.push({ ...copy, action: 'kept', reason });
|
|
138
|
+
result.kept += 1;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const base = originDefaultTip(repoRoot);
|
|
142
|
+
const scan = scanOpenPaths(options.lsofBin);
|
|
143
|
+
let abortReason = null;
|
|
144
|
+
if (!base.ok) abortReason = base.reason;
|
|
145
|
+
else if (!scan.ok) abortReason = `could not check for running programs: ${scan.reason}`;
|
|
146
|
+
if (abortReason) result.aborted = abortReason;
|
|
147
|
+
|
|
148
|
+
const selfReal = (() => {
|
|
149
|
+
try { return fs.realpathSync(options.cwd || process.cwd()); } catch { return null; }
|
|
150
|
+
})();
|
|
151
|
+
|
|
152
|
+
for (const entry of copies) {
|
|
153
|
+
const copy = { name: path.basename(entry.path), path: entry.path, branch: entry.branch };
|
|
154
|
+
let realCopy;
|
|
155
|
+
try {
|
|
156
|
+
realCopy = fs.realpathSync(entry.path);
|
|
157
|
+
} catch {
|
|
158
|
+
keep(copy, 'folder is missing');
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!isUnder(realCopy, realDir)) {
|
|
162
|
+
keep(copy, 'points outside the copies folder, left alone');
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (abortReason) {
|
|
166
|
+
keep(copy, abortReason);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!entry.branch) {
|
|
170
|
+
keep(copy, 'not on a branch');
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const ancestor = git(['merge-base', '--is-ancestor', `refs/heads/${entry.branch}`, base.tip], repoRoot);
|
|
174
|
+
if (ancestor.status === 1) {
|
|
175
|
+
let age = '';
|
|
176
|
+
try {
|
|
177
|
+
const stat = fs.statSync(realCopy);
|
|
178
|
+
age = `, ${formatAge(now - (stat.birthtimeMs || stat.mtimeMs))}`;
|
|
179
|
+
} catch {}
|
|
180
|
+
keep(copy, `work not landed yet${age}`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!ancestor.ok) {
|
|
184
|
+
keep(copy, `git could not tell if it landed: ${firstLine(ancestor.stderr)}`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if ((selfReal && (selfReal === realCopy || isUnder(selfReal, realCopy)))
|
|
188
|
+
|| hasLiveProcess(realCopy, scan.paths)) {
|
|
189
|
+
keep(copy, 'landed, but something is still running inside it');
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (dryRun) {
|
|
193
|
+
result.copies.push({ ...copy, action: 'would remove', reason: 'work landed' });
|
|
194
|
+
result.removed += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const removed = git(['worktree', 'remove', entry.path], repoRoot);
|
|
198
|
+
if (!removed.ok) {
|
|
199
|
+
keep(copy, `landed, but git refused: ${firstLine(removed.stderr)}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const branchGone = git(['branch', '-d', entry.branch], repoRoot);
|
|
203
|
+
result.copies.push({
|
|
204
|
+
...copy,
|
|
205
|
+
action: 'removed',
|
|
206
|
+
reason: branchGone.ok ? 'work landed, branch deleted' : `work landed, branch kept: ${firstLine(branchGone.stderr)}`,
|
|
207
|
+
});
|
|
208
|
+
result.removed += 1;
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function formatTidy(result) {
|
|
214
|
+
const lines = [];
|
|
215
|
+
if (!result.worktreesDir) {
|
|
216
|
+
lines.push(`tidy: ${result.aborted || 'nothing to do'}`);
|
|
217
|
+
return lines.join('\n');
|
|
218
|
+
}
|
|
219
|
+
if (result.aborted) lines.push(`tidy: ${result.aborted}, so every copy is kept`);
|
|
220
|
+
const width = Math.max(0, ...result.copies.map((copy) => copy.action.length));
|
|
221
|
+
for (const copy of result.copies) {
|
|
222
|
+
lines.push(`${copy.action.padEnd(width)} ${copy.name} ${copy.reason}`);
|
|
223
|
+
}
|
|
224
|
+
const total = result.copies.length;
|
|
225
|
+
const verb = result.dryRun ? 'would remove' : 'removed';
|
|
226
|
+
lines.push(total
|
|
227
|
+
? `tidy: ${verb} ${result.removed}, kept ${result.kept}, of ${total} ${total === 1 ? 'copy' : 'copies'} in ${result.worktreesDir}`
|
|
228
|
+
: `tidy: no member copies in ${result.worktreesDir}`);
|
|
229
|
+
return lines.join('\n');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
tidyMemberCopies,
|
|
234
|
+
formatTidy,
|
|
235
|
+
};
|