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.
@@ -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
- const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout,
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 ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
1273
- if (ignored.status !== 0 || ignored.error) {
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 (ignored.stdout) {
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 freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
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
- || freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
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 git ignores (kept for review)',
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,