atris 3.70.0 → 3.71.1
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 +18 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +38 -8
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +779 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/integrations.js +17 -5
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +359 -94
- package/commands/mission.js +249 -36
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +71 -17
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +108 -15
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/account-bound.js +13 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +246 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-run-lock.js +59 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +33 -3
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +395 -18
- package/lib/member-board.js +7 -1
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +127 -8
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +47 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/lib/member-autoland.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// GitHub auto-merge. Every local gate fails closed: the branch stays put and
|
|
5
5
|
// one plain line says which gate held it. GitHub then waits for the branch to
|
|
6
6
|
// be up to date and CI green; this never merges, rebases, or polls itself.
|
|
7
|
+
// The one exception is a project with no remote: there the branch merges into
|
|
8
|
+
// the main checkout's branch on this computer, past the same gates.
|
|
7
9
|
|
|
8
10
|
const fs = require('fs');
|
|
9
11
|
const os = require('os');
|
|
@@ -15,6 +17,7 @@ const {
|
|
|
15
17
|
sniffedProtectedLane,
|
|
16
18
|
} = require('./auto-accept-certified');
|
|
17
19
|
const { isDecisionHoldTag, isDecisionTask } = require('./task-decision');
|
|
20
|
+
const { inboxMapEdits } = require('./member-map-inbox');
|
|
18
21
|
|
|
19
22
|
// Git replacement refs can make a local check read different code than the
|
|
20
23
|
// commit that gets pushed; every git and gh call here ignores them.
|
|
@@ -254,16 +257,19 @@ function unsavedHold(cwd) {
|
|
|
254
257
|
return null;
|
|
255
258
|
}
|
|
256
259
|
|
|
257
|
-
|
|
260
|
+
// Files where every side only adds lines: map files, the map notes file, and
|
|
261
|
+
// member logs. A clash in these keeps both sides' lines instead of holding.
|
|
262
|
+
const MAP_FILE = /(?:^|\/)MAP(?:-NOTES)?\.md$|^atris\/team\/[^/]+\/logs\/[^/]+\.md$/;
|
|
258
263
|
|
|
259
|
-
// Keep both sides' lines of one clashing map file, as a stored blob.
|
|
264
|
+
// Keep both sides' lines of one clashing map file, as a stored blob. A file
|
|
265
|
+
// both sides created, like the same day's member log, merges against empty.
|
|
260
266
|
function unionMapBlob(file, mergeBase, ours, theirs, cwd, tmp) {
|
|
261
267
|
const sides = [];
|
|
262
268
|
for (const [name, rev] of [['base', mergeBase], ['ours', ours], ['theirs', theirs]]) {
|
|
263
269
|
const blob = run('git', ['cat-file', 'blob', `${rev}:${file}`], cwd, {}, { raw: true });
|
|
264
|
-
if (!blob.ok) return null;
|
|
270
|
+
if (!blob.ok && name !== 'base') return null;
|
|
265
271
|
const out = path.join(tmp, name);
|
|
266
|
-
fs.writeFileSync(out, blob.stdout);
|
|
272
|
+
fs.writeFileSync(out, blob.ok ? blob.stdout : '');
|
|
267
273
|
sides.push(out);
|
|
268
274
|
}
|
|
269
275
|
const merged = run('git', ['merge-file', '-p', '--union', sides[1], sides[0], sides[2]], cwd, {}, { raw: true });
|
|
@@ -275,19 +281,40 @@ function unionMapBlob(file, mergeBase, ours, theirs, cwd, tmp) {
|
|
|
275
281
|
}
|
|
276
282
|
|
|
277
283
|
// Merge the main branch into the reviewed commit without touching the
|
|
278
|
-
// checkout.
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
// checkout. Edits to the shared map files first move to the map inbox in a
|
|
285
|
+
// commit on top of the reviewed commit, so they never clash. A clean merge
|
|
286
|
+
// pushes that commit as is. When every clash is a map file or member log, both
|
|
287
|
+
// sides' lines are kept and a merge commit is pushed instead, so GitHub has
|
|
288
|
+
// nothing left to resolve. Any other clash holds. With catchUp, a clean merge
|
|
289
|
+
// against a main branch that moved also becomes a merge commit, so the branch
|
|
290
|
+
// is up to date. With localBase, the main branch is the local one and nothing
|
|
291
|
+
// is fetched.
|
|
292
|
+
function mergeWithBase(reviewedSha, base, branch, cwd, { catchUp = false, localBase = false } = {}) {
|
|
293
|
+
const baseRef = localBase ? `refs/heads/${base}` : `refs/remotes/origin/${base}`;
|
|
294
|
+
if (!localBase) {
|
|
295
|
+
const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${base}:${baseRef}`], cwd);
|
|
296
|
+
if (!fetched.ok) return { hold: `the main branch could not be fetched (${fetched.stderr.split('\n')[0]})` };
|
|
297
|
+
}
|
|
298
|
+
const baseRead = run('git', ['rev-parse', baseRef], cwd);
|
|
287
299
|
const baseSha = baseRead.ok ? baseRead.stdout : '';
|
|
288
300
|
if (!/^[0-9a-f]{40,64}$/.test(baseSha)) return { hold: 'the main branch could not be read' };
|
|
301
|
+
const inboxed = inboxMapEdits({ headSha: reviewedSha, baseSha, branch, cwd });
|
|
302
|
+
if (inboxed.hold) return { hold: inboxed.hold };
|
|
303
|
+
const headSha = inboxed.sha;
|
|
304
|
+
const mapInboxed = inboxed.moved;
|
|
289
305
|
const tree = run('git', ['merge-tree', '--write-tree', '--name-only', headSha, baseSha], cwd);
|
|
290
|
-
if (tree.status === 0)
|
|
306
|
+
if (tree.status === 0) {
|
|
307
|
+
if (!catchUp) return { sha: headSha, mapInboxed };
|
|
308
|
+
if (headSha === reviewedSha && run('git', ['merge-base', '--is-ancestor', baseSha, headSha], cwd).ok) {
|
|
309
|
+
return { sha: headSha, current: true };
|
|
310
|
+
}
|
|
311
|
+
const commit = run('git', [
|
|
312
|
+
'commit-tree', tree.stdout.split(/\r?\n/)[0], '-p', headSha, '-p', baseSha,
|
|
313
|
+
'-m', `Merge ${base} into ${branch} to catch up`,
|
|
314
|
+
], cwd);
|
|
315
|
+
if (!commit.ok || !/^[0-9a-f]{40,64}$/.test(commit.stdout)) return { hold: 'the catch-up merge could not be saved' };
|
|
316
|
+
return { sha: commit.stdout, mapInboxed };
|
|
317
|
+
}
|
|
291
318
|
if (tree.status !== 1) return { hold: 'the branch could not be compared with the main branch' };
|
|
292
319
|
const [treeId, ...files] = tree.stdout.split(/\r?\n\r?\n/)[0].split(/\r?\n/).filter(Boolean);
|
|
293
320
|
const clashes = files.filter((file) => !MAP_FILE.test(file));
|
|
@@ -317,17 +344,106 @@ function mergeWithBase(headSha, base, branch, cwd) {
|
|
|
317
344
|
'-m', `Merge ${base} into ${branch}, keeping map lines from both sides`,
|
|
318
345
|
], cwd);
|
|
319
346
|
if (!commit.ok || !/^[0-9a-f]{40,64}$/.test(commit.stdout)) return { hold: 'the map merge could not be saved' };
|
|
320
|
-
return { sha: commit.stdout, mapMerged: files };
|
|
347
|
+
return { sha: commit.stdout, mapMerged: files, mapInboxed };
|
|
321
348
|
} finally {
|
|
322
349
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
323
350
|
}
|
|
324
351
|
}
|
|
325
352
|
|
|
353
|
+
// Every branch autoland handed to GitHub, so a sweep can tell when GitHub
|
|
354
|
+
// dropped one. Lives in the main checkout beside the policy.
|
|
355
|
+
function queueFile(cwd) {
|
|
356
|
+
const root = mainCheckoutRoot(cwd);
|
|
357
|
+
return root ? path.join(root, '.atris', 'state', 'autoland-queue.json') : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readQueue(file) {
|
|
361
|
+
try {
|
|
362
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
363
|
+
if (data && typeof data.entries === 'object' && !Array.isArray(data.entries)) return data.entries;
|
|
364
|
+
} catch {}
|
|
365
|
+
return {};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function writeQueue(file, entries) {
|
|
369
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
370
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
371
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ schema: 'atris.autoland_queue.v1', entries }, null, 2)}\n`);
|
|
372
|
+
fs.renameSync(tmp, file);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function recordQueued(cwd, entry) {
|
|
376
|
+
const file = queueFile(cwd);
|
|
377
|
+
if (!file) return;
|
|
378
|
+
try {
|
|
379
|
+
const entries = readQueue(file);
|
|
380
|
+
entries[entry.branch] = entry;
|
|
381
|
+
writeQueue(file, entries);
|
|
382
|
+
} catch {}
|
|
383
|
+
}
|
|
384
|
+
|
|
326
385
|
function held(gate, line) {
|
|
327
386
|
return { merged: false, auto_merge: false, gate, line: `left the branch for you: ${line}` };
|
|
328
387
|
}
|
|
329
388
|
|
|
330
|
-
function
|
|
389
|
+
function hasOrigin(cwd) {
|
|
390
|
+
return run('git', ['remote', 'get-url', 'origin'], cwd).ok;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The branch the main checkout is on, or '' when it is not on one.
|
|
394
|
+
function mainCheckoutBranch(cwd) {
|
|
395
|
+
const root = mainCheckoutRoot(cwd);
|
|
396
|
+
const read = root ? run('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], root) : null;
|
|
397
|
+
return read?.ok ? read.stdout : '';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Where a finished run's work goes, as one plain line for the start of a run.
|
|
401
|
+
function landingDestination(cwd) {
|
|
402
|
+
if (!policyOn(cwd)) return 'finished work stays on its own branch for you; self-landing is off';
|
|
403
|
+
if (!hasOrigin(cwd)) {
|
|
404
|
+
const base = mainCheckoutBranch(cwd);
|
|
405
|
+
return `finished work merges into ${base || 'the main branch'} on this computer once its check and review pass`;
|
|
406
|
+
}
|
|
407
|
+
return 'finished work goes to GitHub as a pull request that merges itself once checks pass';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// A project with no remote has no GitHub to hand the branch to, so the main
|
|
411
|
+
// checkout's own branch is the target. The reviewed commit catches up with it
|
|
412
|
+
// without touching any checkout, then the main checkout fast-forwards to that
|
|
413
|
+
// commit, which refuses rather than overwrite anything there.
|
|
414
|
+
function landLocally({ headSha, branch, cwd, pinnedTree }) {
|
|
415
|
+
const root = mainCheckoutRoot(cwd);
|
|
416
|
+
const base = mainCheckoutBranch(cwd);
|
|
417
|
+
if (!root || !base) return held('base', 'the main checkout is not on a branch');
|
|
418
|
+
if (base === branch) return held('branch', 'the work is not on its own branch');
|
|
419
|
+
const mainState = gitCodeState(root);
|
|
420
|
+
if (mainState.hidden || codeStatus(mainState) !== '') {
|
|
421
|
+
return held('unsaved', 'the main checkout has unsaved changes; merging there could mix them in');
|
|
422
|
+
}
|
|
423
|
+
const landing = mergeWithBase(headSha, base, branch, cwd, { catchUp: true, localBase: true });
|
|
424
|
+
if (landing.hold) return held('conflict', landing.hold);
|
|
425
|
+
if (!pinnedTree()) return held('review', 'the reviewed commit no longer holds the reviewed code');
|
|
426
|
+
const forward = run('git', ['merge', '--ff-only', '-q', landing.sha], root);
|
|
427
|
+
if (!forward.ok) return held('merge', `the main checkout could not move forward (${forward.stderr.split('\n')[0]})`);
|
|
428
|
+
const mapNote = landing.mapInboxed?.length ? ' (map edits saved to the map inbox)'
|
|
429
|
+
: landing.mapMerged ? ' (map lines from both sides kept)' : '';
|
|
430
|
+
return {
|
|
431
|
+
merged: true,
|
|
432
|
+
auto_merge: false,
|
|
433
|
+
local: true,
|
|
434
|
+
gate: null,
|
|
435
|
+
base,
|
|
436
|
+
landed: landing.sha,
|
|
437
|
+
map_merged: landing.mapMerged || [],
|
|
438
|
+
map_inboxed: landing.mapInboxed || [],
|
|
439
|
+
line: `merged into ${base} on this computer${mapNote}; this project has no GitHub remote`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// closeTaskIds: jobs taken off the list for this run. They only ride along
|
|
444
|
+
// so the sweep can close them once the change merges; unlike the mission's
|
|
445
|
+
// own linked tasks they do not gate the landing.
|
|
446
|
+
function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask, closeTaskIds = [] } = {}) {
|
|
331
447
|
if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
|
|
332
448
|
if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
|
|
333
449
|
if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
|
|
@@ -376,7 +492,8 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
376
492
|
if (!branch || branch === 'HEAD' || branch === 'master' || branch === 'main') {
|
|
377
493
|
return held('branch', 'the work is not on its own branch');
|
|
378
494
|
}
|
|
379
|
-
|
|
495
|
+
if (!hasOrigin(cwd)) return landLocally({ headSha, branch, cwd, pinnedTree });
|
|
496
|
+
const remoteHead =run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
|
|
380
497
|
const base = (remoteHead.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m) || [])[1] || '';
|
|
381
498
|
if (!remoteHead.ok || !base) return held('base', 'the main branch on GitHub could not be read');
|
|
382
499
|
if (branch === base) return held('branch', 'the work is not on its own branch');
|
|
@@ -423,7 +540,14 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
423
540
|
|
|
424
541
|
const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', pushSha], cwd);
|
|
425
542
|
if (!merge.ok) return held('auto-merge', `auto-merge could not be switched on for ${label}`);
|
|
426
|
-
|
|
543
|
+
recordQueued(cwd, {
|
|
544
|
+
branch, base, number, pushed: pushSha, mission_id: mission.id || null,
|
|
545
|
+
task_ids: [...new Set([...taskIds, ...(Array.isArray(closeTaskIds) ? closeTaskIds : [])
|
|
546
|
+
.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())])],
|
|
547
|
+
requeues: 0, queued_at: new Date().toISOString(),
|
|
548
|
+
});
|
|
549
|
+
const mapNote = landing.mapInboxed?.length ? ' (map edits saved to the map inbox)'
|
|
550
|
+
: landing.mapMerged ? ' (map lines from both sides kept)' : '';
|
|
427
551
|
return {
|
|
428
552
|
merged: false,
|
|
429
553
|
auto_merge: true,
|
|
@@ -431,12 +555,265 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
431
555
|
url: label,
|
|
432
556
|
pushed: pushSha,
|
|
433
557
|
map_merged: landing.mapMerged || [],
|
|
558
|
+
map_inboxed: landing.mapInboxed || [],
|
|
434
559
|
line: `merges on its own once checks pass${mapNote}: ${label}`,
|
|
435
560
|
};
|
|
436
561
|
}
|
|
437
562
|
|
|
563
|
+
const MAX_REQUEUES = 3;
|
|
564
|
+
|
|
565
|
+
// One queued branch: leave it, forget it, or catch it up and queue it again.
|
|
566
|
+
// Only the exact commit autoland queued (or its own catch-up) is re-queued; a
|
|
567
|
+
// push by anyone else, a closed pull request, or a real clash leaves it.
|
|
568
|
+
// GitHub keeps auto-merge on when master moves under a queued branch and the
|
|
569
|
+
// two clash, so a clashing branch is caught up even while still queued.
|
|
570
|
+
// GitHub runs holding a failed check on this pull request. gh exits nonzero
|
|
571
|
+
// when any check failed, so the list is read either way.
|
|
572
|
+
function failedCheckRuns(number, cwd) {
|
|
573
|
+
const out = run('gh', ['pr', 'checks', String(number), '--json', 'bucket,link'], cwd);
|
|
574
|
+
let checks = [];
|
|
575
|
+
try {
|
|
576
|
+
checks = JSON.parse(out.stdout || '[]');
|
|
577
|
+
} catch {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
if (!Array.isArray(checks)) return [];
|
|
581
|
+
const ids = checks
|
|
582
|
+
.filter((check) => check && check.bucket === 'fail')
|
|
583
|
+
.map((check) => (String(check.link || '').match(/\/actions\/runs\/(\d+)/) || [])[1])
|
|
584
|
+
.filter(Boolean);
|
|
585
|
+
return [...new Set(ids)];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function inMergeQueue(number, cwd) {
|
|
589
|
+
const out = run('gh', ['api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-F', `number=${number}`,
|
|
590
|
+
'-f', 'query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){isInMergeQueue}}}',
|
|
591
|
+
'--jq', '.data.repository.pullRequest.isInMergeQueue'], cwd);
|
|
592
|
+
return out.ok && out.stdout === 'true';
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function requeueOne(entry, cwd, maxRequeues) {
|
|
596
|
+
const { branch, base, number } = entry;
|
|
597
|
+
const label = `pull request #${number} (${branch})`;
|
|
598
|
+
const keep = (state, line) => ({ branch, number, state, keep: true, line: `${label}: ${line}` });
|
|
599
|
+
const forget = (state, line) => ({ branch, number, state, keep: false, line: `${label}: ${line}` });
|
|
600
|
+
const viewed = run('gh', ['pr', 'view', String(number), '--json',
|
|
601
|
+
'number,state,headRefName,baseRefName,headRefOid,isCrossRepository,autoMergeRequest,mergeable'], cwd);
|
|
602
|
+
let pr = null;
|
|
603
|
+
try {
|
|
604
|
+
pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
|
|
605
|
+
} catch {
|
|
606
|
+
pr = null;
|
|
607
|
+
}
|
|
608
|
+
if (!pr || typeof pr !== 'object') return keep('unreadable', 'github could not be read; will look again');
|
|
609
|
+
// Only the commit autoland pushed counts as the member's own change; a merge
|
|
610
|
+
// that carried someone else's push leaves the job for a person.
|
|
611
|
+
if (pr.state === 'MERGED') {
|
|
612
|
+
if (pr.headRefOid !== entry.pushed) return forget('merged', 'merged with commits someone else pushed; its job is left for you');
|
|
613
|
+
return { ...forget('merged', 'merged'), own: true };
|
|
614
|
+
}
|
|
615
|
+
if (pr.state !== 'OPEN') return forget('closed', 'closed by someone; left alone');
|
|
616
|
+
if (pr.headRefName !== branch || pr.baseRefName !== base || pr.isCrossRepository !== false) {
|
|
617
|
+
return forget('moved', 'no longer points from this branch into the main branch; left for you');
|
|
618
|
+
}
|
|
619
|
+
// In GitHub's merge line a change shows no auto-merge request and its
|
|
620
|
+
// branch is locked, so a catch-up push there fails. Leave it to the line.
|
|
621
|
+
if (!pr.autoMergeRequest && inMergeQueue(number, cwd)) return keep('queued', 'in github\'s merge line');
|
|
622
|
+
if (pr.autoMergeRequest && pr.mergeable !== 'CONFLICTING') {
|
|
623
|
+
// A failed check holds the change forever. Run the failed checks again
|
|
624
|
+
// once per pushed commit: a test that fails now and then passes, a real
|
|
625
|
+
// failure fails twice and waits for a fix.
|
|
626
|
+
const failed = pr.headRefOid === entry.pushed ? failedCheckRuns(number, cwd) : [];
|
|
627
|
+
if (failed.length && entry.reran !== entry.pushed) {
|
|
628
|
+
const reran = failed.filter((id) => run('gh', ['run', 'rerun', id, '--failed'], cwd).ok);
|
|
629
|
+
if (reran.length) return { ...keep('rerun', 'a check failed; ran the failed checks again once'), reran: entry.pushed };
|
|
630
|
+
return keep('queued', 'a check failed and could not be run again; will look again');
|
|
631
|
+
}
|
|
632
|
+
if (failed.length) return keep('failing', 'its checks failed again after one retry; it needs a fix');
|
|
633
|
+
return keep('queued', 'still queued');
|
|
634
|
+
}
|
|
635
|
+
if (pr.headRefOid !== entry.pushed) return forget('changed', 'someone pushed to it after it was queued; left for you');
|
|
636
|
+
if ((Number(entry.requeues) || 0) >= maxRequeues) {
|
|
637
|
+
return forget('gave-up', `dropped again after ${maxRequeues} catch-ups; left for you`);
|
|
638
|
+
}
|
|
639
|
+
const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], cwd);
|
|
640
|
+
const remote = run('git', ['rev-parse', `refs/remotes/origin/${branch}`], cwd);
|
|
641
|
+
if (!fetched.ok || !remote.ok || remote.stdout !== entry.pushed) {
|
|
642
|
+
return keep('unreadable', 'the branch on github could not be read; will look again');
|
|
643
|
+
}
|
|
644
|
+
const landing = mergeWithBase(entry.pushed, base, branch, cwd, { catchUp: true });
|
|
645
|
+
if (landing.hold) return { ...forget('clash', `${landing.hold}; left for you`), why: landing.hold };
|
|
646
|
+
if (!landing.current) {
|
|
647
|
+
const push = run('git', ['push', 'origin', `${landing.sha}:refs/heads/${branch}`], cwd);
|
|
648
|
+
if (!push.ok) return keep('push', `the catch-up could not be pushed (${push.stderr.split('\n')[0]})`);
|
|
649
|
+
}
|
|
650
|
+
const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', landing.sha], cwd);
|
|
651
|
+
// The catch-up may already be on github; carry it so the next look does not
|
|
652
|
+
// mistake our own push for someone else's.
|
|
653
|
+
if (!merge.ok) return { ...keep('auto-merge', 'auto-merge could not be switched back on; will look again'), pushed: landing.sha };
|
|
654
|
+
return {
|
|
655
|
+
branch, number, state: 'requeued', keep: true, pushed: landing.sha,
|
|
656
|
+
line: `${label}: ${landing.current ? 'queued again' : 'caught up with the main branch and queued again'}`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// The job picker's own hold test, so the two never drift apart. A row that
|
|
661
|
+
// cannot be read counts as held.
|
|
662
|
+
function heldAtClose(row) {
|
|
663
|
+
if (!row || typeof row !== 'object') return true;
|
|
664
|
+
return Boolean(require('./member-task-pick').heldForPerson(row));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function defaultCloseTask(id, { number }) {
|
|
668
|
+
const taskDb = require('./task-db');
|
|
669
|
+
const db = taskDb.open();
|
|
670
|
+
// A hold put on the job while its change waited to merge still wins: that
|
|
671
|
+
// job stays for a person.
|
|
672
|
+
const row = taskDb.getTask(db, id);
|
|
673
|
+
if (heldAtClose(row)) return { updated: false };
|
|
674
|
+
const out = taskDb.doneTask(db, {
|
|
675
|
+
id, actor: 'autoland', allowReview: true, autoAccepted: true,
|
|
676
|
+
action: 'merged', proof: `its own pull request #${number} merged`,
|
|
677
|
+
});
|
|
678
|
+
return { ...out, replaced: row.metadata && row.metadata.replaced_pull_requests };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// The member's own change merged: close every job it was queued for. A job
|
|
682
|
+
// already closed, or one that cannot be read, is left as it is. Each closed
|
|
683
|
+
// job hands back the clashing pull requests its redo replaced.
|
|
684
|
+
function closeMergedJobs(entry, closeTask) {
|
|
685
|
+
const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
686
|
+
const closed = [];
|
|
687
|
+
const replaced = [];
|
|
688
|
+
for (const id of ids) {
|
|
689
|
+
let out = null;
|
|
690
|
+
try {
|
|
691
|
+
out = closeTask(id, { number: entry.number, branch: entry.branch });
|
|
692
|
+
} catch {
|
|
693
|
+
out = null;
|
|
694
|
+
}
|
|
695
|
+
if (!out || !out.updated) continue;
|
|
696
|
+
closed.push(id);
|
|
697
|
+
if (Array.isArray(out.replaced)) replaced.push(...out.replaced);
|
|
698
|
+
}
|
|
699
|
+
return { closed, replaced };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// A clashing pull request stayed open after its job's redo merged, one more
|
|
703
|
+
// stale change for a person to sweep (2026-10-15: 29 open, most of them
|
|
704
|
+
// replaced). Close it once the redo merges, unless someone else pushed to it
|
|
705
|
+
// or it is no longer open.
|
|
706
|
+
function closeReplacedPullRequests(replaced, number, cwd) {
|
|
707
|
+
const closed = [];
|
|
708
|
+
for (const old of replaced) {
|
|
709
|
+
if (!old || !Number.isInteger(old.number) || old.number <= 0 || old.number === number) continue;
|
|
710
|
+
if (closed.includes(old.number)) continue;
|
|
711
|
+
const viewed = run('gh', ['pr', 'view', String(old.number), '--json', 'state,headRefOid'], cwd);
|
|
712
|
+
let pr = null;
|
|
713
|
+
try {
|
|
714
|
+
pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
|
|
715
|
+
} catch {
|
|
716
|
+
pr = null;
|
|
717
|
+
}
|
|
718
|
+
if (!pr || pr.state !== 'OPEN' || pr.headRefOid !== old.pushed) continue;
|
|
719
|
+
const done = run('gh', ['pr', 'close', String(old.number), '--comment',
|
|
720
|
+
`Replaced by #${number}, which merged. Its job is done.`], cwd);
|
|
721
|
+
if (done.ok) closed.push(old.number);
|
|
722
|
+
}
|
|
723
|
+
return closed;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// A clashing change is dropped from the merge line, and its job used to sit in
|
|
727
|
+
// review forever with one line in a terminal (2026-10-15: fifteen green pull
|
|
728
|
+
// requests stuck clashing, none picked up again). The job goes back on the
|
|
729
|
+
// list instead; the member's next run sees the old branch clashes and starts
|
|
730
|
+
// from the main branch. After two such restarts it stays for a person.
|
|
731
|
+
const MAX_CLASH_RESTARTS = 2;
|
|
732
|
+
|
|
733
|
+
function defaultSendBack(id, { number, pushed, why }) {
|
|
734
|
+
const taskDb = require('./task-db');
|
|
735
|
+
const db = taskDb.open();
|
|
736
|
+
const row = taskDb.getTask(db, id);
|
|
737
|
+
if (!row || row.status !== 'review' || heldAtClose(row)) return { reopened: false };
|
|
738
|
+
const restarts = Number(row.metadata && row.metadata.clash_restarts) || 0;
|
|
739
|
+
if (restarts >= MAX_CLASH_RESTARTS) return { reopened: false };
|
|
740
|
+
// Remember the clashing change so the redo's merge can close it.
|
|
741
|
+
const prior = Array.isArray(row.metadata && row.metadata.replaced_pull_requests) ? row.metadata.replaced_pull_requests : [];
|
|
742
|
+
return taskDb.reopenTask(db, {
|
|
743
|
+
id, actor: 'autoland', fromReview: true,
|
|
744
|
+
metadata: { clash_restarts: restarts + 1, replaced_pull_requests: [...prior, { number, pushed }] },
|
|
745
|
+
reason: `pull request #${number} could not merge: ${why}; start again from the main branch`,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function sendBackClashedJobs(entry, why, sendBack) {
|
|
750
|
+
const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
751
|
+
const sent = [];
|
|
752
|
+
for (const id of ids) {
|
|
753
|
+
let out = null;
|
|
754
|
+
try {
|
|
755
|
+
out = sendBack(id, { number: entry.number, branch: entry.branch, pushed: entry.pushed, why });
|
|
756
|
+
} catch {
|
|
757
|
+
out = null;
|
|
758
|
+
}
|
|
759
|
+
if (out && out.reopened) sent.push(id);
|
|
760
|
+
}
|
|
761
|
+
return sent;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Look at every branch autoland queued and re-queue the ones GitHub dropped.
|
|
765
|
+
// A branch whose own change merged closes the jobs it was queued for; one that
|
|
766
|
+
// clashes sends its jobs back on the list.
|
|
767
|
+
function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask = defaultCloseTask, sendBack = defaultSendBack } = {}) {
|
|
768
|
+
const file = queueFile(cwd);
|
|
769
|
+
if (!file) return { checked: 0, results: [], line: 'not inside a git checkout' };
|
|
770
|
+
if (!policyOn(cwd)) return { checked: 0, results: [], line: 'self-landing is not switched on in the main checkout' };
|
|
771
|
+
const entries = readQueue(file);
|
|
772
|
+
const results = [];
|
|
773
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
774
|
+
const valid = entry && typeof entry.branch === 'string' && entry.branch === key && typeof entry.base === 'string'
|
|
775
|
+
&& Number.isInteger(entry.number) && entry.number > 0 && /^[0-9a-f]{40,64}$/.test(String(entry.pushed || ''));
|
|
776
|
+
const result = valid ? requeueOne(entry, cwd, maxRequeues)
|
|
777
|
+
: { branch: key, state: 'unreadable', keep: false, line: `${key}: queue entry could not be read; dropped from the list` };
|
|
778
|
+
if (valid && result.own) {
|
|
779
|
+
const merged = closeMergedJobs(entry, closeTask);
|
|
780
|
+
result.closed = merged.closed;
|
|
781
|
+
const n = result.closed.length;
|
|
782
|
+
if (n) result.line += `; closed ${n === 1 ? 'its job' : `its ${n} jobs`}`;
|
|
783
|
+
result.replaced = closeReplacedPullRequests(merged.replaced, entry.number, cwd);
|
|
784
|
+
if (result.replaced.length) {
|
|
785
|
+
result.line += `; closed the clashing ${result.replaced.length === 1 ? 'change' : 'changes'} it replaced (${result.replaced.map((r) => `#${r}`).join(', ')})`;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (valid && result.state === 'clash') {
|
|
789
|
+
result.sent_back = sendBackClashedJobs(entry, result.why, sendBack);
|
|
790
|
+
const n = result.sent_back.length;
|
|
791
|
+
if (n) result.line = result.line.replace(/; left for you$/, `; ${n === 1 ? 'its job went' : `its ${n} jobs went`} back on the list`);
|
|
792
|
+
}
|
|
793
|
+
results.push(result);
|
|
794
|
+
if (!result.keep) delete entries[key];
|
|
795
|
+
else if (result.state === 'requeued' || (result.pushed && result.pushed !== entry.pushed)) {
|
|
796
|
+
entries[key] = { ...entry, pushed: result.pushed, requeues: (Number(entry.requeues) || 0) + 1, requeued_at: new Date().toISOString() };
|
|
797
|
+
} else if (result.reran) {
|
|
798
|
+
entries[key] = { ...entry, reran: result.reran, reran_at: new Date().toISOString() };
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (results.length) writeQueue(file, entries);
|
|
802
|
+
const requeued = results.filter((r) => r.state === 'requeued').length;
|
|
803
|
+
const rerun = results.filter((r) => r.state === 'rerun').length;
|
|
804
|
+
const closed = results.reduce((sum, r) => sum + (r.closed ? r.closed.length : 0), 0);
|
|
805
|
+
const closedNote = (closed ? `; ${closed} finished ${closed === 1 ? 'job' : 'jobs'} closed after merging` : '')
|
|
806
|
+
+ (rerun ? `; ${rerun} failed ${rerun === 1 ? 'check' : 'checks'} run again` : '');
|
|
807
|
+
const line = results.length
|
|
808
|
+
? `${requeued} of ${results.length} queued ${results.length === 1 ? 'change' : 'changes'} put back in line${closedNote}`
|
|
809
|
+
: 'nothing is waiting in github\'s merge line';
|
|
810
|
+
return { checked: results.length, requeued, rerun, closed, results, line };
|
|
811
|
+
}
|
|
812
|
+
|
|
438
813
|
module.exports = {
|
|
439
814
|
autolandMemberRun,
|
|
815
|
+
landingDestination,
|
|
816
|
+
requeueDroppedAutolands,
|
|
440
817
|
codeStatus,
|
|
441
818
|
gitCodeState,
|
|
442
819
|
};
|
package/lib/member-board.js
CHANGED
|
@@ -454,8 +454,14 @@ function memberBoardCommand(...args) {
|
|
|
454
454
|
if (args.includes('--follow')) {
|
|
455
455
|
const dir = progressDir();
|
|
456
456
|
const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
457
|
+
// Mark where every log ends before reading the snapshot, so a line written
|
|
458
|
+
// while the snapshot prints is streamed after it instead of lost.
|
|
459
|
+
const early = [];
|
|
460
|
+
let send = (event) => early.push(event);
|
|
461
|
+
const follower = followBoard({ dir, emit: (event) => { if (!only || only.includes(event.member)) send(event); } });
|
|
457
462
|
write({ kind: 'snapshot', at: new Date().toISOString(), dir, members: readBoard({ dir, all, asleep: sleepingHere(), only }) });
|
|
458
|
-
|
|
463
|
+
send = write;
|
|
464
|
+
early.forEach(write);
|
|
459
465
|
// Runs until ctrl-c or the reader closes the pipe.
|
|
460
466
|
return new Promise((resolve) => {
|
|
461
467
|
const done = () => { follower.stop(); resolve(); };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Member changes save map notes as their own small files under
|
|
4
|
+
// atris/refs/map-notes.d/ instead of editing atris/refs/MAP-NOTES.md, so two
|
|
5
|
+
// changes never clash on the shared map. Once an hour the autoland tick folds
|
|
6
|
+
// those files into MAP-NOTES.md on the main branch and removes them, in one
|
|
7
|
+
// commit built without touching any checkout. A push that loses a race with
|
|
8
|
+
// another landing is dropped; the next hour tries again from the new main.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const NOTES_FILE = 'atris/refs/MAP-NOTES.md';
|
|
16
|
+
const DROP_DIR = 'atris/refs/map-notes.d';
|
|
17
|
+
const FOLD_EVERY_MS = 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
function git(args, cwd, extraEnv = {}) {
|
|
20
|
+
const res = spawnSync('git', args, {
|
|
21
|
+
cwd,
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
timeout: 120000,
|
|
24
|
+
env: { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' },
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
ok: res.status === 0,
|
|
28
|
+
stdout: String(res.stdout || ''),
|
|
29
|
+
stderr: String(res.stderr || res.error?.message || '').trim(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function foldDue(lastAt, now = Date.now()) {
|
|
34
|
+
const last = Date.parse(lastAt || '');
|
|
35
|
+
return !Number.isFinite(last) || now - last >= FOLD_EVERY_MS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isHeading = (line) => /^## \S/.test(line);
|
|
39
|
+
const trimEnd = (text) => String(text || '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
|
40
|
+
|
|
41
|
+
// Fold drop texts into the notes text. A drop that opens with a "## " heading
|
|
42
|
+
// already in the notes adds its new lines at the end of that section; any
|
|
43
|
+
// other drop is added at the end of the file as written. A drop whose lines
|
|
44
|
+
// are all already there adds nothing.
|
|
45
|
+
function foldNotesText(notes, drops) {
|
|
46
|
+
const lines = trimEnd(notes) ? trimEnd(notes).split('\n') : [];
|
|
47
|
+
let added = 0;
|
|
48
|
+
for (const drop of drops) {
|
|
49
|
+
const body = trimEnd(drop.text).split('\n');
|
|
50
|
+
while (body.length && !body[0].trim()) body.shift();
|
|
51
|
+
if (!body.length) continue;
|
|
52
|
+
const at = isHeading(body[0]) ? lines.findIndex((l) => l.trim() === body[0].trim()) : -1;
|
|
53
|
+
if (at >= 0) {
|
|
54
|
+
let end = lines.findIndex((l, i) => i > at && isHeading(l));
|
|
55
|
+
if (end < 0) end = lines.length;
|
|
56
|
+
const seen = new Set(lines.slice(at, end).map((l) => l.trim()));
|
|
57
|
+
const fresh = body.slice(1).filter((l) => l.trim() && !seen.has(l.trim()));
|
|
58
|
+
if (!fresh.length) continue;
|
|
59
|
+
while (end > at + 1 && !lines[end - 1].trim()) end--;
|
|
60
|
+
lines.splice(end, 0, ...fresh);
|
|
61
|
+
added += fresh.length;
|
|
62
|
+
} else {
|
|
63
|
+
const seen = new Set(lines.map((l) => l.trim()));
|
|
64
|
+
const content = body.filter((l) => l.trim());
|
|
65
|
+
if (content.every((l) => seen.has(l.trim()))) continue;
|
|
66
|
+
if (lines.length) lines.push('');
|
|
67
|
+
lines.push(...body);
|
|
68
|
+
added += content.length;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { text: lines.length ? `${lines.join('\n')}\n` : '', added };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mainBranch(root, remote) {
|
|
75
|
+
const head = git(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], root).stdout.trim();
|
|
76
|
+
return head.startsWith(`${remote}/`) ? head.slice(remote.length + 1) : 'master';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fold every drop file on the main branch into the shared map notes and push
|
|
80
|
+
// the result. Returns what happened; never throws for an ordinary miss.
|
|
81
|
+
function foldMapNotesOnMain(root, { base = '', remote = 'origin', push = true } = {}) {
|
|
82
|
+
base = base || mainBranch(root, remote);
|
|
83
|
+
const remoteRef = `refs/remotes/${remote}/${base}`;
|
|
84
|
+
const fetched = git(['fetch', '-q', remote, `+refs/heads/${base}:${remoteRef}`], root);
|
|
85
|
+
if (!fetched.ok) return { folded: 0, reason: 'main_unreadable', detail: fetched.stderr.split('\n')[0] };
|
|
86
|
+
const baseSha = git(['rev-parse', remoteRef], root).stdout.trim();
|
|
87
|
+
if (!/^[0-9a-f]{40,64}$/.test(baseSha)) return { folded: 0, reason: 'main_unreadable' };
|
|
88
|
+
|
|
89
|
+
const listed = git(['ls-tree', '-r', '--name-only', baseSha, '--', `${DROP_DIR}/`], root);
|
|
90
|
+
const files = listed.stdout.split('\n').filter((f) => f.endsWith('.md')).sort();
|
|
91
|
+
if (!files.length) return { folded: 0, reason: 'nothing_to_fold', base: baseSha };
|
|
92
|
+
|
|
93
|
+
const drops = [];
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
const blob = git(['cat-file', 'blob', `${baseSha}:${file}`], root);
|
|
96
|
+
if (!blob.ok) return { folded: 0, reason: 'drop_unreadable', detail: file };
|
|
97
|
+
drops.push({ file, text: blob.stdout });
|
|
98
|
+
}
|
|
99
|
+
const current = git(['cat-file', 'blob', `${baseSha}:${NOTES_FILE}`], root);
|
|
100
|
+
const { text, added } = foldNotesText(current.ok ? current.stdout : '', drops);
|
|
101
|
+
|
|
102
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-fold-'));
|
|
103
|
+
try {
|
|
104
|
+
const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
|
|
105
|
+
const notesPath = path.join(tmp, 'notes');
|
|
106
|
+
fs.writeFileSync(notesPath, text);
|
|
107
|
+
const blob = git(['hash-object', '-w', notesPath], root).stdout.trim();
|
|
108
|
+
const built = git(['read-tree', baseSha], root, index).ok
|
|
109
|
+
&& /^[0-9a-f]{40,64}$/.test(blob)
|
|
110
|
+
&& git(['update-index', '--add', '--cacheinfo', `100644,${blob},${NOTES_FILE}`], root, index).ok
|
|
111
|
+
&& git(['update-index', '--force-remove', '--', ...files], root, index).ok;
|
|
112
|
+
const tree = built ? git(['write-tree'], root, index).stdout.trim() : '';
|
|
113
|
+
const message = `Fold ${files.length} map ${files.length === 1 ? 'note' : 'notes'} from member changes into the shared map`;
|
|
114
|
+
const commit = tree ? git(['commit-tree', tree, '-p', baseSha, '-m', message], root).stdout.trim() : '';
|
|
115
|
+
if (!/^[0-9a-f]{40,64}$/.test(commit)) return { folded: 0, reason: 'commit_failed' };
|
|
116
|
+
if (!push) return { folded: files.length, added, sha: commit, pushed: false, files };
|
|
117
|
+
const pushed = git(['push', '-q', remote, `${commit}:refs/heads/${base}`], root);
|
|
118
|
+
if (!pushed.ok) return { folded: 0, reason: 'push_rejected', detail: pushed.stderr.split('\n')[0], sha: commit };
|
|
119
|
+
git(['update-ref', remoteRef, commit], root);
|
|
120
|
+
return { folded: files.length, added, sha: commit, pushed: true, files };
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { NOTES_FILE, DROP_DIR, FOLD_EVERY_MS, foldDue, foldNotesText, foldMapNotesOnMain };
|