@damiangilgonzalez/harness 1.3.16 → 1.3.18

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.
@@ -115,9 +115,127 @@ export function isCleanupEnabled(root) {
115
115
 
116
116
  /** The first trunk ref that actually exists, or null when there is nothing to compare against. */
117
117
  export function resolveTrunk(root) {
118
+ // What the remote itself calls its default branch comes first: a trunk called `develop` or
119
+ // `trunk` is invisible to the table below, and guessing `main` there compares against nothing.
120
+ const announced = remoteDefault(root);
121
+ if (announced) return announced.ref;
118
122
  return TRUNKS.find((ref) => git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).ok) || null;
119
123
  }
120
124
 
125
+ // `origin/HEAD` as the remote announced it, or null. A plain `git init` + `remote add` never writes
126
+ // that ref (only `clone` and `remote set-head` do), so null is common and is not an error.
127
+ function remoteDefault(root) {
128
+ const sym = git(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
129
+ if (!sym.ok || !sym.out.startsWith('refs/remotes/origin/')) return null;
130
+ const name = sym.out.slice('refs/remotes/origin/'.length);
131
+ if (!git(root, ['rev-parse', '--verify', '--quiet', `origin/${name}^{commit}`]).ok) return null;
132
+ return { ref: `origin/${name}`, name };
133
+ }
134
+
135
+ /**
136
+ * The branch a new worktree starts from: origin's default, never the local one.
137
+ *
138
+ * Measured in a user's project: the local `main` was 400 commits behind origin, and a worktree cut
139
+ * from it starts 400 commits in the past — every review and every merge after that pays for it.
140
+ * The remote tip is the only base that is current by construction. Null when there is no origin.
141
+ */
142
+ export function defaultBranch(root) {
143
+ const announced = remoteDefault(root);
144
+ if (announced) return announced;
145
+ for (const name of ['main', 'master']) {
146
+ if (git(root, ['rev-parse', '--verify', '--quiet', `origin/${name}^{commit}`]).ok) return { ref: `origin/${name}`, name };
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** `git fetch origin`, reported rather than thrown: offline is a warning, not a crash. */
152
+ export function fetchOrigin(root) {
153
+ const r = git(root, ['fetch', '--quiet', 'origin']);
154
+ return { ok: r.ok, err: r.err };
155
+ }
156
+
157
+ // A worktree name becomes a directory under `.claude/worktrees/` and, by default, a branch. One
158
+ // segment of plain characters, so `../../elsewhere` or an absolute path can never be where it lands.
159
+ const WORKTREE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
160
+
161
+ /** Why this name cannot be used, or null when it can. */
162
+ export function invalidWorktreeName(name) {
163
+ if (typeof name !== 'string' || !name) return 'a name is required';
164
+ if (!WORKTREE_NAME.test(name) || name.includes('..') || name.endsWith('.lock') || name.endsWith('.')) {
165
+ return `"${name}" is not a valid worktree name: use letters, digits, '.', '_' or '-' (no '/', no '..')`;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * The preparation hook a project defines for a new worktree, or null.
172
+ *
173
+ * A worktree is born with what git tracks and nothing else: no `.env`, no dependencies. What each
174
+ * project needs to be runnable is the project's knowledge, not ours, so rsc does not guess it — it
175
+ * runs the project's own script if there is one. The new worktree's copy wins (it is the code of
176
+ * the branch that will run), the main checkout's is the fallback (a hook not yet committed).
177
+ */
178
+ export const PREPARE_HOOKS = ['scripts/dev/preparar-worktree.sh', 'scripts/dev/preparar-worktree.mjs'];
179
+ export function findPrepareHook(...dirs) {
180
+ for (const dir of dirs) {
181
+ for (const rel of PREPARE_HOOKS) if (existsSync(join(dir, rel))) return join(dir, rel);
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * Create a worktree at `.claude/worktrees/<name>` on a NEW branch cut from origin's default branch.
188
+ *
189
+ * `--no-track` because a branch cut from `origin/main` would otherwise track `origin/main`, and
190
+ * the first `git pull` from the worktree would merge the trunk into the feature behind your back.
191
+ * The branch gets its own upstream on its first `git push -u`.
192
+ */
193
+ export function createWorktree(root, name, { branch } = {}) {
194
+ const bad = invalidWorktreeName(name);
195
+ if (bad) return { created: false, reason: bad };
196
+ const ref = branch || name;
197
+ if (!git(root, ['check-ref-format', '--branch', ref]).ok) return { created: false, reason: `"${ref}" is not a valid branch name` };
198
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${ref}`]).ok) {
199
+ return { created: false, reason: `branch ${ref} already exists; pick another with --branch <rama>` };
200
+ }
201
+ const path = join(real(root), '.claude', 'worktrees', name);
202
+ if (existsSync(path)) return { created: false, reason: `${path} already exists` };
203
+
204
+ const fetched = fetchOrigin(root);
205
+ const base = defaultBranch(root);
206
+ if (!base) {
207
+ return { created: false, reason: 'no origin default branch to start from (no `origin` remote, or neither origin/main nor origin/master exists). Add the remote and push the trunk first.' };
208
+ }
209
+ mkdirSync(dirname(path), { recursive: true });
210
+ const add = git(root, ['worktree', 'add', '--quiet', '--no-track', '-b', ref, path, base.ref]);
211
+ if (!add.ok) return { created: false, reason: `git refused to create it: ${add.err || add.out}` };
212
+ return { created: true, path: real(path), branch: ref, base: base.ref, fetched: fetched.ok, fetchError: fetched.ok ? null : fetched.err };
213
+ }
214
+
215
+ /** Run the preparation hook inside the new worktree. Its output goes straight to the person. */
216
+ export function runPrepareHook(hook, worktreePath, mainRoot) {
217
+ const runner = hook.endsWith('.mjs') ? process.execPath : 'bash';
218
+ const r = spawnSync(runner, [hook], {
219
+ cwd: worktreePath,
220
+ stdio: 'inherit',
221
+ windowsHide: true,
222
+ env: { ...process.env, RSC_MAIN_WORKTREE: real(mainRoot), RSC_WORKTREE: worktreePath },
223
+ });
224
+ if (r.error) return { ok: false, reason: `could not start ${runner}: ${r.error.message}` };
225
+ return { ok: r.status === 0, reason: r.status === 0 ? null : `exited with code ${r.status}` };
226
+ }
227
+
228
+ /**
229
+ * The candidates a bulk `reap --merged` may take: landed and empty of anything unsaved, where the
230
+ * only thing missing is certainty about provenance. That doubt is what `--confirm` accepts, once,
231
+ * for the whole list the person was just shown. Nothing else is: a dirty worktree, or one holding a
232
+ * file in no commit, is never part of a bulk yes (test 14's rule, unchanged).
233
+ */
234
+ export function mergedCandidates(classified) {
235
+ return classified.filter((c) => c.verdict === 'safe'
236
+ || (c.verdict === 'ask' && c.reasons.length && c.reasons.every((r) => r === 'provenance-ambiguous')));
237
+ }
238
+
121
239
  /** Every working tree git knows about. The first entry is always the main one. */
122
240
  export function listWorktrees(root) {
123
241
  const { ok, out } = git(root, ['worktree', 'list', '--porcelain']);
@@ -166,8 +284,12 @@ export function provenanceOf(root, wt) {
166
284
  const p = wt.path;
167
285
  const home = real(root);
168
286
  const inside = (dir) => p === dir || p.startsWith(dir + sep);
287
+ // `.claude/worktrees/` is where Claude Code puts the worktrees it opens, and where
288
+ // `rsc worktrees new` puts them too. Left out, every one of them read as foreign: up to fourteen
289
+ // piled up in one user's project, invisible to the cleanup, until they were removed by hand.
169
290
  const location = inside(join(home, '.worktrees'))
170
291
  || inside(join(home, 'worktrees'))
292
+ || inside(join(home, '.claude', 'worktrees'))
171
293
  || (dirname(p) === dirname(home) && basename(p).startsWith(`${basename(home)}-`));
172
294
  const branch = Boolean(wt.branch && RSC_BRANCH.test(wt.branch));
173
295
  if (location && branch) return 'rsc';
@@ -236,7 +358,7 @@ export function integrationOf(root, wt, trunk) {
236
358
  * with nothing pending can still hold the only copy of an .env, a local database or a page of notes,
237
359
  * and removing the directory takes them with it. `--ignored` is the only way to be told they exist.
238
360
  */
239
- export function contentOutsideHistory(wtPath) {
361
+ export function contentOutsideHistory(wtPath, mainRoot) {
240
362
  const { ok, raw } = git(wtPath, ['status', '--porcelain', '-z', '-uall', '--ignored=matching']);
241
363
  const dirty = [];
242
364
  const outside = [];
@@ -252,12 +374,27 @@ export function contentOutsideHistory(wtPath) {
252
374
  // does not exist — the exact failure the `raw` comment above exists to prevent (P6).
253
375
  if (code[0] === 'R' || code[0] === 'C') { i++; dirty.push(path); continue; }
254
376
  if (code === '??' || code === '!!') {
255
- if (!isRegenerable(path)) outside.push(path);
377
+ if (!isRegenerable(path) && !copiedFromMain(wtPath, mainRoot, path)) outside.push(path);
256
378
  } else dirty.push(path);
257
379
  }
258
380
  return { dirty, outside, readable: true };
259
381
  }
260
382
 
383
+ // A file byte-identical to the same path in the main checkout is a copy, not the only copy: the
384
+ // preparation hook of `rsc worktrees new` puts the `.env` there exactly that way. Without this, every
385
+ // prepared worktree would hold "a file in no commit" for ever and no cleanup could take it. Compared
386
+ // by content, never by name: an `.env` edited inside the worktree differs and still blocks.
387
+ function copiedFromMain(wtPath, mainRoot, path) {
388
+ if (!mainRoot || path.endsWith('/')) return false;
389
+ const original = join(mainRoot, path);
390
+ if (real(original) === real(join(wtPath, path))) return false;
391
+ try {
392
+ return readFileSync(original).equals(readFileSync(join(wtPath, path)));
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
261
398
  // A directory name counts anywhere it is used as a DIRECTORY; a filename counts only as a filename.
262
399
  // Matching the table against the last segment made `config/env` — a dotenv full of live credentials —
263
400
  // read as a virtualenv and get deleted. Matching it only at position 0 fixed that and broke every
@@ -307,7 +444,7 @@ export function classifyWorktrees(root) {
307
444
  if (integration !== 'integrated') return { ...base, reasons: [integration] };
308
445
  if (!hasLandedWork(root, wt)) return { ...base, reasons: ['nothing-landed'] };
309
446
 
310
- const { dirty, outside, readable } = contentOutsideHistory(wt.path);
447
+ const { dirty, outside, readable } = contentOutsideHistory(wt.path, real(root));
311
448
  if (!readable) return { ...base, reasons: ['unreadable'] };
312
449
 
313
450
  const reasons = [];
@@ -371,7 +508,10 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
371
508
  // `-d` refuses an unmerged branch, which is the recovery net — but it happily deletes the TRUNK,
372
509
  // which is an ancestor of everything. A sibling checkout of `main` classified as ambiguous, the user
373
510
  // confirmed removing a stray directory, and the trunk ref went with it.
374
- const branchDeleted = candidate.branch && !isTrunkName(candidate.branch)
511
+ // The default branch the remote announces is a trunk too, whatever it is called.
512
+ const announced = remoteDefault(root);
513
+ const isTrunk = isTrunkName(candidate.branch || '') || Boolean(announced && candidate.branch === announced.name);
514
+ const branchDeleted = candidate.branch && !isTrunk
375
515
  ? git(root, ['branch', '-d', candidate.branch]).ok
376
516
  : false;
377
517
  // No `git worktree prune` here. It has no expiry of its own, so running it while another worktree's