@miller-tech/uap 1.165.0 → 1.169.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +47 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/coord.d.ts +1 -1
- package/dist/cli/coord.d.ts.map +1 -1
- package/dist/cli/coord.js +59 -0
- package/dist/cli/coord.js.map +1 -1
- package/dist/cli/merge-queue.d.ts +53 -0
- package/dist/cli/merge-queue.d.ts.map +1 -0
- package/dist/cli/merge-queue.js +239 -0
- package/dist/cli/merge-queue.js.map +1 -0
- package/dist/cli/worktree.d.ts +69 -1
- package/dist/cli/worktree.d.ts.map +1 -1
- package/dist/cli/worktree.js +560 -26
- package/dist/cli/worktree.js.map +1 -1
- package/dist/config/policy-recommendations.d.ts.map +1 -1
- package/dist/config/policy-recommendations.js +4 -0
- package/dist/config/policy-recommendations.js.map +1 -1
- package/dist/coordination/index.d.ts +1 -0
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -0
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/ownership.d.ts +51 -0
- package/dist/coordination/ownership.d.ts.map +1 -0
- package/dist/coordination/ownership.js +175 -0
- package/dist/coordination/ownership.js.map +1 -0
- package/dist/delivery/self-gate.js +6 -0
- package/dist/delivery/self-gate.js.map +1 -1
- package/dist/delivery/verifier-ladder.d.ts +16 -0
- package/dist/delivery/verifier-ladder.d.ts.map +1 -1
- package/dist/delivery/verifier-ladder.js +20 -3
- package/dist/delivery/verifier-ladder.js.map +1 -1
- package/docs/INDEX.md +2 -1
- package/docs/guides/PARALLEL_AGENTS.md +209 -0
- package/docs/guides/POLICIES.md +1 -0
- package/docs/guides/WORKTREE_WORKFLOW.md +5 -2
- package/docs/reference/CLI.md +24 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/branch_freshness.py +134 -0
- package/src/policies/enforcers/coord_overlap.py +126 -47
- package/src/policies/schemas/policies/branch-freshness.md +81 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/coordinate-file.sh +153 -3
- package/templates/hooks/session-end.sh +15 -0
- package/templates/hooks/session-start.sh +24 -2
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
package/dist/cli/worktree.js
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import { cpSync, existsSync, readdirSync, mkdirSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
4
|
+
import { join, dirname, resolve, sep } from 'path';
|
|
5
5
|
import { simpleGit } from 'simple-git';
|
|
6
6
|
import Database from 'better-sqlite3';
|
|
7
|
-
import { execSync } from 'child_process';
|
|
7
|
+
import { execSync, execFileSync } from 'child_process';
|
|
8
|
+
/** Commits behind the integration ref at which a worktree reads as abandoned. */
|
|
9
|
+
export const STALE_BEHIND_LIMIT = 200;
|
|
8
10
|
let worktreeDb = null;
|
|
11
|
+
/**
|
|
12
|
+
* The MAIN checkout — where .worktrees/ and the shared coordination DB live.
|
|
13
|
+
* git-common-dir resolves it even when called from inside a linked worktree.
|
|
14
|
+
*/
|
|
15
|
+
async function mainRootOf(git) {
|
|
16
|
+
try {
|
|
17
|
+
const common = (await git.raw(['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim();
|
|
18
|
+
if (common)
|
|
19
|
+
return dirname(common);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// older git without --path-format, or not a repo
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
// NOT --show-toplevel: inside a linked worktree that returns the WORKTREE's
|
|
26
|
+
// root, which has no agents/ dir — silently disabling liveness in exactly the
|
|
27
|
+
// case this helper exists for. The relative form of --git-common-dir has
|
|
28
|
+
// existed since git 2.5 and still points at the MAIN .git.
|
|
29
|
+
const rel = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
|
|
30
|
+
if (rel)
|
|
31
|
+
return dirname(resolve(process.cwd(), rel));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// not a repo
|
|
35
|
+
}
|
|
36
|
+
return process.cwd();
|
|
37
|
+
}
|
|
9
38
|
function getWorktreeDb(cwd) {
|
|
10
39
|
if (worktreeDb)
|
|
11
40
|
return worktreeDb;
|
|
@@ -53,7 +82,13 @@ export async function worktreeCommand(action, options = {}) {
|
|
|
53
82
|
}
|
|
54
83
|
switch (action) {
|
|
55
84
|
case 'create':
|
|
56
|
-
await createWorktree(cwd, git, options.slug, options.from);
|
|
85
|
+
await createWorktree(cwd, git, options.slug, options.from, options.noFetch);
|
|
86
|
+
break;
|
|
87
|
+
case 'sync':
|
|
88
|
+
await syncWorktree(cwd, git, { id: options.id, all: options.all });
|
|
89
|
+
break;
|
|
90
|
+
case 'hygiene':
|
|
91
|
+
await worktreeHygiene(cwd, git, { brief: options.brief });
|
|
57
92
|
break;
|
|
58
93
|
case 'list':
|
|
59
94
|
await listWorktrees(cwd, git);
|
|
@@ -79,7 +114,7 @@ export async function worktreeCommand(action, options = {}) {
|
|
|
79
114
|
break;
|
|
80
115
|
}
|
|
81
116
|
}
|
|
82
|
-
async function createWorktree(cwd, git, slug, baseBranch) {
|
|
117
|
+
async function createWorktree(cwd, git, slug, baseBranch, noFetch) {
|
|
83
118
|
const spinner = ora('Creating worktree...').start();
|
|
84
119
|
try {
|
|
85
120
|
// Get next ID atomically from DB
|
|
@@ -88,11 +123,23 @@ async function createWorktree(cwd, git, slug, baseBranch) {
|
|
|
88
123
|
const worktreeName = `${paddedId}-${slug}`;
|
|
89
124
|
const branchName = `feature/${worktreeName}`;
|
|
90
125
|
const worktreePath = join(cwd, '.worktrees', worktreeName);
|
|
91
|
-
//
|
|
126
|
+
// FRESH-BASE GATE. A worktree used to be cut from whatever the main checkout
|
|
127
|
+
// happened to have at HEAD — so if local master was stale (or the operator was
|
|
128
|
+
// parked on some unrelated branch), the agent started N commits behind and every
|
|
129
|
+
// file it touched was at risk of silently reverting work that had already landed.
|
|
130
|
+
// Observed live: a worktree created while local master was 1 behind origin was
|
|
131
|
+
// born stale, and the oldest worktrees in this repo drifted to 1241 commits behind.
|
|
132
|
+
// Base on the REMOTE tip by default; --from still wins for deliberate stacking.
|
|
92
133
|
spinner.text = 'Resolving base branch...';
|
|
93
|
-
|
|
94
|
-
let branchBase = currentBranch.trim();
|
|
134
|
+
let branchBase;
|
|
95
135
|
if (baseBranch) {
|
|
136
|
+
// Explicit base: fetch it first so `--from origin/x` (and a local ref that
|
|
137
|
+
// tracks it) resolves to the current remote tip rather than a stale copy.
|
|
138
|
+
// Strip the remote prefix — `git fetch origin origin/x` is not a valid
|
|
139
|
+
// refspec, so it failed silently and left the stale local ref in place.
|
|
140
|
+
if (!noFetch) {
|
|
141
|
+
await fetchQuietly(git, baseBranch.replace(/^origin\//, ''));
|
|
142
|
+
}
|
|
96
143
|
try {
|
|
97
144
|
await git.revparse([baseBranch]);
|
|
98
145
|
branchBase = baseBranch;
|
|
@@ -102,6 +149,9 @@ async function createWorktree(cwd, git, slug, baseBranch) {
|
|
|
102
149
|
process.exit(1);
|
|
103
150
|
}
|
|
104
151
|
}
|
|
152
|
+
else {
|
|
153
|
+
branchBase = await resolveFreshBase(git, { noFetch, spinner });
|
|
154
|
+
}
|
|
105
155
|
// Create worktree with new branch
|
|
106
156
|
spinner.text = `Creating branch ${branchName}...`;
|
|
107
157
|
await git.raw(['worktree', 'add', '-b', branchName, worktreePath, branchBase]);
|
|
@@ -140,7 +190,8 @@ async function createWorktree(cwd, git, slug, baseBranch) {
|
|
|
140
190
|
`).run(slug, branchName, worktreePath);
|
|
141
191
|
spinner.succeed(`Created worktree: ${worktreeName}`);
|
|
142
192
|
console.log(chalk.dim(` Branch: ${branchName}`));
|
|
143
|
-
console.log(chalk.dim(`
|
|
193
|
+
console.log(chalk.dim(` Base: ${branchBase}`));
|
|
194
|
+
console.log(chalk.dim(` Path: ${worktreePath}`));
|
|
144
195
|
console.log('');
|
|
145
196
|
console.log(chalk.bold('Next steps:'));
|
|
146
197
|
console.log(` cd .worktrees/${worktreeName}`);
|
|
@@ -275,17 +326,418 @@ function ensurePrNumber(worktreePath) {
|
|
|
275
326
|
}
|
|
276
327
|
}
|
|
277
328
|
async function syncBranchWithMaster(worktreeGit, branch) {
|
|
278
|
-
await worktreeGit
|
|
279
|
-
|
|
329
|
+
const base = await resolveIntegrationRef(worktreeGit);
|
|
330
|
+
await fetchQuietly(worktreeGit, base.replace(/^origin\//, ''));
|
|
331
|
+
const behindRaw = await worktreeGit.raw(['rev-list', '--count', `${branch}..${base}`]);
|
|
280
332
|
const behind = parseRevListCount(behindRaw);
|
|
281
333
|
if (behind === 0) {
|
|
282
334
|
return;
|
|
283
335
|
}
|
|
284
336
|
try {
|
|
285
|
-
await worktreeGit.raw(['merge', '--no-edit',
|
|
337
|
+
await worktreeGit.raw(['merge', '--no-edit', base]);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
throw new Error(`Worktree branch is behind ${base} and automatic sync failed. Resolve merge conflicts in the worktree, then rerun the command.`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Best-effort fetch — never fails the caller (offline, no remote, auth prompt). */
|
|
344
|
+
async function fetchQuietly(git, ref) {
|
|
345
|
+
try {
|
|
346
|
+
await git.fetch(ref ? ['origin', ref] : ['origin']);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Name of the repo's integration branch, without the remote prefix.
|
|
355
|
+
* Prefers origin/HEAD (what the remote itself calls default), then the usual
|
|
356
|
+
* suspects, then the current local branch. Hard-coding "master" broke every
|
|
357
|
+
* main-branch repo that installed UAP.
|
|
358
|
+
*/
|
|
359
|
+
export async function resolveDefaultBranch(git) {
|
|
360
|
+
try {
|
|
361
|
+
const head = await git.raw(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
362
|
+
const name = head.trim().replace('refs/remotes/origin/', '');
|
|
363
|
+
if (name)
|
|
364
|
+
return name;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// origin/HEAD not set (common on clones made with --single-branch) — fall through.
|
|
368
|
+
}
|
|
369
|
+
for (const candidate of ['master', 'main']) {
|
|
370
|
+
try {
|
|
371
|
+
await git.revparse([`refs/remotes/origin/${candidate}`]);
|
|
372
|
+
return candidate;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// not present — try the next one
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
return (await git.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return 'master';
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Fully-qualified ref to integrate against: `origin/<default>` when the remote has it. */
|
|
386
|
+
export async function resolveIntegrationRef(git) {
|
|
387
|
+
const branch = await resolveDefaultBranch(git);
|
|
388
|
+
try {
|
|
389
|
+
await git.revparse([`refs/remotes/origin/${branch}`]);
|
|
390
|
+
return `origin/${branch}`;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return branch;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The ref a new worktree should be cut from: the freshly-fetched remote tip.
|
|
398
|
+
* Falls back to the local branch when there is no reachable remote, so this
|
|
399
|
+
* still works offline and in never-pushed repos.
|
|
400
|
+
*/
|
|
401
|
+
async function resolveFreshBase(git, opts = {}) {
|
|
402
|
+
const defaultBranch = await resolveDefaultBranch(git);
|
|
403
|
+
if (!opts.noFetch) {
|
|
404
|
+
if (opts.spinner)
|
|
405
|
+
opts.spinner.text = `Fetching origin/${defaultBranch}...`;
|
|
406
|
+
await fetchQuietly(git, defaultBranch);
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
await git.revparse([`refs/remotes/origin/${defaultBranch}`]);
|
|
410
|
+
return `origin/${defaultBranch}`;
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// No remote-tracking ref (offline first-run, local-only repo): use the local
|
|
414
|
+
// branch if it exists, else whatever HEAD is. Never fail worktree creation.
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
await git.revparse([defaultBranch]);
|
|
418
|
+
return defaultBranch;
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return 'HEAD';
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Branches a LIVE agent is currently announcing work on.
|
|
426
|
+
*
|
|
427
|
+
* Git metadata alone cannot tell "abandoned" from "someone is working here this
|
|
428
|
+
* second": both look like an old branch with uncommitted files. That ambiguity is
|
|
429
|
+
* not academic — a worktree in this repo was read as abandoned WIP and merged
|
|
430
|
+
* forward while its owning agent was mid-session, because the drift report only
|
|
431
|
+
* ever consulted git. The coordination DB knows the difference; join against it.
|
|
432
|
+
*
|
|
433
|
+
* Liveness uses the same heartbeat window as coordinate-file.sh so "live" means
|
|
434
|
+
* one thing across the system. Fail-open: an unreadable DB marks nothing active.
|
|
435
|
+
*/
|
|
436
|
+
export function liveAgentBranches(mainRoot) {
|
|
437
|
+
// Validate like coordinate-file.sh does, or the same env var means "120s" to
|
|
438
|
+
// the hook and "protection off" to us: parseInt('-1') is truthy, and a
|
|
439
|
+
// negative window matches no rows.
|
|
440
|
+
const raw = Number.parseInt(process.env.UAP_COORD_LIVE_SECONDS ?? '', 10);
|
|
441
|
+
const windowSeconds = Number.isFinite(raw) && raw > 0 ? raw : 120;
|
|
442
|
+
const branches = new Set();
|
|
443
|
+
const dbPath = join(mainRoot, 'agents', 'data', 'coordination', 'coordination.db');
|
|
444
|
+
if (!existsSync(dbPath))
|
|
445
|
+
return { branches, readable: true }; // genuinely no coordination: idle
|
|
446
|
+
let db = null;
|
|
447
|
+
try {
|
|
448
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
449
|
+
// Readers rarely block in WAL, but SQLITE_BUSY is likeliest exactly when
|
|
450
|
+
// agents ARE live — the moment a fail-open would be most wrong.
|
|
451
|
+
db.pragma('busy_timeout = 10000');
|
|
452
|
+
// UNION the two writers. An agent that has registered and is heartbeating
|
|
453
|
+
// but has no OPEN announcement — just started, only reading, or its
|
|
454
|
+
// announcements were completed at session end — is still live.
|
|
455
|
+
const rows = db
|
|
456
|
+
.prepare(`SELECT DISTINCT branch FROM (
|
|
457
|
+
SELECT wa.worktree_branch AS branch
|
|
458
|
+
FROM work_announcements wa
|
|
459
|
+
LEFT JOIN agent_registry ar ON ar.id = wa.agent_id
|
|
460
|
+
WHERE wa.completed_at IS NULL
|
|
461
|
+
AND wa.worktree_branch IS NOT NULL
|
|
462
|
+
AND COALESCE(ar.status, 'active') = 'active'
|
|
463
|
+
AND (strftime('%s','now')
|
|
464
|
+
- strftime('%s', COALESCE(ar.last_heartbeat, wa.announced_at))) < :win
|
|
465
|
+
UNION
|
|
466
|
+
SELECT ar2.worktree_branch AS branch
|
|
467
|
+
FROM agent_registry ar2
|
|
468
|
+
WHERE ar2.status = 'active'
|
|
469
|
+
AND ar2.worktree_branch IS NOT NULL
|
|
470
|
+
AND (strftime('%s','now') - strftime('%s', ar2.last_heartbeat)) < :win
|
|
471
|
+
)`)
|
|
472
|
+
.all({ win: windowSeconds });
|
|
473
|
+
for (const r of rows)
|
|
474
|
+
if (r.branch)
|
|
475
|
+
branches.add(r.branch);
|
|
476
|
+
return { branches, readable: true };
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// Could not read it. Say so — do not let "unknown" masquerade as "idle".
|
|
480
|
+
return { branches, readable: false };
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
try {
|
|
484
|
+
db?.close();
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
/* ignore */
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Measure one worktree's drift against the integration ref. Never throws.
|
|
493
|
+
*
|
|
494
|
+
* Kept to ONE git process on the common path. The first version spent four
|
|
495
|
+
* (`rev-parse`, two `rev-list`, `status`) which, across this repo's 152
|
|
496
|
+
* worktrees, ran ~600 sequential git invocations — comfortably past the 15s
|
|
497
|
+
* budget the session banner allows, so the advisory was always killed before it
|
|
498
|
+
* printed while still costing the full 15 seconds.
|
|
499
|
+
*
|
|
500
|
+
* @param branch pre-resolved from `git worktree list --porcelain`, which already
|
|
501
|
+
* reports it — re-asking per worktree was a free process we were paying for.
|
|
502
|
+
* @param withDirty `git status` is by far the most expensive call (it stats the
|
|
503
|
+
* whole tree). Only worth it for worktrees that already look interesting.
|
|
504
|
+
*/
|
|
505
|
+
export async function measureDrift(worktreePath, integrationRef, branch, withDirty = true) {
|
|
506
|
+
try {
|
|
507
|
+
const wtGit = simpleGit(worktreePath);
|
|
508
|
+
const resolved = branch ?? (await wtGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
509
|
+
// One symmetric-difference call replaces two rev-lists. Note the THREE dots:
|
|
510
|
+
// `--left-right --count a...b` emits "<behind>\t<ahead>" relative to the merge base.
|
|
511
|
+
const raw = await wtGit.raw([
|
|
512
|
+
'rev-list',
|
|
513
|
+
'--left-right',
|
|
514
|
+
'--count',
|
|
515
|
+
`${integrationRef}...HEAD`,
|
|
516
|
+
]);
|
|
517
|
+
const [behindRaw, aheadRaw] = raw.trim().split(/\s+/);
|
|
518
|
+
const behind = parseRevListCount(behindRaw ?? '0');
|
|
519
|
+
const ahead = parseRevListCount(aheadRaw ?? '0');
|
|
520
|
+
let dirty = 0;
|
|
521
|
+
if (withDirty) {
|
|
522
|
+
const status = await wtGit.status();
|
|
523
|
+
// status.files is the authoritative entry list; summing the category arrays
|
|
524
|
+
// double-counts, because a staged-and-modified file appears in both.
|
|
525
|
+
dirty = status.files.length;
|
|
526
|
+
}
|
|
527
|
+
const name = worktreePath.split('.worktrees/')[1] || worktreePath;
|
|
528
|
+
return { name, path: worktreePath, branch: resolved, behind, ahead, dirty };
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** All linked worktrees under .worktrees/, with their branches. */
|
|
535
|
+
async function listWorktreeRefs(git) {
|
|
536
|
+
try {
|
|
537
|
+
const raw = await git.raw(['worktree', 'list', '--porcelain']);
|
|
538
|
+
const refs = [];
|
|
539
|
+
let current = null;
|
|
540
|
+
for (const line of raw.split('\n')) {
|
|
541
|
+
if (line.startsWith('worktree ')) {
|
|
542
|
+
current = line.replace('worktree ', '').trim();
|
|
543
|
+
}
|
|
544
|
+
else if (line.startsWith('branch ') && current) {
|
|
545
|
+
if (current.includes('.worktrees')) {
|
|
546
|
+
refs.push({ path: current, branch: line.replace('branch refs/heads/', '').trim() });
|
|
547
|
+
}
|
|
548
|
+
current = null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return refs;
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Run an async mapper over items with bounded concurrency.
|
|
559
|
+
* Unbounded would spawn 152×N git processes at once and thrash the machine;
|
|
560
|
+
* sequential leaves the CPU idle while each git process does IO.
|
|
561
|
+
*/
|
|
562
|
+
async function mapLimit(items, limit, fn) {
|
|
563
|
+
const out = new Array(items.length);
|
|
564
|
+
let next = 0;
|
|
565
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
566
|
+
while (next < items.length) {
|
|
567
|
+
const i = next++;
|
|
568
|
+
out[i] = await fn(items[i]);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
await Promise.all(workers);
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* `uap worktree sync` — pull the integration branch into a worktree MID-FLIGHT.
|
|
576
|
+
* The old flow only synced at `finish`, which is the most expensive possible
|
|
577
|
+
* moment to discover a conflict. This makes re-basing cheap and routine.
|
|
578
|
+
*/
|
|
579
|
+
async function syncWorktree(cwd, git, opts = {}) {
|
|
580
|
+
const integrationRef = await resolveIntegrationRef(git);
|
|
581
|
+
const spinner = ora(`Syncing with ${integrationRef}...`).start();
|
|
582
|
+
let targets;
|
|
583
|
+
if (opts.all) {
|
|
584
|
+
targets = (await listWorktreeRefs(git)).map((r) => r.path);
|
|
585
|
+
}
|
|
586
|
+
else if (opts.id) {
|
|
587
|
+
const found = findWorktreeById(cwd, opts.id);
|
|
588
|
+
if (!found) {
|
|
589
|
+
spinner.fail(`Worktree with ID ${opts.id} not found`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
targets = [found];
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
// Default: the worktree we are standing in.
|
|
596
|
+
targets = [cwd];
|
|
597
|
+
}
|
|
598
|
+
await fetchQuietly(git, integrationRef.replace(/^origin\//, ''));
|
|
599
|
+
spinner.stop();
|
|
600
|
+
let synced = 0;
|
|
601
|
+
let conflicted = 0;
|
|
602
|
+
let already = 0;
|
|
603
|
+
let skipped = 0;
|
|
604
|
+
// Probe concurrently, then merge serially: merges mutate working trees and
|
|
605
|
+
// their output should stay readable and attributable.
|
|
606
|
+
const drifts = await mapLimit(targets, 8, (t) => measureDrift(t, integrationRef));
|
|
607
|
+
for (const drift of drifts) {
|
|
608
|
+
if (!drift)
|
|
609
|
+
continue;
|
|
610
|
+
if (drift.behind === 0) {
|
|
611
|
+
already++;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// Merging into a dirty tree aborts with "local changes would be overwritten",
|
|
615
|
+
// which the old code reported as a CONFLICT — misleading, and with --all it
|
|
616
|
+
// would say that about every dirty worktree in the repo.
|
|
617
|
+
if (drift.dirty > 0) {
|
|
618
|
+
skipped++;
|
|
619
|
+
console.log(chalk.yellow(` ⏭ ${drift.name}: ${drift.dirty} uncommitted change(s) — commit or stash first ` +
|
|
620
|
+
`(${drift.behind} behind)`));
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const wtGit = simpleGit(drift.path);
|
|
624
|
+
try {
|
|
625
|
+
await wtGit.raw(['merge', '--no-edit', integrationRef]);
|
|
626
|
+
synced++;
|
|
627
|
+
console.log(chalk.green(` ✔ ${drift.name}: merged ${drift.behind} commit(s) from ${integrationRef}`));
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
conflicted++;
|
|
631
|
+
console.log(chalk.red(` ✖ ${drift.name}: CONFLICT merging ${integrationRef} ` +
|
|
632
|
+
`(${drift.behind} behind). Resolve in ${drift.path}, then commit.`));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
console.log('');
|
|
636
|
+
console.log(chalk.bold(`Sync: ${synced} updated, ${already} already current, ` +
|
|
637
|
+
`${skipped} skipped (dirty), ${conflicted} need manual resolution`));
|
|
638
|
+
if (conflicted > 0) {
|
|
639
|
+
process.exitCode = 1;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* `uap worktree hygiene` — surface drift and at-risk work across ALL worktrees.
|
|
644
|
+
* Silent accumulation is how work gets lost: a branch nobody re-synced for a
|
|
645
|
+
* thousand commits will either conflict violently or be quietly abandoned.
|
|
646
|
+
*/
|
|
647
|
+
async function worktreeHygiene(_cwd, git, opts = {}) {
|
|
648
|
+
const integrationRef = await resolveIntegrationRef(git);
|
|
649
|
+
// Refresh the remote tip first: measuring against a stale origin/master
|
|
650
|
+
// under-reports drift, which is precisely what this report exists to catch.
|
|
651
|
+
await fetchQuietly(git, integrationRef.replace(/^origin\//, ''));
|
|
652
|
+
const refs = await listWorktreeRefs(git);
|
|
653
|
+
// Two passes so `git status` — the expensive call — runs only where it can
|
|
654
|
+
// change the verdict. Everything else needs one git process per worktree.
|
|
655
|
+
const cheap = await mapLimit(refs, 8, (r) => measureDrift(r.path, integrationRef, r.branch, false));
|
|
656
|
+
const measured = await mapLimit(cheap.filter((d) => d !== null), 8, async (d) => (d.ahead > 0 ? ((await measureDrift(d.path, integrationRef, d.branch, true)) ?? d) : d));
|
|
657
|
+
// Mark the worktrees someone is actually working in, so "old" is never read as
|
|
658
|
+
// "abandoned". A live worktree must never be offered up for pruning.
|
|
659
|
+
const live = liveAgentBranches(await mainRootOf(git)).branches;
|
|
660
|
+
const drifts = measured.map((d) => ({ ...d, active: live.has(d.branch) }));
|
|
661
|
+
const atRisk = drifts.filter((d) => d.ahead > 0 || d.dirty > 0);
|
|
662
|
+
const summary = summarizeHygiene(drifts, integrationRef);
|
|
663
|
+
if (opts.brief) {
|
|
664
|
+
if (summary)
|
|
665
|
+
console.log(summary);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
console.log(chalk.bold(`\n🧹 Worktree hygiene (vs ${integrationRef})\n`));
|
|
669
|
+
if (drifts.length === 0) {
|
|
670
|
+
console.log(chalk.dim('No linked worktrees.'));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
drifts.sort((a, b) => b.behind - a.behind);
|
|
674
|
+
console.log('| Worktree | Behind | Ahead | Dirty | Status |');
|
|
675
|
+
console.log('|----------|--------|-------|-------|--------|');
|
|
676
|
+
for (const d of drifts) {
|
|
677
|
+
// ACTIVE outranks every other label: an old, drifted, dirty worktree with a
|
|
678
|
+
// live agent in it is not stale work, it is work in progress.
|
|
679
|
+
const status = d.active
|
|
680
|
+
? chalk.cyan('ACTIVE — agent working here')
|
|
681
|
+
: d.ahead > 0 || d.dirty > 0
|
|
682
|
+
? chalk.yellow('UNMERGED WORK')
|
|
683
|
+
: d.behind > STALE_BEHIND_LIMIT
|
|
684
|
+
? chalk.red('STALE — safe to prune')
|
|
685
|
+
: chalk.dim('clean');
|
|
686
|
+
console.log(`| ${d.name} | ${d.behind} | ${d.ahead} | ${d.dirty} | ${status} |`);
|
|
687
|
+
}
|
|
688
|
+
console.log('');
|
|
689
|
+
if (summary)
|
|
690
|
+
console.log(summary);
|
|
691
|
+
if (atRisk.length > 0) {
|
|
692
|
+
console.log(chalk.dim(' Reconcile with: uap worktree sync --id <id> then uap worktree pr <id>'));
|
|
693
|
+
console.log(chalk.dim(' Abandon with: uap worktree cleanup <id>'));
|
|
694
|
+
}
|
|
695
|
+
console.log(chalk.dim(' Bulk prune merged//stale worktrees: uap worktree prune --older-than 30'));
|
|
696
|
+
console.log('');
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* One-line advisory for session-start. Returns '' when nothing needs attention,
|
|
700
|
+
* so the caller can stay silent on a healthy repo.
|
|
701
|
+
*/
|
|
702
|
+
export function summarizeHygiene(drifts, integrationRef) {
|
|
703
|
+
if (drifts.length === 0)
|
|
704
|
+
return '';
|
|
705
|
+
// Active worktrees are excluded from BOTH risk counts: they are neither
|
|
706
|
+
// abandoned nor at risk, they are being worked on. Counting them as drift is
|
|
707
|
+
// how a live worktree gets mistaken for salvage.
|
|
708
|
+
const idle = drifts.filter((d) => !d.active);
|
|
709
|
+
const active = drifts.filter((d) => d.active);
|
|
710
|
+
const atRisk = idle.filter((d) => d.ahead > 0 || d.dirty > 0);
|
|
711
|
+
const stale = idle.filter((d) => d.behind > STALE_BEHIND_LIMIT);
|
|
712
|
+
if (atRisk.length === 0 && stale.length === 0 && active.length === 0)
|
|
713
|
+
return '';
|
|
714
|
+
const parts = [];
|
|
715
|
+
if (atRisk.length > 0) {
|
|
716
|
+
parts.push(`${atRisk.length} worktree(s) hold unmerged or uncommitted work`);
|
|
717
|
+
}
|
|
718
|
+
if (stale.length > 0) {
|
|
719
|
+
parts.push(`${stale.length} are >${STALE_BEHIND_LIMIT} commits behind ${integrationRef}`);
|
|
720
|
+
}
|
|
721
|
+
if (active.length > 0) {
|
|
722
|
+
parts.push(`${active.length} have a LIVE agent working in them (do not reclaim)`);
|
|
723
|
+
}
|
|
724
|
+
if (parts.length === 0)
|
|
725
|
+
return '';
|
|
726
|
+
const ranked = idle.length > 0 ? idle : drifts;
|
|
727
|
+
const worst = ranked.reduce((a, b) => (b.behind > a.behind ? b : a));
|
|
728
|
+
return (`⚠️ Worktree drift: ${parts.join('; ')} ` +
|
|
729
|
+
`(worst: ${worst.name} at ${worst.behind} behind). Run \`uap worktree hygiene\`.`);
|
|
730
|
+
}
|
|
731
|
+
/** Resolve a worktree directory from its numeric ID. */
|
|
732
|
+
function findWorktreeById(cwd, id) {
|
|
733
|
+
try {
|
|
734
|
+
const worktreesDir = join(cwd, '.worktrees');
|
|
735
|
+
const entries = readdirSync(worktreesDir);
|
|
736
|
+
const match = entries.find((e) => e.startsWith(`${id.padStart(3, '0')}-`));
|
|
737
|
+
return match ? join(worktreesDir, match) : null;
|
|
286
738
|
}
|
|
287
739
|
catch {
|
|
288
|
-
|
|
740
|
+
return null;
|
|
289
741
|
}
|
|
290
742
|
}
|
|
291
743
|
export function parseRevListCount(output) {
|
|
@@ -485,21 +937,101 @@ async function pruneStaleWorktrees(cwd, options) {
|
|
|
485
937
|
const { rmSync } = await import('fs');
|
|
486
938
|
const db = getWorktreeDb(cwd);
|
|
487
939
|
const days = options.olderThan;
|
|
488
|
-
|
|
489
|
-
//
|
|
940
|
+
// created_at is stored in SECONDS (`strftime('%s','now')`). Comparing it to a
|
|
941
|
+
// MILLISECOND cutoff made `created_at < cutoff` true for every row that will
|
|
942
|
+
// ever exist, so `--older-than` filtered nothing: a worktree created a minute
|
|
943
|
+
// ago was always a prune candidate. Verified against this repo's registry —
|
|
944
|
+
// 117/117 rows selected even at `--older-than 3650`. Compare in one unit.
|
|
945
|
+
const cutoffSeconds = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60;
|
|
490
946
|
const stale = db.prepare(`
|
|
491
|
-
SELECT id, slug, worktree_path, created_at, status
|
|
947
|
+
SELECT id, slug, branch_name, worktree_path, created_at, status
|
|
492
948
|
FROM worktrees
|
|
493
949
|
WHERE created_at < ?
|
|
494
|
-
`).all(
|
|
950
|
+
`).all(cutoffSeconds);
|
|
495
951
|
if (stale.length === 0) {
|
|
496
952
|
console.log(chalk.green(`No worktrees older than ${days} days found`));
|
|
497
953
|
return;
|
|
498
954
|
}
|
|
955
|
+
// NEVER reclaim a worktree an agent is live in. Age is not abandonment: a
|
|
956
|
+
// month-old worktree can have someone working in it right now, and prune
|
|
957
|
+
// deletes the directory — uncommitted work included. This is the one place in
|
|
958
|
+
// the system where getting "stale" wrong is unrecoverable.
|
|
959
|
+
const liveness = liveAgentBranches(await mainRootOf(simpleGit(cwd)));
|
|
960
|
+
if (!liveness.readable) {
|
|
961
|
+
// FAIL CLOSED. An empty set means both "nobody is live" and "I could not
|
|
962
|
+
// tell" — and the consequence of guessing wrong here is rm -rf of somebody's
|
|
963
|
+
// uncommitted work. Refuse rather than assume the repo is idle.
|
|
964
|
+
console.log(chalk.red('Refusing to prune: the coordination DB could not be read, so live agents cannot be ruled out.'));
|
|
965
|
+
console.log(chalk.dim(' Re-run with --force to prune anyway.'));
|
|
966
|
+
if (!options.force)
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const liveBranches = liveness.branches;
|
|
970
|
+
// Containment: only ever delete inside <cwd>/.worktrees/. worktree_path comes
|
|
971
|
+
// from a registry under .uap/, which is exempt from the worktree write guard —
|
|
972
|
+
// so a planted row must not be able to aim rmSync at the main checkout.
|
|
973
|
+
const worktreesRoot = resolve(cwd, '.worktrees');
|
|
974
|
+
const protectedRows = [];
|
|
975
|
+
const outOfScope = [];
|
|
976
|
+
const prunable = stale.filter((row) => {
|
|
977
|
+
const abs = resolve(row.worktree_path);
|
|
978
|
+
if (abs !== worktreesRoot && !abs.startsWith(worktreesRoot + sep)) {
|
|
979
|
+
outOfScope.push(row);
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
// Prefer the branch the registry recorded; only consult git if it is absent.
|
|
983
|
+
// Strip GIT_* from the child env: git resolves GIT_DIR before cwd, so an
|
|
984
|
+
// ambient value makes every worktree report the SAME branch — which would
|
|
985
|
+
// mark every live worktree prunable in one shot. (This repo has hit GIT_DIR
|
|
986
|
+
// poisoning before; see the enforcers' _clean_env.)
|
|
987
|
+
let branch = row.branch_name ?? '';
|
|
988
|
+
if (!branch) {
|
|
989
|
+
try {
|
|
990
|
+
const env = { ...process.env };
|
|
991
|
+
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_PREFIX']) {
|
|
992
|
+
delete env[k];
|
|
993
|
+
}
|
|
994
|
+
branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
995
|
+
cwd: row.worktree_path,
|
|
996
|
+
encoding: 'utf-8',
|
|
997
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
998
|
+
timeout: 10_000,
|
|
999
|
+
env,
|
|
1000
|
+
}).trim();
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
// Cannot determine the branch => cannot prove it is idle => protect it.
|
|
1004
|
+
protectedRows.push(row);
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (branch && liveBranches.has(branch)) {
|
|
1009
|
+
protectedRows.push(row);
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
return true;
|
|
1013
|
+
});
|
|
1014
|
+
if (outOfScope.length > 0) {
|
|
1015
|
+
console.log(chalk.red(`Refusing ${outOfScope.length} registry row(s) whose path is outside ${worktreesRoot}: ` +
|
|
1016
|
+
`${outOfScope.map((r) => r.worktree_path).join(', ')}`));
|
|
1017
|
+
}
|
|
1018
|
+
if (protectedRows.length > 0) {
|
|
1019
|
+
console.log(chalk.cyan(`Skipping ${protectedRows.length} worktree(s) with a LIVE agent working in them: ` +
|
|
1020
|
+
`${protectedRows.map((r) => r.slug).join(', ')}`));
|
|
1021
|
+
}
|
|
1022
|
+
if (prunable.length === 0) {
|
|
1023
|
+
console.log(chalk.green('Nothing prunable — every candidate has a live agent in it.'));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
stale.length = 0;
|
|
1027
|
+
stale.push(...prunable);
|
|
499
1028
|
console.log(chalk.bold(`Found ${stale.length} stale worktree(s) older than ${days} days:`));
|
|
500
1029
|
console.log('');
|
|
501
1030
|
for (const wt of stale) {
|
|
502
|
-
|
|
1031
|
+
// created_at is SECONDS; dividing a seconds-vs-milliseconds difference by a
|
|
1032
|
+
// day in ms printed every worktree as ~20,600 days old — the visible symptom
|
|
1033
|
+
// of the cutoff bug fixed above.
|
|
1034
|
+
const age = Math.floor((Date.now() / 1000 - wt.created_at) / (60 * 60 * 24));
|
|
503
1035
|
const statusColor = wt.status === 'cleaned' ? chalk.yellow : chalk.dim;
|
|
504
1036
|
console.log(` ${wt.id}: ${wt.slug} (${age} days old) - ${statusColor(wt.status)}`);
|
|
505
1037
|
}
|
|
@@ -516,24 +1048,26 @@ async function pruneStaleWorktrees(cwd, options) {
|
|
|
516
1048
|
return;
|
|
517
1049
|
}
|
|
518
1050
|
}
|
|
519
|
-
//
|
|
1051
|
+
// DRY RUN: report and return BEFORE touching anything. This check used to sit
|
|
1052
|
+
// AFTER the delete loop, so `--dry-run` deleted every directory and then
|
|
1053
|
+
// printed "[DRY RUN] Would prune" — and because dryRun also skipped the
|
|
1054
|
+
// confirmation prompt, it did so without asking. The flag documented as
|
|
1055
|
+
// "Preview without making changes" was the most destructive way to invoke it.
|
|
1056
|
+
if (options.dryRun) {
|
|
1057
|
+
console.log(chalk.yellow(`[DRY RUN] Would prune ${stale.length} worktree(s) and remove ` +
|
|
1058
|
+
`${stale.filter((wt) => existsSync(wt.worktree_path)).length} directory(ies). Nothing was changed.`));
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
520
1061
|
let pruned = 0;
|
|
521
1062
|
let directoriesRemoved = 0;
|
|
522
1063
|
for (const wt of stale) {
|
|
523
|
-
// Remove from DB
|
|
524
1064
|
db.prepare('DELETE FROM worktrees WHERE id = ?').run(wt.id);
|
|
525
1065
|
pruned++;
|
|
526
|
-
// Remove directory
|
|
527
1066
|
if (existsSync(wt.worktree_path)) {
|
|
528
1067
|
rmSync(wt.worktree_path, { recursive: true, force: true });
|
|
529
1068
|
directoriesRemoved++;
|
|
530
1069
|
}
|
|
531
1070
|
}
|
|
532
|
-
|
|
533
|
-
console.log(chalk.yellow(`[DRY RUN] Would prune ${pruned} worktree(s), remove ${directoriesRemoved} directory(ies)`));
|
|
534
|
-
}
|
|
535
|
-
else {
|
|
536
|
-
console.log(chalk.green(`Pruned ${pruned} worktree(s), removed ${directoriesRemoved} directory(ies)`));
|
|
537
|
-
}
|
|
1071
|
+
console.log(chalk.green(`Pruned ${pruned} worktree(s), removed ${directoriesRemoved} directory(ies)`));
|
|
538
1072
|
}
|
|
539
1073
|
//# sourceMappingURL=worktree.js.map
|