@gethmy/harness 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/worktree.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
- import { existsSync, rmSync } from "node:fs";
2
+ import { existsSync, readdirSync, rmSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { log } from "./log.js";
5
5
  import { installCommand } from "./pm.js";
@@ -65,15 +65,21 @@ export function fetchBaseBranch(
65
65
  );
66
66
  }
67
67
  }
68
- // Prefer git's stderr (captured by `stdio: "pipe"`) over the generic
69
- // "Command failed: …" message so connectivity failures are legible (#444).
70
- const e = lastErr as { stderr?: unknown } | null;
71
- const detail =
72
- e?.stderr?.toString?.().trim() ||
73
- (lastErr instanceof Error ? lastErr.message : String(lastErr));
74
68
  throw new WorktreeBaseError(
75
69
  `Could not fetch origin/${baseBranch} after ${attempts} attempts — ` +
76
- `refusing to build on a stale base. ${detail}`,
70
+ `refusing to build on a stale base. ${gitErrorDetail(lastErr)}`,
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Prefer git's stderr (captured by `stdio: "pipe"`) over the generic
76
+ * "Command failed: …" message so connectivity failures are legible (#444).
77
+ */
78
+ function gitErrorDetail(err: unknown): string {
79
+ const e = err as { stderr?: unknown } | null;
80
+ return (
81
+ e?.stderr?.toString?.().trim() ||
82
+ (err instanceof Error ? err.message : String(err))
77
83
  );
78
84
  }
79
85
 
@@ -110,21 +116,89 @@ export function resolveWorktreeStartRef(
110
116
 
111
117
  /**
112
118
  * Fetch a branch from origin so `origin/<branchName>` resolves locally, and
113
- * report whether the branch exists on the remote. Connectivity has already been
114
- * proven by the preceding `fetchBaseBranch()`, so a failure here means the ref
115
- * simply isn't on origin yet (a fresh first-stage run) rather than a network
116
- * fault hence we return false instead of throwing.
119
+ * report whether the branch exists on the remote.
120
+ *
121
+ * This decides whether a continuation run keeps its branch or rebuilds from
122
+ * `origin/<baseBranch>`, and a rebuild ends in a force-push over the branch —
123
+ * so a wrong "false" here costs the user's pushed work. It therefore fails
124
+ * CLOSED. It used to swallow every error and return false on the reasoning
125
+ * that `fetchBaseBranch()` had just proven connectivity; that holds for the
126
+ * base ref a moment earlier, not for this ref now, so a transient per-ref
127
+ * failure (rate limit, packfile error, auth blip) read as "branch absent" and
128
+ * destroyed the branch.
129
+ *
130
+ * `ls-remote --exit-code` separates the two cases that must not be conflated:
131
+ * exit 2 means the ref genuinely is not on origin (a fresh first run — return
132
+ * false and start a fresh branch), any other failure means the probe itself
133
+ * failed and we must NOT conclude the branch is absent — throw
134
+ * `WorktreeBaseError`, which the worker already treats as an infrastructure
135
+ * failure and requeues without burning the give-up budget.
136
+ *
137
+ * The fetch then uses an explicit refspec: on a single-branch clone a bare
138
+ * `git fetch origin <branch>` succeeds without creating
139
+ * `refs/remotes/origin/<branch>`, so the start ref would not resolve.
117
140
  */
118
- function fetchExistingBranch(repoRoot: string, branchName: string): boolean {
119
- try {
120
- execFileSync("git", ["fetch", "origin", branchName], {
121
- cwd: repoRoot,
122
- stdio: "pipe",
123
- });
124
- return true;
125
- } catch {
126
- return false;
141
+ export function fetchExistingBranch(
142
+ repoRoot: string,
143
+ branchName: string,
144
+ attempts = 3,
145
+ lsRemoteImpl: (root: string, branch: string) => void = (root, branch) =>
146
+ execFileSync(
147
+ "git",
148
+ ["ls-remote", "--exit-code", "origin", `refs/heads/${branch}`],
149
+ { cwd: root, stdio: "pipe" },
150
+ ),
151
+ fetchImpl: (root: string, branch: string) => void = (root, branch) =>
152
+ execFileSync(
153
+ "git",
154
+ [
155
+ "fetch",
156
+ "origin",
157
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
158
+ ],
159
+ { cwd: root, stdio: "pipe" },
160
+ ),
161
+ ): boolean {
162
+ // Probe with the same retry cushion as fetchBaseBranch — a single-shot
163
+ // probe turned every transient per-ref failure into a WorktreeBaseError,
164
+ // and because that error burns no budget and arms no cooldown, a
165
+ // persistent remote fault hot-looped the requeue (#637 follow-up). Exit
166
+ // code 2 ("no matching refs") is a definitive answer, never retried.
167
+ let probed = false;
168
+ let lastErr: unknown;
169
+ for (let attempt = 1; attempt <= attempts && !probed; attempt++) {
170
+ try {
171
+ lsRemoteImpl(repoRoot, branchName);
172
+ probed = true;
173
+ } catch (err) {
174
+ if ((err as { status?: number }).status === 2) return false;
175
+ lastErr = err;
176
+ log.warn(
177
+ TAG,
178
+ `ls-remote ${branchName} failed (attempt ${attempt}/${attempts})`,
179
+ );
180
+ }
127
181
  }
182
+ if (!probed) {
183
+ throw new WorktreeBaseError(
184
+ `Could not determine whether ${branchName} exists on origin after ${attempts} attempts: ${gitErrorDetail(lastErr)}. Refusing to rebuild the branch — that would force-push over any pushed work.`,
185
+ );
186
+ }
187
+ for (let attempt = 1; attempt <= attempts; attempt++) {
188
+ try {
189
+ fetchImpl(repoRoot, branchName);
190
+ return true;
191
+ } catch (err) {
192
+ lastErr = err;
193
+ log.warn(
194
+ TAG,
195
+ `fetch ${branchName} failed (attempt ${attempt}/${attempts})`,
196
+ );
197
+ }
198
+ }
199
+ throw new WorktreeBaseError(
200
+ `${branchName} exists on origin but could not be fetched after ${attempts} attempts: ${gitErrorDetail(lastErr)}. Refusing to rebuild the branch.`,
201
+ );
128
202
  }
129
203
 
130
204
  export interface CreateWorktreeOptions {
@@ -141,6 +215,29 @@ export interface CreateWorktreeOptions {
141
215
  * Create a git worktree for the agent to work in.
142
216
  * Returns the absolute path to the new worktree.
143
217
  */
218
+ /**
219
+ * The worktree's current HEAD sha, or null if it cannot be read.
220
+ *
221
+ * Recorded right after `createWorktree` so a run can later count only the
222
+ * commits IT made. On a continued branch (`continueExisting`) the checkout
223
+ * starts on the branch's own tip, so a range measured from origin/<base>
224
+ * would be satisfied by the previous attempt's commits and a run that
225
+ * committed nothing would be scored a success (#915).
226
+ *
227
+ * Best-effort by design: a null baseline makes the caller fall back to the
228
+ * origin/<base> range, which is the behaviour that predates this helper.
229
+ */
230
+ export function readWorktreeHead(worktreePath: string): string | null {
231
+ try {
232
+ return execFileSync("git", ["rev-parse", "HEAD"], {
233
+ cwd: worktreePath,
234
+ encoding: "utf-8",
235
+ }).trim();
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
144
241
  export function createWorktree(
145
242
  basePath: string,
146
243
  baseBranch: string,
@@ -259,6 +356,25 @@ export function createWorktree(
259
356
  return worktreeDir;
260
357
  }
261
358
 
359
+ /**
360
+ * True when `dir` is not itself a git worktree or repository (no `.git`
361
+ * entry) while a direct child directory carries one — i.e. `dir` is a
362
+ * CONTAINER of worktrees, like `.harmony-worktrees/agent-attempts/`.
363
+ * Force-removing such a directory destroys every worktree beneath it, live
364
+ * runs included (#928), so `cleanupWorktree` refuses it outright instead of
365
+ * escalating to `rmSync`.
366
+ */
367
+ function containsForeignWorktrees(dir: string): boolean {
368
+ if (existsSync(resolve(dir, ".git"))) return false;
369
+ let children: string[];
370
+ try {
371
+ children = readdirSync(dir);
372
+ } catch {
373
+ return false;
374
+ }
375
+ return children.some((child) => existsSync(resolve(dir, child, ".git")));
376
+ }
377
+
262
378
  /**
263
379
  * Remove a git worktree and its branch.
264
380
  */
@@ -271,6 +387,16 @@ export function cleanupWorktree(
271
387
  }).trim();
272
388
 
273
389
  if (existsSync(worktreePath)) {
390
+ // Never escalate on a container: `git worktree remove` would fail on it
391
+ // (it is not a worktree), and the old rmSync fallback then deleted every
392
+ // worktree underneath — including live ones with uncommitted work (#928).
393
+ if (containsForeignWorktrees(worktreePath)) {
394
+ throw new Error(
395
+ `Refusing to remove ${worktreePath}: it is not a git worktree itself, ` +
396
+ `but git worktrees live directly beneath it. Removing it would ` +
397
+ `destroy them — pass the individual worktree paths instead (#928).`,
398
+ );
399
+ }
274
400
  try {
275
401
  execFileSync("git", ["worktree", "remove", worktreePath, "--force"], {
276
402
  cwd: repoRoot,