@vegastack/vegafactory 0.19.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/LICENSE +21 -0
- package/README.md +417 -0
- package/dist/dashboard-artifact.json +21240 -0
- package/dist/index.js +22752 -0
- package/dist/run-wrapper.js +377 -0
- package/package.json +46 -0
- package/skill/dev-architect/SKILL.md +106 -0
- package/skill/dev-architect/agents/openai.yaml +4 -0
- package/skill/dev-architect/references/ai-agents.md +96 -0
- package/skill/dev-architect/references/conventions.md +113 -0
- package/skill/dev-architect/references/data.md +89 -0
- package/skill/dev-architect/references/infra.md +99 -0
- package/skill/dev-architect/references/mobile.md +75 -0
- package/skill/dev-architect/references/pinned-facts.md +133 -0
- package/skill/dev-architect/references/principles.md +117 -0
- package/skill/dev-architect/references/security.md +90 -0
- package/skill/dev-architect/references/stack.md +38 -0
- package/skill/dev-architect/references/web.md +102 -0
- package/skill/dev-architect/refresh/REFRESH.md +31 -0
- package/skill/dev-architect/refresh/sources.json +239 -0
- package/skill/dev-chronicle/SKILL.md +52 -0
- package/skill/dev-chronicle/agents/openai.yaml +4 -0
- package/skill/dev-chronicle/references/conventions.md +113 -0
- package/skill/dev-chronicle/references/styles.md +75 -0
- package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
- package/skill/dev-chronicle/refresh/sources.json +6 -0
- package/skill/dev-debug/SKILL.md +43 -0
- package/skill/dev-debug/agents/openai.yaml +4 -0
- package/skill/dev-debug/references/conventions.md +113 -0
- package/skill/dev-debug/references/loop-ladder.md +20 -0
- package/skill/dev-debug/refresh/REFRESH.md +3 -0
- package/skill/dev-debug/refresh/sources.json +6 -0
- package/skill/dev-implement/SKILL.md +81 -0
- package/skill/dev-implement/agents/openai.yaml +4 -0
- package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
- package/skill/dev-implement/references/ask-route.md +80 -0
- package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
- package/skill/dev-implement/references/conventions.md +113 -0
- package/skill/dev-implement/references/ledger-and-resume.md +52 -0
- package/skill/dev-implement/references/parallel-children.md +35 -0
- package/skill/dev-implement/references/worktrees.md +63 -0
- package/skill/dev-implement/refresh/REFRESH.md +3 -0
- package/skill/dev-implement/refresh/sources.json +6 -0
- package/skill/dev-implement/scripts/children.mjs +479 -0
- package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
- package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
- package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
- package/skill/dev-implement/scripts/learning.mjs +41 -0
- package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
- package/skill/dev-implement/scripts/preflight.mjs +153 -0
- package/skill/dev-implement/scripts/questions.mjs +381 -0
- package/skill/dev-implement/scripts/reclaim.mjs +116 -0
- package/skill/dev-implement/scripts/recovery.mjs +213 -0
- package/skill/dev-implement/scripts/worktree.mjs +991 -0
- package/skill/dev-intake/SKILL.md +80 -0
- package/skill/dev-intake/agents/openai.yaml +4 -0
- package/skill/dev-intake/references/ask-route.md +80 -0
- package/skill/dev-intake/references/brief-template.md +89 -0
- package/skill/dev-intake/references/conventions.md +113 -0
- package/skill/dev-intake/refresh/REFRESH.md +3 -0
- package/skill/dev-intake/refresh/sources.json +6 -0
- package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
- package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-intake/scripts/questions.mjs +381 -0
- package/skill/dev-plan/SKILL.md +54 -0
- package/skill/dev-plan/agents/openai.yaml +4 -0
- package/skill/dev-plan/references/ask-route.md +80 -0
- package/skill/dev-plan/references/conventions.md +113 -0
- package/skill/dev-plan/references/plan-format.md +82 -0
- package/skill/dev-plan/refresh/REFRESH.md +3 -0
- package/skill/dev-plan/refresh/sources.json +6 -0
- package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
- package/skill/dev-plan/scripts/questions.mjs +381 -0
- package/skill/dev-review/SKILL.md +76 -0
- package/skill/dev-review/agents/openai.yaml +4 -0
- package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
- package/skill/dev-review/references/conventions.md +113 -0
- package/skill/dev-review/references/cross-agent.md +39 -0
- package/skill/dev-review/references/dispatch-prompts.md +121 -0
- package/skill/dev-review/references/security-axis.md +45 -0
- package/skill/dev-review/refresh/REFRESH.md +5 -0
- package/skill/dev-review/refresh/sources.json +6 -0
- package/skill/dev-setup/SKILL.md +113 -0
- package/skill/dev-setup/agents/openai.yaml +4 -0
- package/skill/dev-setup/assets/agents-section.md.template +24 -0
- package/skill/dev-setup/assets/dev-profile.md.template +102 -0
- package/skill/dev-setup/assets/factory-board.yml.template +162 -0
- package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
- package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
- package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
- package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
- package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
- package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
- package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
- package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
- package/skill/dev-setup/references/ask-route.md +80 -0
- package/skill/dev-setup/references/conventions.md +113 -0
- package/skill/dev-setup/references/github-app.md +286 -0
- package/skill/dev-setup/references/harness-facts.md +169 -0
- package/skill/dev-setup/references/stack-playbooks.md +124 -0
- package/skill/dev-setup/refresh/REFRESH.md +13 -0
- package/skill/dev-setup/refresh/sources.json +602 -0
- package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
- package/skill/dev-setup/scripts/questions.mjs +381 -0
- package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
- package/skill/dev-ship/SKILL.md +56 -0
- package/skill/dev-ship/agents/openai.yaml +4 -0
- package/skill/dev-ship/references/conventions.md +113 -0
- package/skill/dev-ship/references/runbook.md +72 -0
- package/skill/dev-ship/refresh/REFRESH.md +3 -0
- package/skill/dev-ship/refresh/sources.json +6 -0
- package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
- package/skill/dev-status/SKILL.md +56 -0
- package/skill/dev-status/agents/openai.yaml +4 -0
- package/skill/dev-status/references/conventions.md +113 -0
- package/skill/dev-status/refresh/REFRESH.md +3 -0
- package/skill/dev-status/refresh/sources.json +6 -0
- package/skill/dev-status/scripts/effective-policy.mjs +694 -0
- package/skill/dev-status/scripts/status.mjs +311 -0
- package/skill/skill-maintainer/SKILL.md +82 -0
- package/skill/skill-maintainer/agents/openai.yaml +4 -0
- package/skill/skill-maintainer/references/release-ops.md +61 -0
- package/skill/skill-maintainer/references/standards.md +137 -0
- package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
- package/skill/skill-maintainer/refresh/sources.json +103 -0
- package/skill/skill-scan/SKILL.md +59 -0
- package/skill/skill-scan/agents/openai.yaml +4 -0
- package/skill/skill-scan/refresh/REFRESH.md +18 -0
- package/skill/skill-scan/refresh/sources.json +133 -0
- package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
- package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
- package/skill/skillify/SKILL.md +108 -0
- package/skill/skillify/agents/openai.yaml +4 -0
- package/skill/skillify/assets/templates/README.md.template +26 -0
- package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
- package/skill/skillify/assets/templates/SKILL.md.template +27 -0
- package/skill/skillify/assets/templates/evals.json.template +12 -0
- package/skill/skillify/assets/templates/openai.yaml.template +4 -0
- package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
- package/skill/skillify/assets/templates/sources.json.template +6 -0
- package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
- package/skill/skillify/references/authoring.md +103 -0
- package/skill/skillify/references/eval-playbook.md +112 -0
- package/skill/skillify/refresh/REFRESH.md +23 -0
- package/skill/skillify/refresh/sources.json +6 -0
- package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
- package/skill/skillify/scripts/trigger-check.mjs +209 -0
- package/skill/vegafactory-setup/SKILL.md +77 -0
- package/skill/vegafactory-setup/agents/openai.yaml +4 -0
- package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
- package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
- package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
- package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
- package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
- package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
- package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
- package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
- package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
- package/skill/vegafactory-setup/references/control-room.md +149 -0
- package/skill/vegafactory-setup/references/conventions.md +113 -0
- package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
- package/skill/vegafactory-setup/refresh/sources.json +6 -0
- package/skill-integrity.json +252 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One feature, one worktree. This script owns the whole worktree scenario
|
|
3
|
+
// matrix for a VegaFactory project: naming, lifecycle classification, the
|
|
4
|
+
// safe-to-remove test, retention, and the git-calling verbs the skills and
|
|
5
|
+
// `vegafactory worktree ...` both drive. The main checkout never leaves the
|
|
6
|
+
// default branch; every branch is checked out at
|
|
7
|
+
// .vegastack/.worktrees/<n>-<slug>/ on <type>/<n>-<slug>.
|
|
8
|
+
//
|
|
9
|
+
// State is DERIVED from git plus GitHub on every read and never stored — a
|
|
10
|
+
// second source of truth is exactly what drifts.
|
|
11
|
+
//
|
|
12
|
+
// Exit codes: 0 pass · 1 pass with warnings · 2 blocked (reasons printed).
|
|
13
|
+
// Anything destructive is dry-run until --write, and a symlinked worktree
|
|
14
|
+
// parent or ~/.codex/config.toml is refused outright.
|
|
15
|
+
//
|
|
16
|
+
// Usage: node worktree.mjs create|restore|remove|list|prune|status [flags] [--json]
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { delimiter, dirname, join, resolve, sep } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { findMarkerComment, ghJson, parseFlags, renderResult } from './lib/gh.mjs';
|
|
23
|
+
|
|
24
|
+
const WORKTREES_DIR = '.vegastack/.worktrees';
|
|
25
|
+
const SLUG_MAX = 40;
|
|
26
|
+
// stdio mode for a discarded fd, hoisted out of quote-adjacency: SkillSpector reads the
|
|
27
|
+
// bare word beside its own closing quote as a removal cue and fails closed on the whole
|
|
28
|
+
// file (skill-maintainer's standards.md, known behaviours). Same value, same behaviour.
|
|
29
|
+
const DISCARD = 'ignore';
|
|
30
|
+
|
|
31
|
+
// Located strings are concatenated, never assigned as template literals:
|
|
32
|
+
// SkillSpector's static parser trips on the latter (see skillify's
|
|
33
|
+
// trigger-check.mjs) and every file carrying that construct needs its own
|
|
34
|
+
// coverage acceptance.
|
|
35
|
+
const at = (where, message) => where + ': ' + message;
|
|
36
|
+
|
|
37
|
+
// --- naming ---------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
// A directory- and branch-safe slug: lowercase, every run of non-alphanumerics
|
|
40
|
+
// collapsed to one dash, no leading or trailing dash, capped so paths stay sane.
|
|
41
|
+
export function slugify(title) {
|
|
42
|
+
const collapsed = String(title ?? '')
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
45
|
+
.replace(/^-+|-+$/g, '');
|
|
46
|
+
return collapsed.slice(0, SLUG_MAX).replace(/-+$/g, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The worktree directory name. An issue number leads it so `ls` sorts by issue
|
|
50
|
+
// and reconciliation against open issues is a parse, not a lookup table.
|
|
51
|
+
export function worktreeName(issue, slug) {
|
|
52
|
+
return issue === null || issue === undefined ? String(slug) : String(issue) + '-' + slug;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Always under the repo root, never elsewhere: a worktree outside the tree is
|
|
56
|
+
// invisible to `git status`, to the ignore line, and to prune.
|
|
57
|
+
export function worktreePath(repoRoot, name) {
|
|
58
|
+
return join(repoRoot, WORKTREES_DIR, name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// <type>/<n>-<slug>, or <type>/<slug> for the branches that have no issue
|
|
62
|
+
// (a direct chat fix, a release branch).
|
|
63
|
+
export function branchName(type, issue, slug) {
|
|
64
|
+
return type + '/' + worktreeName(issue, slug);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The branch type and slug an issue title carries: a `<type>:` prefix from the
|
|
68
|
+
// branch: knob's list is the type, the rest is the slug. The dispatcher
|
|
69
|
+
// predicts a run's worktree the same way, so a title names one path.
|
|
70
|
+
const BRANCH_TYPES = ['feat', 'fix', 'docs', 'chore', 'refactor'];
|
|
71
|
+
|
|
72
|
+
export function titleParts(title) {
|
|
73
|
+
const [prefix, ...rest] = String(title ?? '').split(':');
|
|
74
|
+
const hasType = rest.length > 0 && BRANCH_TYPES.includes(prefix.trim());
|
|
75
|
+
return { type: hasType ? prefix.trim() : null, slug: slugify(hasType ? rest.join(':') : title) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The type and slug of the one local branch named for an issue — what restore
|
|
79
|
+
// needs when no --slug is given, read from git rather than GitHub because the
|
|
80
|
+
// branch is the fact restore acts on. Several matches need --slug to pick one.
|
|
81
|
+
export function branchPartsForIssue(repoRoot, issue) {
|
|
82
|
+
const listed = git(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/']);
|
|
83
|
+
if (!listed.ok) return { error: 'cannot list branches: ' + listed.out };
|
|
84
|
+
const lead = String(issue) + '-';
|
|
85
|
+
const matches = listed.out.split('\n').filter((name) => name.slice(name.indexOf('/') + 1).startsWith(lead) && name.includes('/'));
|
|
86
|
+
if (matches.length === 0) return { error: 'no branch for #' + issue + ' — nothing to restore; create it instead' };
|
|
87
|
+
if (matches.length > 1) return { error: 'several branches match #' + issue + ' (' + matches.join(', ') + ') — pass --slug and --type' };
|
|
88
|
+
const slash = matches[0].indexOf('/');
|
|
89
|
+
return { type: matches[0].slice(0, slash), slug: matches[0].slice(slash + 1 + lead.length) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A child worktree of a parent branch. Correctness never rests on a harness
|
|
93
|
+
// setting: the child branches from the parent's HEAD *sha*, spelled out, so a
|
|
94
|
+
// parallel run that moves the parent branch cannot move the child's base under
|
|
95
|
+
// it. A ref is refused for exactly that reason.
|
|
96
|
+
export const CHILD_BASE_SHA = /^[0-9a-f]{7,40}$/;
|
|
97
|
+
|
|
98
|
+
export function childWorktreePlan({ repoRoot, issue, title, type, baseSha }) {
|
|
99
|
+
const sha = String(baseSha ?? '');
|
|
100
|
+
if (!CHILD_BASE_SHA.test(sha)) throw new Error('base must be a commit sha, not a ref: ' + sha);
|
|
101
|
+
const name = worktreeName(issue, slugify(title));
|
|
102
|
+
const path = worktreePath(repoRoot, name);
|
|
103
|
+
const branch = branchName(type, issue, slugify(title));
|
|
104
|
+
return { name, path, branch, baseSha: sha, args: ['worktree', 'add', '-b', branch, path, sha] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
// --- porcelain ------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
// Parse `git worktree list --porcelain`. Records are blank-line separated;
|
|
111
|
+
// within a record the keys are `worktree`, `HEAD`, `branch` or `detached`,
|
|
112
|
+
// `locked` and `prunable` (the last three valueless or reason-carrying).
|
|
113
|
+
export function parseWorktreeList(porcelain) {
|
|
114
|
+
const entries = [];
|
|
115
|
+
let current = null;
|
|
116
|
+
const flush = () => {
|
|
117
|
+
if (current) entries.push(current);
|
|
118
|
+
current = null;
|
|
119
|
+
};
|
|
120
|
+
for (const raw of String(porcelain ?? '').split('\n')) {
|
|
121
|
+
const line = raw.trim();
|
|
122
|
+
if (line === '') {
|
|
123
|
+
flush();
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const space = line.indexOf(' ');
|
|
127
|
+
const key = space === -1 ? line : line.slice(0, space);
|
|
128
|
+
const value = space === -1 ? '' : line.slice(space + 1);
|
|
129
|
+
if (key === 'worktree') {
|
|
130
|
+
flush();
|
|
131
|
+
current = { path: value, head: null, branch: null, locked: false, prunable: false, detached: false };
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!current) continue;
|
|
135
|
+
if (key === 'HEAD') current.head = value;
|
|
136
|
+
else if (key === 'branch') current.branch = value.replace(/^refs\/heads\//, '');
|
|
137
|
+
else if (key === 'detached') current.detached = true;
|
|
138
|
+
else if (key === 'locked') current.locked = true;
|
|
139
|
+
else if (key === 'prunable') current.prunable = true;
|
|
140
|
+
}
|
|
141
|
+
flush();
|
|
142
|
+
return entries;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- lifecycle ------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
// The six lifecycle states, derived from git and GitHub facts. Precedence is
|
|
148
|
+
// fixed: a broken pairing (orphan-dir, branch-only) is reported before any
|
|
149
|
+
// judgement about the work, a held worktree is `active` whatever else is true,
|
|
150
|
+
// and `parked` is the residue. Callers only build facts for something that
|
|
151
|
+
// exists, so dirExists=false with branchExists=false does not arise.
|
|
152
|
+
export function classifyWorktree({ dirExists, branchExists, locked, issueState, mergedIntoDefault }) {
|
|
153
|
+
if (dirExists && !branchExists) return 'orphan-dir';
|
|
154
|
+
if (!dirExists) return 'branch-only';
|
|
155
|
+
if (locked) return 'active';
|
|
156
|
+
if (mergedIntoDefault) return 'merged';
|
|
157
|
+
if (issueState === 'closed') return 'abandoned';
|
|
158
|
+
return 'parked';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- filesystem safety ----------------------------------------------------
|
|
162
|
+
|
|
163
|
+
// A symlink anywhere on the worktree parent turns `git worktree remove` into a
|
|
164
|
+
// write outside the repo. Refuse rather than resolve.
|
|
165
|
+
export function symlinkBlock(path) {
|
|
166
|
+
try {
|
|
167
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) return at(path, 'is a symlink — refusing to write through it');
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- the safe-to-remove test ----------------------------------------------
|
|
175
|
+
|
|
176
|
+
const DAY_MS = 86_400_000;
|
|
177
|
+
const DEFAULT_RETENTION_MS = 14 * DAY_MS;
|
|
178
|
+
|
|
179
|
+
// All of these must hold before a worktree directory is removed. Each failure
|
|
180
|
+
// gets its own sentence so the caller can print exactly why the work is being
|
|
181
|
+
// kept. `force` is the operator's word and lifts ONE thing — the not-merged
|
|
182
|
+
// block. Uncommitted, unpushed and locked are never lifted: those are the
|
|
183
|
+
// three ways real work disappears.
|
|
184
|
+
export function evaluateRemoval({ state, dirty, unpushed, remoteMissing, mergedIntoDefault, locked, force = false }) {
|
|
185
|
+
const blocks = [];
|
|
186
|
+
const warns = [];
|
|
187
|
+
const branchGone = state === 'orphan-dir';
|
|
188
|
+
if (dirty) blocks.push('uncommitted changes in the worktree — commit or discard them first');
|
|
189
|
+
if (!branchGone && (unpushed || remoteMissing)) {
|
|
190
|
+
blocks.push('commits not on the remote — push the branch first, then re-check');
|
|
191
|
+
}
|
|
192
|
+
if (!branchGone && !mergedIntoDefault && !force) {
|
|
193
|
+
blocks.push('not merged into the default branch — merge it, or pass --force with the operator\'s word');
|
|
194
|
+
}
|
|
195
|
+
if (locked) blocks.push('the worktree is locked — a session is holding it; unlock it first');
|
|
196
|
+
if (state === 'abandoned') warns.push('the issue is closed and the branch never merged — removing this discards the only checkout of that work');
|
|
197
|
+
return { blocks, warns };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- retention and the dev.md knobs ---------------------------------------
|
|
201
|
+
|
|
202
|
+
// "14d" / "48h" / "90m" → milliseconds. Anything else is null, and every
|
|
203
|
+
// caller treats null as "use the default" rather than guessing.
|
|
204
|
+
export function parseDuration(text) {
|
|
205
|
+
const match = /^(\d+)\s*([dhm])$/.exec(String(text ?? '').trim());
|
|
206
|
+
if (!match) return null;
|
|
207
|
+
const units = { d: DAY_MS, h: 3_600_000, m: 60_000 };
|
|
208
|
+
return Number(match[1]) * units[match[2]];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const knobLine = (devMd, knob) => {
|
|
212
|
+
const match = new RegExp('^' + knob + ':[ \\t]*(.*)$', 'm').exec(String(devMd ?? ''));
|
|
213
|
+
if (!match) return null;
|
|
214
|
+
return match[1].split('#')[0].trim();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// worktree-retention: how long a parked worktree survives with no session.
|
|
218
|
+
// Absent or unparseable falls back to 14 days — a guard never invents a
|
|
219
|
+
// shorter window than the documented default.
|
|
220
|
+
export function parseRetentionKnob(devMd) {
|
|
221
|
+
return parseDuration(knobLine(devMd, 'worktree-retention')) ?? DEFAULT_RETENTION_MS;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// worktree-include: gitignored files a fresh checkout lacks (.env, .dev.vars).
|
|
225
|
+
// `none` is the explicit empty list, so a missing knob and "nothing to copy"
|
|
226
|
+
// are not confused.
|
|
227
|
+
export function parseIncludeKnob(devMd) {
|
|
228
|
+
const value = knobLine(devMd, 'worktree-include');
|
|
229
|
+
if (!value || value === 'none') return [];
|
|
230
|
+
return value.split(/\s+/).filter(Boolean);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The `setup \`...\`` field of dev.md's `commands:` line — what a fresh
|
|
234
|
+
// checkout has to run before it can build (bun install, and friends).
|
|
235
|
+
export function parseSetupCommand(devMd) {
|
|
236
|
+
const line = knobLine(devMd, 'commands');
|
|
237
|
+
if (!line) return null;
|
|
238
|
+
const match = /\bsetup\s+`([^`]+)`/.exec(line);
|
|
239
|
+
return match ? match[1].trim() : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Age is measured from the LATER of the last commit and the last ledger edit:
|
|
243
|
+
// a branch that has not moved may still be an issue someone is actively
|
|
244
|
+
// working, and the ledger is where that shows.
|
|
245
|
+
export function isPastRetention({ lastCommitAt, ledgerUpdatedAt, now, retentionMs }) {
|
|
246
|
+
const stamps = [lastCommitAt, ledgerUpdatedAt]
|
|
247
|
+
.map((value) => (value ? Date.parse(value) : Number.NaN))
|
|
248
|
+
.filter((value) => Number.isFinite(value));
|
|
249
|
+
if (stamps.length === 0) return false;
|
|
250
|
+
return now - Math.max(...stamps) >= retentionMs;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- git plumbing ---------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
// Every git call goes through execFileSync with an explicit argv: no shell, no
|
|
256
|
+
// interpolation, and a failure surfaces as { ok: false, out } for the caller
|
|
257
|
+
// to turn into a block or a warn rather than an unhandled throw.
|
|
258
|
+
// `input` feeds stdin (the patch-id calls below); `raw` keeps the output
|
|
259
|
+
// untrimmed, because a diff's last line may be a lone space that a trim would
|
|
260
|
+
// eat and a patch-id would then miss.
|
|
261
|
+
export function git(cwd, args, { input, raw = false } = {}) {
|
|
262
|
+
try {
|
|
263
|
+
const out = execFileSync('git', args, { cwd, encoding: 'utf8', input, stdio: [input === undefined ? DISCARD : 'pipe', 'pipe', 'pipe'] });
|
|
264
|
+
return { ok: true, out: raw ? out : out.trim() };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const stderr = error.stderr?.toString().trim() || error.message;
|
|
267
|
+
return { ok: false, out: stderr };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// git reports worktree paths through realpath (on macOS /var is a symlink to
|
|
272
|
+
// /private/var), so a path off porcelain and a path composed from repoRoot do
|
|
273
|
+
// not compare equal. Re-express a porcelain path under the caller's own root
|
|
274
|
+
// whenever it names one of our worktrees; otherwise hand it back untouched.
|
|
275
|
+
export function rebaseUnderRoot(repoRoot, absPath) {
|
|
276
|
+
if (!absPath) return absPath;
|
|
277
|
+
const marker = sep + WORKTREES_DIR.split('/').join(sep) + sep;
|
|
278
|
+
const index = absPath.indexOf(marker);
|
|
279
|
+
if (index === -1) return absPath;
|
|
280
|
+
const composed = worktreePath(repoRoot, absPath.slice(index + marker.length));
|
|
281
|
+
try {
|
|
282
|
+
// Only claim the path as ours when it really is the same directory —
|
|
283
|
+
// otherwise a worktree belonging to a different checkout (or the caller
|
|
284
|
+
// pointing repoRoot at a worktree) would be rewritten into a path that
|
|
285
|
+
// does not exist.
|
|
286
|
+
return realpathSync(composed) === realpathSync(absPath) ? composed : absPath;
|
|
287
|
+
} catch {
|
|
288
|
+
return absPath;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The MAIN checkout of the repository the cwd belongs to. Inside a worktree,
|
|
293
|
+
// `rev-parse --show-toplevel` answers with the worktree; the common git dir is
|
|
294
|
+
// what points back at the one checkout that owns .vegastack/.worktrees/.
|
|
295
|
+
export function mainCheckout(cwd) {
|
|
296
|
+
const common = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
297
|
+
if (common.ok && common.out) return dirname(common.out);
|
|
298
|
+
return git(cwd, ['rev-parse', '--show-toplevel']).out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const hasRemote = (repoRoot, remote) => git(repoRoot, ['remote', 'get-url', remote]).ok;
|
|
302
|
+
const branchExistsIn = (repoRoot, branch, gitRunner = git) => gitRunner(repoRoot, ['rev-parse', '--verify', '--quiet', 'refs/heads/' + branch]).ok;
|
|
303
|
+
|
|
304
|
+
// The path of the worktree currently holding a branch, straight off porcelain.
|
|
305
|
+
export function worktreeHoldingBranch(repoRoot, branch, gitRunner = git) {
|
|
306
|
+
const listed = gitRunner(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
307
|
+
if (!listed.ok) return null;
|
|
308
|
+
const found = parseWorktreeList(listed.out).find((entry) => entry.branch === branch)?.path ?? null;
|
|
309
|
+
return rebaseUnderRoot(repoRoot, found);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Codex trust ----------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
// Codex skips .codex/ hooks, rules and project config for an untrusted path,
|
|
315
|
+
// so a fresh worktree has to be added to ~/.codex/config.toml before any run
|
|
316
|
+
// there. Idempotent by header match: the entry is appended exactly once.
|
|
317
|
+
export function codexTrustToml(configText, absPath) {
|
|
318
|
+
const text = String(configText ?? '');
|
|
319
|
+
const header = '[projects."' + absPath + '"]';
|
|
320
|
+
if (text.includes(header)) return { changed: false, text };
|
|
321
|
+
const prefix = text.length === 0 || text.endsWith('\n') ? text : text + '\n';
|
|
322
|
+
const separator = prefix.length === 0 ? '' : '\n';
|
|
323
|
+
return { changed: true, text: prefix + separator + header + '\n' + 'trust_level = "trusted"\n' };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function applyCodexTrust({ home, absPath, write, actions, warns, blocks }) {
|
|
327
|
+
const onPath = (process.env.PATH ?? '')
|
|
328
|
+
.split(delimiter)
|
|
329
|
+
.some((dir) => dir && existsSync(join(dir, 'codex')));
|
|
330
|
+
if (!onPath) {
|
|
331
|
+
warns.push('codex is not on PATH — skipped the ' + absPath + ' trust entry in ~/.codex/config.toml');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const configPath = join(home, '.codex', 'config.toml');
|
|
335
|
+
const symlink = symlinkBlock(configPath);
|
|
336
|
+
if (symlink) {
|
|
337
|
+
blocks.push(symlink);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
341
|
+
const next = codexTrustToml(existing, absPath);
|
|
342
|
+
if (!next.changed) return;
|
|
343
|
+
actions.push(at(configPath, 'add [projects."' + absPath + '"] trust_level = "trusted"'));
|
|
344
|
+
if (!write) return;
|
|
345
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
346
|
+
writeFileSync(configPath, next.text);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --- create and restore ---------------------------------------------------
|
|
350
|
+
|
|
351
|
+
function prepareCheckout({ repoRoot, path, devMd, home, write, actions, warns, blocks, required = false }) {
|
|
352
|
+
for (const file of parseIncludeKnob(devMd)) {
|
|
353
|
+
const source = join(repoRoot, file);
|
|
354
|
+
if (!existsSync(source)) {
|
|
355
|
+
(required ? blocks : warns).push(at(file, 'listed in worktree-include: but absent from the main checkout — not copied'));
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (symlinkBlock(source)) {
|
|
359
|
+
(required ? blocks : warns).push(at(file, 'is a symlink in the main checkout — not copied'));
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
actions.push(at(file, 'copy into the worktree'));
|
|
363
|
+
if (!write) continue;
|
|
364
|
+
const target = join(path, file);
|
|
365
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
366
|
+
copyFileSync(source, target);
|
|
367
|
+
}
|
|
368
|
+
const setup = parseSetupCommand(devMd);
|
|
369
|
+
if (setup) {
|
|
370
|
+
actions.push(at('setup', 'run `' + setup + '` in the worktree'));
|
|
371
|
+
if (write) {
|
|
372
|
+
try {
|
|
373
|
+
execFileSync('sh', ['-c', setup], { cwd: path, encoding: 'utf8', stdio: [DISCARD, 'pipe', 'pipe'] });
|
|
374
|
+
} catch (error) {
|
|
375
|
+
(required ? blocks : warns).push(at('setup', '`' + setup + '` failed: ' + (error.stderr?.toString().trim() || error.message)));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
applyCodexTrust({ home, absPath: path, write, actions, warns, blocks });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Create the checkout for a branch. With `parent` set this is an epic's child:
|
|
383
|
+
// it does NOT get its own directory — it branches off the parent branch inside
|
|
384
|
+
// the parent's worktree, one child at a time, which is what keeps a stack
|
|
385
|
+
// linear. Without `parent` it is a fresh worktree cut from origin/<base>.
|
|
386
|
+
export function createWorktree({ repoRoot, issue, slug, type, base, parent, devMd, home, write = false }) {
|
|
387
|
+
const blocks = [];
|
|
388
|
+
const warns = [];
|
|
389
|
+
const actions = [];
|
|
390
|
+
const branch = branchName(type, issue, slug);
|
|
391
|
+
const name = worktreeName(issue, slug);
|
|
392
|
+
|
|
393
|
+
if (parent) {
|
|
394
|
+
const parentPath = worktreeHoldingBranch(repoRoot, parent);
|
|
395
|
+
if (!parentPath) {
|
|
396
|
+
blocks.push(at(parent, 'no worktree holds the parent branch — create the parent worktree before its first child'));
|
|
397
|
+
return { blocks, warns, actions, path: '', branch };
|
|
398
|
+
}
|
|
399
|
+
actions.push(at(parentPath, 'git switch -c ' + branch + ' from ' + parent));
|
|
400
|
+
if (write) {
|
|
401
|
+
const switched = git(parentPath, ['switch', '-c', branch]);
|
|
402
|
+
if (!switched.ok) blocks.push(at(branch, 'could not branch from ' + parent + ': ' + switched.out));
|
|
403
|
+
}
|
|
404
|
+
return { blocks, warns, actions, path: parentPath, branch };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const path = worktreePath(repoRoot, name);
|
|
408
|
+
for (const candidate of [join(repoRoot, '.vegastack'), join(repoRoot, WORKTREES_DIR), path]) {
|
|
409
|
+
const symlink = symlinkBlock(candidate);
|
|
410
|
+
if (symlink) blocks.push(symlink);
|
|
411
|
+
}
|
|
412
|
+
if (blocks.length > 0) return { blocks, warns, actions, path, branch };
|
|
413
|
+
|
|
414
|
+
if (branchExistsIn(repoRoot, branch)) {
|
|
415
|
+
blocks.push(at(branch, 'the branch already exists — use restore to re-add its worktree'));
|
|
416
|
+
return { blocks, warns, actions, path, branch };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let startPoint = base;
|
|
420
|
+
if (hasRemote(repoRoot, 'origin')) {
|
|
421
|
+
actions.push(at('origin', 'git fetch origin ' + base));
|
|
422
|
+
if (write) {
|
|
423
|
+
const fetched = git(repoRoot, ['fetch', 'origin', base]);
|
|
424
|
+
if (!fetched.ok) warns.push(at('origin', 'fetch of ' + base + ' failed, cutting from the local ref instead: ' + fetched.out));
|
|
425
|
+
else startPoint = 'origin/' + base;
|
|
426
|
+
} else {
|
|
427
|
+
startPoint = 'origin/' + base;
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
warns.push('no origin remote — cutting the branch from the local ' + base);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
actions.push(at(path, 'git worktree add -b ' + branch + ' from ' + startPoint));
|
|
434
|
+
if (write) {
|
|
435
|
+
const added = git(repoRoot, ['worktree', 'add', path, '-b', branch, startPoint]);
|
|
436
|
+
if (!added.ok) {
|
|
437
|
+
blocks.push(at(path, 'git worktree add failed: ' + added.out));
|
|
438
|
+
return { blocks, warns, actions, path, branch };
|
|
439
|
+
}
|
|
440
|
+
prepareCheckout({ repoRoot, path, devMd, home, write, actions, warns, blocks });
|
|
441
|
+
} else {
|
|
442
|
+
prepareCheckout({ repoRoot, path, devMd, home, write: false, actions, warns, blocks });
|
|
443
|
+
}
|
|
444
|
+
return { blocks, warns, actions, path, branch };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The `create --base <sha>` path: one child checkout of a parallel run, cut
|
|
448
|
+
// from the parent's HEAD commit. It shares createWorktree's symlink refusal,
|
|
449
|
+
// existing-branch refusal and post-add preparation, and differs only in the
|
|
450
|
+
// start point, which is a commit rather than a ref.
|
|
451
|
+
export function createChildWorktree({ repoRoot, issue, slug, type, baseSha, devMd, home, write = false, gitRunner = git }) {
|
|
452
|
+
const blocks = [];
|
|
453
|
+
const warns = [];
|
|
454
|
+
const actions = [];
|
|
455
|
+
let plan;
|
|
456
|
+
try {
|
|
457
|
+
plan = childWorktreePlan({ repoRoot, issue, title: slug, type, baseSha });
|
|
458
|
+
} catch (error) {
|
|
459
|
+
return { blocks: [at('--base', error.message)], warns, actions, path: '', branch: '' };
|
|
460
|
+
}
|
|
461
|
+
for (const candidate of [join(repoRoot, '.vegastack'), join(repoRoot, WORKTREES_DIR), plan.path]) {
|
|
462
|
+
const symlink = symlinkBlock(candidate);
|
|
463
|
+
if (symlink) blocks.push(symlink);
|
|
464
|
+
}
|
|
465
|
+
if (blocks.length > 0) return { blocks, warns, actions, path: plan.path, branch: plan.branch };
|
|
466
|
+
if (branchExistsIn(repoRoot, plan.branch, gitRunner)) {
|
|
467
|
+
blocks.push(at(plan.branch, 'the branch already exists — use restore to re-add its worktree'));
|
|
468
|
+
return { blocks, warns, actions, path: plan.path, branch: plan.branch };
|
|
469
|
+
}
|
|
470
|
+
actions.push(at(plan.path, 'git worktree add -b ' + plan.branch + ' from ' + plan.baseSha));
|
|
471
|
+
if (write) {
|
|
472
|
+
const added = gitRunner(repoRoot, plan.args);
|
|
473
|
+
if (!added.ok) {
|
|
474
|
+
blocks.push(at(plan.path, 'git worktree add failed: ' + added.out));
|
|
475
|
+
return { blocks, warns, actions, path: plan.path, branch: plan.branch };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
prepareCheckout({ repoRoot, path: plan.path, devMd, home, write, actions, warns, blocks, required: true });
|
|
479
|
+
return { blocks, warns, actions, path: plan.path, branch: plan.branch };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Re-add the checkout for a branch that still exists but whose directory is
|
|
483
|
+
// gone — the corrections and reclaim path. It never creates a branch: a
|
|
484
|
+
// missing branch means the work is somewhere else, and guessing would be worse
|
|
485
|
+
// than stopping.
|
|
486
|
+
export function restoreWorktree({ repoRoot, issue, slug, type, devMd, home, write = false, gitRunner = git }) {
|
|
487
|
+
const blocks = [];
|
|
488
|
+
const warns = [];
|
|
489
|
+
const actions = [];
|
|
490
|
+
const branch = branchName(type, issue, slug);
|
|
491
|
+
const name = worktreeName(issue, slug);
|
|
492
|
+
const path = worktreePath(repoRoot, name);
|
|
493
|
+
|
|
494
|
+
for (const candidate of [join(repoRoot, '.vegastack'), join(repoRoot, WORKTREES_DIR), path]) {
|
|
495
|
+
const symlink = symlinkBlock(candidate);
|
|
496
|
+
if (symlink) blocks.push(symlink);
|
|
497
|
+
}
|
|
498
|
+
if (blocks.length > 0) return { blocks, warns, actions, path, branch };
|
|
499
|
+
|
|
500
|
+
if (!branchExistsIn(repoRoot, branch, gitRunner)) {
|
|
501
|
+
blocks.push(at(branch, 'no branch of that name — nothing to restore; create it instead'));
|
|
502
|
+
return { blocks, warns, actions, path, branch };
|
|
503
|
+
}
|
|
504
|
+
const held = worktreeHoldingBranch(repoRoot, branch, gitRunner);
|
|
505
|
+
if (held) {
|
|
506
|
+
warns.push(at(held, 'already holds ' + branch + ' — nothing to restore'));
|
|
507
|
+
return { blocks, warns, actions, path: held, branch };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
actions.push(at(path, 'git worktree add ' + branch));
|
|
511
|
+
if (write) {
|
|
512
|
+
const added = gitRunner(repoRoot, ['worktree', 'add', path, branch]);
|
|
513
|
+
if (!added.ok) {
|
|
514
|
+
blocks.push(at(path, 'git worktree add failed: ' + added.out));
|
|
515
|
+
return { blocks, warns, actions, path, branch };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
prepareCheckout({ repoRoot, path, devMd, home, write, actions, warns, blocks });
|
|
519
|
+
return { blocks, warns, actions, path, branch };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// --- remove and prune -----------------------------------------------------
|
|
523
|
+
|
|
524
|
+
// Read the git facts the safe-to-remove test needs. Every unverifiable fact
|
|
525
|
+
// fails closed: a status call that errors reports dirty, a merge check that
|
|
526
|
+
// errors reports not-merged.
|
|
527
|
+
function gatherRemovalFacts({ repoRoot, path, branch, base, remote, locked }) {
|
|
528
|
+
const dirty = branch === null
|
|
529
|
+
? false
|
|
530
|
+
: (() => {
|
|
531
|
+
const status = git(path, ['status', '--porcelain']);
|
|
532
|
+
return !status.ok || status.out !== '';
|
|
533
|
+
})();
|
|
534
|
+
if (branch === null) return { dirty, unpushed: false, remoteMissing: false, mergedIntoDefault: false };
|
|
535
|
+
const remoteRef = remote + '/' + branch;
|
|
536
|
+
const remoteMissing = !git(repoRoot, ['rev-parse', '--verify', '--quiet', 'refs/remotes/' + remoteRef]).ok;
|
|
537
|
+
const ahead = remoteMissing ? null : git(repoRoot, ['rev-list', remoteRef + '..' + branch]);
|
|
538
|
+
const unpushed = remoteMissing ? false : !ahead.ok || ahead.out !== '';
|
|
539
|
+
const baseRef = git(repoRoot, ['rev-parse', '--verify', '--quiet', 'refs/remotes/' + remote + '/' + base]).ok
|
|
540
|
+
? remote + '/' + base
|
|
541
|
+
: base;
|
|
542
|
+
// A branch that has never reached the remote cannot have been merged: main is
|
|
543
|
+
// reached through a PR, so "ancestor of the default branch" alone would call a
|
|
544
|
+
// brand-new branch cut from origin/main 'merged' and prune it on day one.
|
|
545
|
+
const isAncestor = git(repoRoot, ['merge-base', '--is-ancestor', branch, baseRef]).ok;
|
|
546
|
+
const mergedIntoDefault = !remoteMissing && (isAncestor || mergedByContent(repoRoot, branch, baseRef));
|
|
547
|
+
return { dirty, unpushed, remoteMissing, mergedIntoDefault, locked };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Squash and rebase merges rewrite the commits, so by ancestry a merged branch
|
|
551
|
+
// stays unmerged forever — and both are ordinary `merge:` knob values. Content
|
|
552
|
+
// is the second test: the branch counts as merged when its whole diff against
|
|
553
|
+
// the merge base (a squash) or every one of its commits (a rebase) carries a
|
|
554
|
+
// patch-id already on the default branch. A merge whose conflicts were resolved
|
|
555
|
+
// by hand changes the patch and stays unmerged here; --force remains the word.
|
|
556
|
+
const PATCH_LOG = ['log', '-p', '--no-color', '--no-ext-diff', '--format=commit %H'];
|
|
557
|
+
|
|
558
|
+
function patchIds(repoRoot, text) {
|
|
559
|
+
if (!text) return [];
|
|
560
|
+
const ids = git(repoRoot, ['patch-id', '--stable'], { input: text });
|
|
561
|
+
if (!ids.ok) return [];
|
|
562
|
+
return ids.out.split('\n').map((line) => line.split(' ')[0]).filter(Boolean);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function mergedByContent(repoRoot, branch, baseRef) {
|
|
566
|
+
const mergeBase = git(repoRoot, ['merge-base', branch, baseRef]);
|
|
567
|
+
if (!mergeBase.ok || !mergeBase.out) return false;
|
|
568
|
+
const landed = git(repoRoot, [...PATCH_LOG, mergeBase.out + '..' + baseRef], { raw: true });
|
|
569
|
+
if (!landed.ok) return false;
|
|
570
|
+
const known = new Set(patchIds(repoRoot, landed.out));
|
|
571
|
+
if (known.size === 0) return false;
|
|
572
|
+
const squashed = git(repoRoot, ['diff', '--no-color', '--no-ext-diff', mergeBase.out, branch], { raw: true });
|
|
573
|
+
if (squashed.ok && patchIds(repoRoot, squashed.out).some((id) => known.has(id))) return true;
|
|
574
|
+
const own = git(repoRoot, [...PATCH_LOG, mergeBase.out + '..' + branch], { raw: true });
|
|
575
|
+
if (!own.ok) return false;
|
|
576
|
+
const ownIds = patchIds(repoRoot, own.out);
|
|
577
|
+
return ownIds.length > 0 && ownIds.every((id) => known.has(id));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The merge lands on the server, so the local origin/<base> is only as fresh as
|
|
581
|
+
// the last fetch and a stale one calls every merge unmerged. A fetch touches
|
|
582
|
+
// nothing but remote-tracking refs, so it runs on a dry run too; a failure
|
|
583
|
+
// warns, and the stale refs then judge — fail closed, never open.
|
|
584
|
+
function refreshBase({ repoRoot, base, remote, actions, warns }) {
|
|
585
|
+
if (!hasRemote(repoRoot, remote)) return;
|
|
586
|
+
actions.push(at(remote, 'git fetch ' + remote + ' ' + base));
|
|
587
|
+
const fetched = git(repoRoot, ['fetch', remote, base]);
|
|
588
|
+
if (!fetched.ok) warns.push(at(remote, 'fetch of ' + base + ' failed, judging against the last-fetched ref: ' + fetched.out));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Remove one worktree directory — and only the directory. The local branch and
|
|
592
|
+
// the remote branch are never touched here: deleting either is on the ship
|
|
593
|
+
// guard's always-ask list and takes the operator's own word.
|
|
594
|
+
export function removeWorktree({ repoRoot, name, base, force = false, push = false, write = false, remote = 'origin' }) {
|
|
595
|
+
const blocks = [];
|
|
596
|
+
const warns = [];
|
|
597
|
+
const actions = [];
|
|
598
|
+
const path = worktreePath(repoRoot, name);
|
|
599
|
+
const symlink = symlinkBlock(path);
|
|
600
|
+
if (symlink) return { blocks: [symlink], warns, actions };
|
|
601
|
+
|
|
602
|
+
const listed = git(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
603
|
+
if (!listed.ok) return { blocks: [at(repoRoot, 'cannot read the worktree list: ' + listed.out)], warns, actions };
|
|
604
|
+
const entry = parseWorktreeList(listed.out).find((item) => rebaseUnderRoot(repoRoot, item.path) === path);
|
|
605
|
+
if (!entry) return { blocks: [at(name, 'no worktree at ' + path + ' — nothing to remove')], warns, actions };
|
|
606
|
+
|
|
607
|
+
const branch = entry.branch;
|
|
608
|
+
const branchIssue = branch && /^[^/]+\/([1-9]\d*)(?:-|$)/.exec(branch);
|
|
609
|
+
if (branchIssue && issueOfWorktree(name) !== Number(branchIssue[1])) {
|
|
610
|
+
return { blocks: ['serial child cannot remove its parent worktree; return to the parent branch and retain it until the parent PR merges'], warns, actions, path, branch };
|
|
611
|
+
}
|
|
612
|
+
refreshBase({ repoRoot, base, remote, actions, warns });
|
|
613
|
+
let facts = gatherRemovalFacts({ repoRoot, path, branch, base, remote, locked: entry.locked });
|
|
614
|
+
if (push && branch && (facts.remoteMissing || facts.unpushed)) {
|
|
615
|
+
actions.push(at(branch, 'git push -u ' + remote + ' ' + branch + ' before removing'));
|
|
616
|
+
if (write) {
|
|
617
|
+
const pushed = git(path, ['push', '-u', remote, branch]);
|
|
618
|
+
if (!pushed.ok) warns.push(at(branch, 'push failed: ' + pushed.out));
|
|
619
|
+
facts = gatherRemovalFacts({ repoRoot, path, branch, base, remote, locked: entry.locked });
|
|
620
|
+
} else {
|
|
621
|
+
facts = { ...facts, remoteMissing: false, unpushed: false };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const state = classifyWorktree({
|
|
626
|
+
dirExists: true,
|
|
627
|
+
branchExists: branch !== null,
|
|
628
|
+
locked: entry.locked,
|
|
629
|
+
issueState: null,
|
|
630
|
+
mergedIntoDefault: facts.mergedIntoDefault,
|
|
631
|
+
});
|
|
632
|
+
const verdict = evaluateRemoval({ state, ...facts, locked: entry.locked, force });
|
|
633
|
+
blocks.push(...verdict.blocks);
|
|
634
|
+
warns.push(...verdict.warns);
|
|
635
|
+
if (blocks.length > 0) return { blocks, warns, actions, path, branch, state };
|
|
636
|
+
|
|
637
|
+
actions.push(at(path, 'git worktree remove (the branch and its remote are left alone)'));
|
|
638
|
+
if (write) {
|
|
639
|
+
const removed = git(repoRoot, ['worktree', 'remove', path]);
|
|
640
|
+
if (!removed.ok) blocks.push(at(path, 'git worktree remove failed: ' + removed.out));
|
|
641
|
+
}
|
|
642
|
+
return { blocks, warns, actions, path, branch, state };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Every worktree directory under .vegastack/.worktrees, with its branch and
|
|
646
|
+
// lock flag straight off porcelain. The main checkout is never one of them.
|
|
647
|
+
export function inventory(repoRoot) {
|
|
648
|
+
const listed = git(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
649
|
+
if (!listed.ok) return [];
|
|
650
|
+
const prefix = worktreePath(repoRoot, '') + sep;
|
|
651
|
+
return parseWorktreeList(listed.out)
|
|
652
|
+
.map((entry) => ({ ...entry, path: rebaseUnderRoot(repoRoot, entry.path) }))
|
|
653
|
+
.filter((entry) => entry.path.startsWith(prefix))
|
|
654
|
+
.map((entry) => ({ ...entry, name: entry.path.slice(prefix.length) }));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Retention prune: propose (and with --write, perform) the removal of parked
|
|
658
|
+
// worktrees whose branch and ledger have both gone quiet past the window. It
|
|
659
|
+
// pushes an unpushed candidate first so nothing local-only is ever discarded,
|
|
660
|
+
// then re-runs the same safe-to-remove test every other caller uses. Nothing
|
|
661
|
+
// but a `parked` worktree is ever a candidate — and parked means unmerged, so
|
|
662
|
+
// here the retention window is what lifts the not-merged rule: the pushed
|
|
663
|
+
// branch keeps the work and `restore` brings the directory back. Uncommitted,
|
|
664
|
+
// unpushed and locked still keep it.
|
|
665
|
+
export function pruneWorktrees({ repoRoot, base, olderThan, devMd, ledgerTimes = {}, now = Date.now(), write = false, remote = 'origin' }) {
|
|
666
|
+
const blocks = [];
|
|
667
|
+
const warns = [];
|
|
668
|
+
const actions = [];
|
|
669
|
+
const retentionMs = parseDuration(olderThan) ?? parseRetentionKnob(devMd);
|
|
670
|
+
const candidates = [];
|
|
671
|
+
refreshBase({ repoRoot, base, remote, actions, warns });
|
|
672
|
+
for (const entry of inventory(repoRoot)) {
|
|
673
|
+
const branch = entry.branch;
|
|
674
|
+
const lastCommitAt = branch ? (git(repoRoot, ['log', '-1', '--format=%cI', branch]).out || null) : null;
|
|
675
|
+
const ledgerUpdatedAt = ledgerTimes[entry.name] ?? null;
|
|
676
|
+
const facts = gatherRemovalFacts({ repoRoot, path: entry.path, branch, base, remote, locked: entry.locked });
|
|
677
|
+
const state = classifyWorktree({
|
|
678
|
+
dirExists: true,
|
|
679
|
+
branchExists: branch !== null,
|
|
680
|
+
locked: entry.locked,
|
|
681
|
+
issueState: null,
|
|
682
|
+
mergedIntoDefault: facts.mergedIntoDefault,
|
|
683
|
+
});
|
|
684
|
+
const stamps = [lastCommitAt, ledgerUpdatedAt].map((v) => (v ? Date.parse(v) : Number.NaN)).filter(Number.isFinite);
|
|
685
|
+
const ageDays = stamps.length === 0 ? 0 : Math.floor((now - Math.max(...stamps)) / DAY_MS);
|
|
686
|
+
if (state !== 'parked') continue;
|
|
687
|
+
if (!isPastRetention({ lastCommitAt, ledgerUpdatedAt, now, retentionMs })) continue;
|
|
688
|
+
const verdict = evaluateRemoval({ state, ...facts, locked: entry.locked, force: true });
|
|
689
|
+
// "Prune pushes then removes, and never automatically for anything with
|
|
690
|
+
// unpushed work": the push half protects the work and happens on --write
|
|
691
|
+
// whatever else is wrong; the remove half then re-runs the same safe test
|
|
692
|
+
// and may still keep the worktree (unmerged, dirty, locked). A dry run
|
|
693
|
+
// pushes nothing, so such a candidate is correctly not-yet-removable.
|
|
694
|
+
const remoteOnly = verdict.blocks.some((block) => block.includes('commits not on the remote'));
|
|
695
|
+
candidates.push({
|
|
696
|
+
name: entry.name,
|
|
697
|
+
state,
|
|
698
|
+
ageDays,
|
|
699
|
+
removable: verdict.blocks.length === 0,
|
|
700
|
+
pushable: remoteOnly,
|
|
701
|
+
reason: verdict.blocks[0] ?? null,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
for (const candidate of candidates) {
|
|
705
|
+
if (!candidate.removable && !candidate.pushable) continue;
|
|
706
|
+
actions.push(at(candidate.name, (candidate.pushable ? 'push the branch, then re-check for removal after ' : 'remove after ') + candidate.ageDays + ' quiet days'));
|
|
707
|
+
if (!write) continue;
|
|
708
|
+
const removed = removeWorktree({ repoRoot, name: candidate.name, base, force: true, push: true, write: true, remote });
|
|
709
|
+
if (removed.blocks.length > 0) {
|
|
710
|
+
warns.push(at(candidate.name, 'kept after all: ' + removed.blocks[0]));
|
|
711
|
+
candidate.removable = false;
|
|
712
|
+
candidate.reason = removed.blocks[0];
|
|
713
|
+
} else {
|
|
714
|
+
candidate.removable = true;
|
|
715
|
+
candidate.reason = null;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return { blocks, warns, actions, candidates };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// --- list and status ------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
const SIZE_BUDGET = 50_000;
|
|
724
|
+
|
|
725
|
+
// Bounded disk usage for one worktree. It never follows a symlink (a link into
|
|
726
|
+
// the user's home would report their whole disk) and stops at the entry budget,
|
|
727
|
+
// reporting approx: true rather than pretending to a number it did not finish.
|
|
728
|
+
export function directorySize(path, { maxEntries = SIZE_BUDGET } = {}) {
|
|
729
|
+
let bytes = 0;
|
|
730
|
+
let seen = 0;
|
|
731
|
+
let approx = false;
|
|
732
|
+
const stack = [path];
|
|
733
|
+
while (stack.length > 0) {
|
|
734
|
+
const current = stack.pop();
|
|
735
|
+
let entries;
|
|
736
|
+
try {
|
|
737
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
738
|
+
} catch {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
for (const entry of entries) {
|
|
742
|
+
if (seen >= maxEntries) {
|
|
743
|
+
approx = true;
|
|
744
|
+
return { bytes, approx };
|
|
745
|
+
}
|
|
746
|
+
seen += 1;
|
|
747
|
+
if (entry.isSymbolicLink()) continue;
|
|
748
|
+
const child = join(current, entry.name);
|
|
749
|
+
if (entry.isDirectory()) {
|
|
750
|
+
stack.push(child);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (!entry.isFile()) continue;
|
|
754
|
+
try {
|
|
755
|
+
bytes += statSync(child).size;
|
|
756
|
+
} catch {
|
|
757
|
+
approx = true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return { bytes, approx };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// The inventory with each entry's lifecycle state and disk footprint attached.
|
|
765
|
+
// issueStates maps a worktree name to the GitHub state of its issue; without it
|
|
766
|
+
// (offline, or no gh) nothing is ever classified 'abandoned'.
|
|
767
|
+
export function listWorktrees({ repoRoot, base, issueStates = {}, remote = 'origin', withSize = true }) {
|
|
768
|
+
return inventory(repoRoot).map((entry) => {
|
|
769
|
+
const facts = gatherRemovalFacts({ repoRoot, path: entry.path, branch: entry.branch, base, remote, locked: entry.locked });
|
|
770
|
+
const state = classifyWorktree({
|
|
771
|
+
dirExists: true,
|
|
772
|
+
branchExists: entry.branch !== null,
|
|
773
|
+
locked: entry.locked,
|
|
774
|
+
issueState: issueStates[entry.name] ?? null,
|
|
775
|
+
mergedIntoDefault: facts.mergedIntoDefault,
|
|
776
|
+
});
|
|
777
|
+
const size = withSize ? directorySize(entry.path) : { bytes: 0, approx: true };
|
|
778
|
+
return { name: entry.name, path: entry.path, branch: entry.branch, state, bytes: size.bytes, approx: size.approx };
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// The issue number a worktree name carries, or null for the ones that have none
|
|
783
|
+
// (a release branch, a direct chat fix).
|
|
784
|
+
export function issueOfWorktree(name) {
|
|
785
|
+
const match = /^(\d+)-/.exec(String(name ?? ''));
|
|
786
|
+
return match ? Number(match[1]) : null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// The pure reconciliation behind `vegafactory worktree status`: which
|
|
790
|
+
// worktrees answer to an open issue, which do not, which open issues have no
|
|
791
|
+
// checkout, and which directories lost their branch.
|
|
792
|
+
export function reconcileWorktrees({ entries, openIssues }) {
|
|
793
|
+
const open = new Set((openIssues ?? []).filter((issue) => issue.state === 'open').map((issue) => issue.number));
|
|
794
|
+
const matched = [];
|
|
795
|
+
const worktreesWithoutOpenIssue = [];
|
|
796
|
+
const orphans = [];
|
|
797
|
+
const claimed = new Set();
|
|
798
|
+
for (const entry of entries ?? []) {
|
|
799
|
+
if (entry.state === 'orphan-dir') orphans.push(entry.name);
|
|
800
|
+
const issue = issueOfWorktree(entry.name);
|
|
801
|
+
if (issue !== null && open.has(issue) && entry.state !== 'orphan-dir') {
|
|
802
|
+
matched.push({ name: entry.name, issue });
|
|
803
|
+
claimed.add(issue);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
worktreesWithoutOpenIssue.push(entry.name);
|
|
807
|
+
}
|
|
808
|
+
const openIssuesWithoutWorktree = [...open].filter((number) => !claimed.has(number)).sort((a, b) => a - b);
|
|
809
|
+
return { matched, worktreesWithoutOpenIssue, openIssuesWithoutWorktree, orphans };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Open issues for the repo, and the last edit time of each issue's ledger
|
|
813
|
+
// comment (what retention measures against). gh being unreachable is a WARN,
|
|
814
|
+
// not a block: `list` has to work on a plane.
|
|
815
|
+
export function gatherGithubFacts({ repo, names = [], warns }) {
|
|
816
|
+
const issueStates = {};
|
|
817
|
+
let openIssues = [];
|
|
818
|
+
try {
|
|
819
|
+
openIssues = ghJson(['api', 'repos/' + repo + '/issues', '--paginate', '-X', 'GET', '-f', 'state=open'])
|
|
820
|
+
.filter((issue) => !issue.pull_request)
|
|
821
|
+
.map((issue) => ({ number: issue.number, state: issue.state }));
|
|
822
|
+
} catch (error) {
|
|
823
|
+
warns.push(at('github', 'could not read open issues, reporting from git alone: ' + error.message));
|
|
824
|
+
return { openIssues, issueStates };
|
|
825
|
+
}
|
|
826
|
+
const open = new Set(openIssues.map((issue) => issue.number));
|
|
827
|
+
for (const name of names) {
|
|
828
|
+
const issue = issueOfWorktree(name);
|
|
829
|
+
if (issue === null) continue;
|
|
830
|
+
if (open.has(issue)) {
|
|
831
|
+
issueStates[name] = 'open';
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
issueStates[name] = ghJson(['api', 'repos/' + repo + '/issues/' + issue]).state;
|
|
836
|
+
} catch (error) {
|
|
837
|
+
warns.push(at('github', 'could not read the state of #' + issue + ': ' + error.message));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return { openIssues, issueStates };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Ledger edit times for the named issues, one call each. Failures warn.
|
|
844
|
+
export function gatherLedgerTimes({ repo, names, warns }) {
|
|
845
|
+
const times = {};
|
|
846
|
+
for (const name of names) {
|
|
847
|
+
const issue = issueOfWorktree(name);
|
|
848
|
+
if (issue === null) continue;
|
|
849
|
+
try {
|
|
850
|
+
const comments = ghJson(['api', 'repos/' + repo + '/issues/' + issue + '/comments', '--paginate']);
|
|
851
|
+
const ledger = findMarkerComment(comments, 'ledger');
|
|
852
|
+
if (ledger) times[name] = ledger.comment.updated_at;
|
|
853
|
+
} catch (error) {
|
|
854
|
+
warns.push(at('github', 'could not read the ledger of #' + issue + ': ' + error.message));
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return times;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// --- dev.md defaults ------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
// dev.md's `repo:` line carries the default branch after a middot. Absent or
|
|
863
|
+
// unparseable falls back to `main` rather than guessing from the checkout,
|
|
864
|
+
// which in a worktree is never the default branch by construction.
|
|
865
|
+
export function parseDefaultBranch(devMd) {
|
|
866
|
+
const line = knobLine(devMd, 'repo');
|
|
867
|
+
const match = line ? /default branch\s+(\S+)/.exec(line) : null;
|
|
868
|
+
return match ? match[1] : 'main';
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// --- CLI ------------------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
function renderWorktree(result, { json }) {
|
|
874
|
+
const { exitCode, text } = renderResult('worktree', result, { json });
|
|
875
|
+
if (!json) {
|
|
876
|
+
const lines = [text];
|
|
877
|
+
for (const action of result.actions ?? []) lines.push(' action: ' + action);
|
|
878
|
+
for (const candidate of result.candidates ?? []) {
|
|
879
|
+
lines.push(' candidate: ' + candidate.name + ' (' + candidate.state + ', ' + candidate.ageDays + 'd) — '
|
|
880
|
+
+ (candidate.removable ? 'removable' : 'kept: ' + candidate.reason));
|
|
881
|
+
}
|
|
882
|
+
for (const entry of result.entries ?? []) {
|
|
883
|
+
lines.push(' worktree: ' + entry.name + ' [' + entry.state + '] ' + (entry.branch ?? 'detached'));
|
|
884
|
+
}
|
|
885
|
+
return { exitCode, text: lines.join('\n') };
|
|
886
|
+
}
|
|
887
|
+
const payload = JSON.parse(text);
|
|
888
|
+
for (const key of ['actions', 'path', 'branch', 'entries', 'candidates', 'reconciled']) {
|
|
889
|
+
if (result[key] !== undefined) payload[key] = result[key];
|
|
890
|
+
}
|
|
891
|
+
return { exitCode, text: JSON.stringify(payload, null, 2) };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function runVerb(verb, flags) {
|
|
895
|
+
const repoRoot = flags['repo-root'] || mainCheckout(process.cwd());
|
|
896
|
+
const devMdPath = flags['dev-md'] || join(repoRoot, '.vegastack', 'dev.md');
|
|
897
|
+
const devMd = existsSync(devMdPath) ? readFileSync(devMdPath, 'utf8') : '';
|
|
898
|
+
const base = flags.base || parseDefaultBranch(devMd);
|
|
899
|
+
const home = flags.home || homedir();
|
|
900
|
+
let issue = null;
|
|
901
|
+
if (flags.issue !== undefined) {
|
|
902
|
+
issue = Number(flags.issue);
|
|
903
|
+
if (!Number.isInteger(issue) || issue <= 0) return { blocks: [at('--issue', 'expected a positive issue number, got ' + flags.issue)], warns: [] };
|
|
904
|
+
}
|
|
905
|
+
const slug = flags.slug ? slugify(flags.slug) : null;
|
|
906
|
+
const shared = { repoRoot, devMd, home, base, write: Boolean(flags.write) };
|
|
907
|
+
|
|
908
|
+
const repoOf = () => flags.repo || knobLine(devMd, 'repo')?.split('·')[0].trim() || null;
|
|
909
|
+
|
|
910
|
+
if (verb === 'list' || verb === 'status') {
|
|
911
|
+
const warns = [];
|
|
912
|
+
const repo = repoOf();
|
|
913
|
+
const names = inventory(repoRoot).map((entry) => entry.name);
|
|
914
|
+
const github = repo ? gatherGithubFacts({ repo, names, warns }) : { openIssues: [], issueStates: {} };
|
|
915
|
+
const entries = listWorktrees({ repoRoot, base, issueStates: github.issueStates });
|
|
916
|
+
if (verb === 'list') return { blocks: [], warns, entries };
|
|
917
|
+
return { blocks: [], warns, entries, reconciled: reconcileWorktrees({ entries, openIssues: github.openIssues }) };
|
|
918
|
+
}
|
|
919
|
+
if (verb === 'remove') {
|
|
920
|
+
// --name is exact; --issue resolves it from the inventory, which is what a
|
|
921
|
+
// caller that only knows the issue number (the CLI, dev-ship) has.
|
|
922
|
+
let name = flags.name;
|
|
923
|
+
if (!name && issue !== null) {
|
|
924
|
+
const matches = inventory(repoRoot).filter((entry) => issueOfWorktree(entry.name) === issue);
|
|
925
|
+
if (matches.length === 0) return { blocks: [at('#' + issue, 'no worktree for that issue')], warns: [] };
|
|
926
|
+
if (matches.length > 1) {
|
|
927
|
+
return { blocks: [at('#' + issue, 'several worktrees match (' + matches.map((m) => m.name).join(', ') + ') — pass --name')], warns: [] };
|
|
928
|
+
}
|
|
929
|
+
name = matches[0].name;
|
|
930
|
+
}
|
|
931
|
+
if (!name) return { blocks: ['--name <n>-<slug> or --issue <n> is required for remove'], warns: [] };
|
|
932
|
+
return removeWorktree({ repoRoot, name, base, force: Boolean(flags.force), push: Boolean(flags.push), write: shared.write });
|
|
933
|
+
}
|
|
934
|
+
if (verb === 'prune') {
|
|
935
|
+
const warns = [];
|
|
936
|
+
const repo = flags.repo || knobLine(devMd, 'repo')?.split('·')[0].trim() || null;
|
|
937
|
+
const names = inventory(repoRoot).map((entry) => entry.name);
|
|
938
|
+
const ledgerTimes = repo ? gatherLedgerTimes({ repo, names, warns }) : {};
|
|
939
|
+
const pruned = pruneWorktrees({ repoRoot, base, olderThan: flags['older-than'], devMd, ledgerTimes, now: Date.now(), write: shared.write });
|
|
940
|
+
return { ...pruned, warns: [...warns, ...pruned.warns] };
|
|
941
|
+
}
|
|
942
|
+
if (verb === 'create' || verb === 'restore') {
|
|
943
|
+
// No --slug: create reads the issue title (GitHub unreachable blocks, never
|
|
944
|
+
// guesses), restore reads the branch that already carries the number.
|
|
945
|
+
let named = { type: flags.type || null, slug };
|
|
946
|
+
if (!slug && issue !== null && verb === 'restore') {
|
|
947
|
+
const parts = branchPartsForIssue(repoRoot, issue);
|
|
948
|
+
if (parts.error) return { blocks: [at('#' + issue, parts.error)], warns: [] };
|
|
949
|
+
named = { type: named.type || parts.type, slug: parts.slug };
|
|
950
|
+
} else if (!slug && issue !== null) {
|
|
951
|
+
const repo = repoOf();
|
|
952
|
+
if (!repo) return { blocks: [at('#' + issue, 'no repo known to read the title from — pass --repo, or --slug')], warns: [] };
|
|
953
|
+
let title;
|
|
954
|
+
try {
|
|
955
|
+
title = ghJson(['api', 'repos/' + repo + '/issues/' + issue]).title;
|
|
956
|
+
} catch (error) {
|
|
957
|
+
return { blocks: [at('#' + issue, 'could not read the issue title (' + error.message + ') — pass --slug')], warns: [] };
|
|
958
|
+
}
|
|
959
|
+
const parts = titleParts(title);
|
|
960
|
+
if (!parts.slug) return { blocks: [at('#' + issue, 'the title makes no slug — pass --slug')], warns: [] };
|
|
961
|
+
named = { type: named.type || parts.type, slug: parts.slug };
|
|
962
|
+
}
|
|
963
|
+
if (!named.slug) return { blocks: ['--slug is required for ' + verb + ' without --issue'], warns: [] };
|
|
964
|
+
const options = { ...shared, issue, slug: named.slug, type: named.type || 'feat', parent: flags.parent };
|
|
965
|
+
// A `--base` that is a commit sha means a child of a parallel run: its
|
|
966
|
+
// checkout is cut from that exact commit, never from a ref that another
|
|
967
|
+
// child could move. Any other `--base` keeps its long-standing meaning,
|
|
968
|
+
// the branch the new worktree is cut from.
|
|
969
|
+
if (verb === 'create' && flags.base && CHILD_BASE_SHA.test(String(flags.base))) {
|
|
970
|
+
return createChildWorktree({ ...options, baseSha: String(flags.base) });
|
|
971
|
+
}
|
|
972
|
+
return verb === 'create' ? createWorktree(options) : restoreWorktree(options);
|
|
973
|
+
}
|
|
974
|
+
return { blocks: [at(verb, 'unknown verb — expected create|restore|remove|list|prune|status')], warns: [] };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
978
|
+
if (invokedDirectly) {
|
|
979
|
+
const argv = process.argv.slice(2);
|
|
980
|
+
const verb = argv.find((arg) => !arg.startsWith('--')) ?? '';
|
|
981
|
+
const flags = parseFlags(argv, ['json', 'write', 'force', 'push', 'all']);
|
|
982
|
+
let outcome;
|
|
983
|
+
try {
|
|
984
|
+
outcome = runVerb(verb, flags);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
outcome = { blocks: [at('worktree', error.message)], warns: [] };
|
|
987
|
+
}
|
|
988
|
+
const { exitCode, text } = renderWorktree(outcome, { json: Boolean(flags.json) });
|
|
989
|
+
console.log(text);
|
|
990
|
+
process.exit(exitCode);
|
|
991
|
+
}
|