atris 3.82.1 → 3.82.3
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 +1 -1
- package/commands/mission.js +3 -1
- package/commands/security-review.js +215 -8
- package/commands/task.js +6 -19
- package/commands/worktree.js +40 -9
- package/lib/campaign-keep.js +1 -0
- package/lib/first-minute.js +41 -5
- package/lib/intents.js +7 -0
- package/lib/member-context.js +20 -1
- package/lib/next-work-context.js +190 -0
- package/lib/security-audit.js +191 -12
- package/lib/sentinel-cloud.js +558 -0
- package/lib/task-db.js +2 -1
- package/lib/worktree-core.js +530 -15
- package/package.json +1 -1
package/lib/worktree-core.js
CHANGED
|
@@ -52,7 +52,8 @@ function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
|
|
|
52
52
|
const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
|
|
53
53
|
// Only network calls need the SSH probe; local calls skip its extra git spawn.
|
|
54
54
|
const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
|
|
55
|
-
|
|
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,
|
|
56
57
|
killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
|
|
57
58
|
let result = spawnSync('git', args, options);
|
|
58
59
|
// Linked checkouts share config. A concurrent start must not silently lose
|
|
@@ -1092,14 +1093,14 @@ function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
|
1092
1093
|
});
|
|
1093
1094
|
}
|
|
1094
1095
|
|
|
1095
|
-
function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason }) {
|
|
1096
|
+
function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason, ...details }) {
|
|
1096
1097
|
try {
|
|
1097
1098
|
const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
|
|
1098
1099
|
const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
|
|
1099
1100
|
const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
|
|
1100
1101
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1101
1102
|
fs.appendFileSync(file, `${JSON.stringify({
|
|
1102
|
-
at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, pid: process.pid,
|
|
1103
|
+
at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, ...details, pid: process.pid,
|
|
1103
1104
|
})}\n`);
|
|
1104
1105
|
} catch {}
|
|
1105
1106
|
}
|
|
@@ -1195,6 +1196,478 @@ function originBaseIsCurrent(root, base) {
|
|
|
1195
1196
|
&& match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
|
|
1196
1197
|
}
|
|
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
|
+
|
|
1198
1671
|
function cleanupWorktrees({
|
|
1199
1672
|
root = repoRoot(),
|
|
1200
1673
|
base: baseOverride = '',
|
|
@@ -1207,6 +1680,8 @@ function cleanupWorktrees({
|
|
|
1207
1680
|
freshGraceMs = WORKTREE_REAP_GRACE_MS,
|
|
1208
1681
|
now = Date.now(),
|
|
1209
1682
|
cleanupBudget = null,
|
|
1683
|
+
rescueIdleMs = 0,
|
|
1684
|
+
rescue = false,
|
|
1210
1685
|
} = {}) {
|
|
1211
1686
|
const worktrees = listWorktrees(root);
|
|
1212
1687
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
@@ -1219,6 +1694,8 @@ function cleanupWorktrees({
|
|
|
1219
1694
|
const removed = [];
|
|
1220
1695
|
const cacheCandidates = [];
|
|
1221
1696
|
const cachePruned = [];
|
|
1697
|
+
const rescued = [];
|
|
1698
|
+
const rescueCandidates = [];
|
|
1222
1699
|
const cacheBudget = cleanupBudget || { count: 0, kib: 0 };
|
|
1223
1700
|
const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
|
|
1224
1701
|
const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
|
|
@@ -1264,19 +1741,44 @@ function cleanupWorktrees({
|
|
|
1264
1741
|
kept.push({ ...item, reason: 'missing_or_unreadable' });
|
|
1265
1742
|
continue;
|
|
1266
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
|
+
};
|
|
1267
1759
|
if (counts.staged || counts.unstaged || counts.untracked) {
|
|
1760
|
+
if (rescueIdleMs > 0) {
|
|
1761
|
+
tryRescue(counts);
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1268
1764
|
pruneCachesForKept(wtPath);
|
|
1269
1765
|
kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
|
|
1270
1766
|
continue;
|
|
1271
1767
|
}
|
|
1272
|
-
const
|
|
1273
|
-
if (
|
|
1768
|
+
const keepers = ignoredFilesToKeep(wt.path);
|
|
1769
|
+
if (!keepers) {
|
|
1274
1770
|
kept.push({ ...item, reason: 'ignored_scan_failed' });
|
|
1275
1771
|
continue;
|
|
1276
1772
|
}
|
|
1277
|
-
if (
|
|
1773
|
+
if (keepers.length) {
|
|
1278
1774
|
pruneCachesForKept(wtPath);
|
|
1279
|
-
kept.push({ ...item, reason: 'ignored_data' });
|
|
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 });
|
|
1280
1782
|
continue;
|
|
1281
1783
|
}
|
|
1282
1784
|
if (!wt.head) {
|
|
@@ -1289,6 +1791,10 @@ function cleanupWorktrees({
|
|
|
1289
1791
|
continue;
|
|
1290
1792
|
}
|
|
1291
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
|
+
}
|
|
1292
1798
|
if (!merged) {
|
|
1293
1799
|
pruneCachesForKept(wtPath);
|
|
1294
1800
|
kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
|
|
@@ -1326,14 +1832,14 @@ function cleanupWorktrees({
|
|
|
1326
1832
|
if (!apply) continue;
|
|
1327
1833
|
const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
|
|
1328
1834
|
const freshCounts = statusCounts(wt.path);
|
|
1329
|
-
const
|
|
1835
|
+
const freshKeepers = ignoredFilesToKeep(wt.path);
|
|
1330
1836
|
const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
|
|
1331
1837
|
const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
|
|
1332
1838
|
const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
|
|
1333
1839
|
const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
|
|
1334
1840
|
if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
|
|
1335
1841
|
|| !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
|
|
1336
|
-
||
|
|
1842
|
+
|| !freshKeepers || freshKeepers.length || unprovableReason(wt.path) || byteChangingSetting(wt.path, { filtersOnly: true }) || !freshMatch || Number(freshMatch[1]) !== kib
|
|
1337
1843
|
|| gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
|
|
1338
1844
|
|| freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
|
|
1339
1845
|
|| runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
|
|
@@ -1351,17 +1857,17 @@ function cleanupWorktrees({
|
|
|
1351
1857
|
}
|
|
1352
1858
|
}
|
|
1353
1859
|
|
|
1354
|
-
return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
|
|
1860
|
+
return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned, rescued, rescueCandidates };
|
|
1355
1861
|
}
|
|
1356
1862
|
|
|
1357
1863
|
const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
1358
1864
|
const SWEEP_EVERY_HOURS = 3;
|
|
1359
1865
|
const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
|
|
1360
1866
|
|
|
1361
|
-
function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
|
|
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 } = {}) {
|
|
1362
1868
|
const root = path.resolve(workspace);
|
|
1363
1869
|
const before = freeSpace(root);
|
|
1364
|
-
const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
|
|
1870
|
+
const report = { workspace: root, apply, rescue, idle_ms: idleMs, rescue_idle_ms: rescueIdleMs, before, after: before, repos: [] };
|
|
1365
1871
|
const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
|
|
1366
1872
|
if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
|
|
1367
1873
|
const scope = path.join(root, space.AGENT_COPIES_DIR);
|
|
@@ -1375,12 +1881,14 @@ function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, acti
|
|
|
1375
1881
|
continue;
|
|
1376
1882
|
}
|
|
1377
1883
|
const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
|
|
1378
|
-
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, cleanupBudget });
|
|
1884
|
+
const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, rescueIdleMs, rescue, cleanupBudget });
|
|
1379
1885
|
report.repos.push({
|
|
1380
1886
|
...entry,
|
|
1381
1887
|
base: result.base,
|
|
1382
1888
|
removed: result.removed,
|
|
1383
1889
|
candidates: result.candidates,
|
|
1890
|
+
rescued: result.rescued,
|
|
1891
|
+
rescueCandidates: result.rescueCandidates,
|
|
1384
1892
|
cachePruned: result.cachePruned,
|
|
1385
1893
|
cacheCandidates: result.cacheCandidates,
|
|
1386
1894
|
kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
|
|
@@ -1400,7 +1908,7 @@ const SWEEP_KEPT_LABELS = {
|
|
|
1400
1908
|
dirty: 'with changes',
|
|
1401
1909
|
unmerged: 'not merged',
|
|
1402
1910
|
detached_unmerged: 'not merged',
|
|
1403
|
-
ignored_data: 'holding files
|
|
1911
|
+
ignored_data: 'holding ignored files that cannot be rebuilt (kept for review)',
|
|
1404
1912
|
ignored_scan_failed: 'unreadable',
|
|
1405
1913
|
fresh_worktree_grace: 'new',
|
|
1406
1914
|
recent_activity: 'used recently',
|
|
@@ -1414,6 +1922,10 @@ const SWEEP_KEPT_LABELS = {
|
|
|
1414
1922
|
path_not_canonical: 'behind a link',
|
|
1415
1923
|
size_unknown_or_cleanup_limit: 'over the per-run limit',
|
|
1416
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)',
|
|
1417
1929
|
};
|
|
1418
1930
|
|
|
1419
1931
|
function plural(count, one, many) {
|
|
@@ -1448,10 +1960,13 @@ function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir
|
|
|
1448
1960
|
|
|
1449
1961
|
module.exports = {
|
|
1450
1962
|
ATRIS_BIN,
|
|
1963
|
+
reapPushedCopy,
|
|
1964
|
+
RESCUE_IDLE_MS,
|
|
1965
|
+
finishShippedCopy,
|
|
1966
|
+
formatKib,
|
|
1451
1967
|
SWEEP_IDLE_MS,
|
|
1452
1968
|
SWEEP_EVERY_HOURS,
|
|
1453
1969
|
SWEEP_KEPT_LABELS,
|
|
1454
|
-
announceShippedCopyRemoval,
|
|
1455
1970
|
assertOneLapProofTree,
|
|
1456
1971
|
baseBranchName,
|
|
1457
1972
|
branchName,
|