@ulysses-ai/create-workspace 0.19.0-beta.0 → 0.20.0-beta.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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // PR creation and merge for the task lifecycle (gh:163).
2
+ // PR creation and merge for the task lifecycle (gh:163), including local
3
+ // mode for repos with no forge to push to (gh:173).
3
4
  //
4
5
  // The task path of /complete-work used to end with two hand-written
5
6
  // JavaScript blocks — one to push and open a PR per repo, one to merge them
@@ -16,41 +17,67 @@
16
17
  // Usage:
17
18
  // node task-pr.mjs --create --root <launcher> --branch <branch>
18
19
  // [--work-item gh:N] [--chat <name> | --repo <r> ...]
19
- // --body-file <repo>=<path> ... [--out <file>]
20
+ // --body-file <forge-repo>=<path> ... [--out <file>]
20
21
  // [--force-with-lease]
21
22
  // node task-pr.mjs --merge --root <launcher> --prs <json-from-create>
22
23
  // [--work-item gh:N]
23
24
  //
25
+ // Every repo of the task resolves to a merge mode (mergeModeFor below):
26
+ // "forge" — its origin is a forge-hosted owner/name — or "local" — no
27
+ // origin at all, or an explicit "local" override in workspace.json
28
+ // (repos.{repo}.merge / workspace.merge), the escape hatch for a clone
29
+ // whose origin is a third-party upstream nobody here may push to. An
30
+ // origin that is neither stops the run with the override spelled out.
31
+ //
24
32
  // --create resolves the task's repos from the chat record's entries for
25
33
  // the branch (--chat) or from repeated --repo flags, skips repos whose
26
- // branch has no commits over origin/{default} (reported as empty), pushes
27
- // each remaining branch, and opens one PR per repo through a per-repo
28
- // forge. The PR title is the linked issue's title when --work-item is
29
- // given, else the branch's first commit subject; the body is the repo's
30
- // body file with `Closes <ref>` appended. Prints `{ prs, empty, pushed }`
31
- // and, with --out, writes the same JSON to a file — a mid-run failure
32
- // still writes what has landed so far, so the state survives the error.
33
- // A repo that already has an open PR for the branch gets it reused, so a
34
- // re-run never opens a duplicate.
34
+ // branch has no commits over the base — origin/{default} for a forge
35
+ // repo, the local {default} for a local repo, whose origin ref never
36
+ // advances — (reported as empty), pushes each remaining forge branch, and
37
+ // opens one PR per forge repo through a per-repo forge — a local repo is
38
+ // neither pushed nor PR'd, only recorded as a local entry for --merge (a
39
+ // body file is required per forge repo alone; one given for a local repo
40
+ // is ignored). The PR title is the linked issue's title when --work-item
41
+ // is given, else the branch's first commit subject; the body is the
42
+ // repo's body file with `Closes <ref>` appended. Prints
43
+ // `{ prs, empty, pushed }` — prs holds forge entries (mode "forge") and
44
+ // local entries (mode "local") alike, every entry carrying its commit
45
+ // count — and, with --out, writes the same JSON to a file — a mid-run
46
+ // failure still writes what has landed so far, so the state survives the
47
+ // error. A repo that already has an open PR for the branch gets it
48
+ // reused, so a re-run never opens a duplicate.
35
49
  //
36
- // --merge merges the project PRs first (squash, delete branch), the
37
- // workspace PR only when every project merge succeeded — a PR the forge
38
- // reports as already MERGED counts as done and is not merged again, which
39
- // is what makes re-running after a partial failure safe. An empty or
40
- // malformed PRs file is refused outright: no PRs means nothing to merge
41
- // and nothing to close. Once every PR is merged the launcher is pulled
50
+ // --merge finishes every entry in the file: forge PRs first (squash,
51
+ // delete branch) and local branches as a `git merge --ff-only` in the
52
+ // repo's source clone — repos/{repo}, or the launcher itself for "." —
53
+ // which must sit clean on its default branch, and whose task branch must
54
+ // fast-forward or be rebased by hand. The project repos merge first, the
55
+ // workspace repo only when every project merge succeeded; a PR the forge
56
+ // reports as already MERGED and a local branch already contained in the
57
+ // default branch both count as done, which is what makes re-running after
58
+ // a partial failure safe. An empty or malformed PRs file — or one naming
59
+ // a repo other than "." or a plain repo name, since a local entry's repo
60
+ // becomes a path — is refused outright: no entries means nothing to merge
61
+ // and nothing to close. Once everything is merged the launcher is pulled
42
62
  // --ff-only — only when it sits on the workspace default branch (else
43
- // pullSkipped), and a failed pull is reported as pullFailed in the JSON
44
- // rather than an error, because the merges stand and the issue still
45
- // closes — and then the linked issue closes with a Merged: comment. On a
46
- // merge failure it stops and names the PRs still open.
63
+ // pullSkipped), never when "." was itself merged locally (the launcher
64
+ // already has that work), when the workspace repo is local (no forge
65
+ // merge happened that a pull could fetch), or when the launcher branch
66
+ // has no upstream to pull from — pullSkipped names which — and a failed
67
+ // pull is reported as pullFailed in the JSON rather than an error,
68
+ // because the merges stand and the issue still closes — and then the
69
+ // linked issue closes with a Merged: comment, but only when --work-item
70
+ // is given AND a tracker is configured; with no tracker the JSON reports
71
+ // closed: null and closeSkipped. On a merge failure it stops and names
72
+ // what is still open.
47
73
 
48
74
  import '../lib/require-node.mjs';
49
75
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
50
- import { join, resolve, dirname } from 'node:path';
76
+ import { resolve, dirname } from 'node:path';
51
77
  import { spawnSync } from 'node:child_process';
52
78
  import { fileURLToPath } from 'node:url';
53
- import { taskWorktreePath, defaultBranchFor, WORKSPACE_REPO } from './task-worktree.mjs';
79
+ import { taskWorktreePath, defaultBranchFor } from './task-worktree.mjs';
80
+ import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
54
81
  import { readRecord } from './chat-record.mjs';
55
82
  import { createForge } from './forges/interface.mjs';
56
83
  import { createTracker } from './trackers/interface.mjs';
@@ -62,24 +89,6 @@ function isMainModule(metaUrl) {
62
89
  } catch { return false; }
63
90
  }
64
91
 
65
- // Same remote shapes the forge adapters resolve a repo from. A URL that
66
- // does not match is not forge-hosted, and this path supports forge-hosted
67
- // repos only — a local/bare remote has no PR concept to aim at.
68
- const FORGE_REMOTE_RE = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/;
69
-
70
- function parseForgeRemote(url) {
71
- const m = String(url).trim().match(FORGE_REMOTE_RE);
72
- return m ? { owner: m[1], name: m[2] } : null;
73
- }
74
-
75
- function readWorkspace(rootDir) {
76
- try {
77
- return JSON.parse(readFileSync(join(rootDir, 'workspace.json'), 'utf-8'));
78
- } catch {
79
- throw new Error(`cannot read ${join(rootDir, 'workspace.json')} — is --root the launcher?`);
80
- }
81
- }
82
-
83
92
  // `workspace.forge: false` is an explicit opt-out; spreading it into a
84
93
  // per-repo config would silently produce a default GitHub forge instead.
85
94
  function assertForgeEnabled(ws) {
@@ -123,29 +132,37 @@ function gitOut(gitFn, cwd, args) {
123
132
  }
124
133
 
125
134
  // The merge base for "is this branch empty" and for the fallback PR title.
126
- // origin/{default} is the truth the PR will merge against; the local branch
127
- // covers a repo whose remote-tracking ref is missing.
128
- function baseRefFor(gitFn, worktree, defaultBranch) {
129
- for (const ref of [`origin/${defaultBranch}`, defaultBranch]) {
135
+ // A forge repo counts against origin/{default} — the truth the PR will
136
+ // merge against — with the local branch as the fallback for a repo whose
137
+ // remote-tracking ref is missing. A local-mode repo counts against the
138
+ // local {default}, where its merges actually land: its origin ref never
139
+ // advances, so counting against it would re-count every already-merged
140
+ // commit (and misjudge every follow-up task as non-empty).
141
+ function baseRefFor(gitFn, target, branch) {
142
+ const { worktree, defaultBranch, mode } = target;
143
+ const refs = mode === 'local'
144
+ ? [defaultBranch, `origin/${defaultBranch}`]
145
+ : [`origin/${defaultBranch}`, defaultBranch];
146
+ for (const ref of refs) {
130
147
  if (gitCheck(gitFn, worktree, ['rev-parse', '--verify', '--quiet', ref]).status === 0) return ref;
131
148
  }
132
149
  return null;
133
150
  }
134
151
 
135
- function commitsOverBase(gitFn, worktree, branch, defaultBranch) {
136
- const base = baseRefFor(gitFn, worktree, defaultBranch);
152
+ function commitsOverBase(gitFn, target, branch) {
153
+ const base = baseRefFor(gitFn, target, branch);
137
154
  // No base ref at all: the branch cannot be proven empty, so it proceeds.
138
155
  if (!base) return null;
139
- return parseInt(gitOut(gitFn, worktree, ['rev-list', '--count', `${base}..${branch}`]), 10) || 0;
156
+ return parseInt(gitOut(gitFn, target.worktree, ['rev-list', '--count', `${base}..${branch}`]), 10) || 0;
140
157
  }
141
158
 
142
- function firstCommitSubject(gitFn, worktree, branch, defaultBranch) {
143
- const base = baseRefFor(gitFn, worktree, defaultBranch);
159
+ function firstCommitSubject(gitFn, target, branch) {
160
+ const base = baseRefFor(gitFn, target, branch);
144
161
  const range = base ? `${base}..${branch}` : branch;
145
- const subjects = gitOut(gitFn, worktree, ['log', '--reverse', '--format=%s', range])
162
+ const subjects = gitOut(gitFn, target.worktree, ['log', '--reverse', '--format=%s', range])
146
163
  .split(/\r?\n/).filter((l) => l.trim() !== '');
147
164
  if (subjects.length > 0) return subjects[0].trim();
148
- return gitOut(gitFn, worktree, ['log', '-1', '--format=%s', branch]);
165
+ return gitOut(gitFn, target.worktree, ['log', '-1', '--format=%s', branch]);
149
166
  }
150
167
 
151
168
  function pushBranch(gitFn, worktree, repo, branch, { forceWithLease = false } = {}) {
@@ -182,7 +199,6 @@ async function createPrs(args, deps) {
182
199
  assertBranchName(deps.gitFn, args.branch);
183
200
  const rootDir = resolve(args.root);
184
201
  const ws = readWorkspace(rootDir);
185
- assertForgeEnabled(ws);
186
202
 
187
203
  const targets = resolveTaskRepos(rootDir, args).map((repo) => {
188
204
  const worktree = taskWorktreePath(rootDir, repo, args.branch);
@@ -195,22 +211,34 @@ async function createPrs(args, deps) {
195
211
  };
196
212
  });
197
213
 
198
- for (const t of targets) t.commits = commitsOverBase(deps.gitFn, t.worktree, args.branch, t.defaultBranch);
214
+ // Mode first, because the commit count itself depends on it: a local
215
+ // repo counts against its local default branch, not the origin ref that
216
+ // never advances (gh:173).
217
+ for (const t of targets) t.mode = mergeModeFor(rootDir, t.repo, { gitFn: deps.gitFn });
218
+ for (const t of targets) t.commits = commitsOverBase(deps.gitFn, t, args.branch);
199
219
  const empty = targets.filter((t) => t.commits === 0).map((t) => t.repo);
200
220
  const active = targets.filter((t) => t.commits !== 0);
201
221
 
202
- // Every guard runs before the first push: a repo whose origin cannot
203
- // host a PR, or whose body file is missing, must stop the whole task
204
- // with nothing pushed and nothing half-opened.
222
+ // Every guard runs before the first push: a repo whose origin can neither
223
+ // host a PR nor opt out with "local", or whose body file is missing, must
224
+ // stop the whole task with nothing pushed and nothing half-opened. The
225
+ // same pass splits the task: local repos are recorded for --merge instead
226
+ // of being pushed. An empty repo is exempt from the origin check — there
227
+ // is nothing to merge however its remote is shaped.
205
228
  for (const t of active) {
229
+ if (t.mode !== null) continue;
206
230
  const url = gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin']);
207
- const parsed = parseForgeRemote(url);
208
- if (!parsed) {
209
- throw new Error(`repo "${t.repo}": origin (${url}) is not a forge-hosted repository — stopped before pushing anything. Complete this repo under the session model.`);
210
- }
211
- Object.assign(t, parsed);
231
+ const setting = t.isWorkspace ? 'workspace.merge' : `repos.${t.repo}.merge`;
232
+ throw new Error(`repo "${t.repo}": origin (${url}) is not a forge-hosted repository — stopped before pushing anything. Set ${setting} to "local" in workspace.json to merge this repo without a forge, or complete it under the session model.`);
212
233
  }
213
- for (const t of active) {
234
+ const forgeActive = active.filter((t) => t.mode === 'forge');
235
+ // Only the forge half of the task needs a forge; an all-local task must
236
+ // still complete with forge operations disabled.
237
+ if (forgeActive.length > 0) assertForgeEnabled(ws);
238
+ for (const t of forgeActive) {
239
+ Object.assign(t, parseForgeRemote(gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin'])));
240
+ }
241
+ for (const t of forgeActive) {
214
242
  const bodyFile = args.bodyFiles.get(t.repo);
215
243
  if (!bodyFile) {
216
244
  throw new Error(`repo "${t.repo}" has commits to merge but no --body-file — one is required for every repo that gets a PR`);
@@ -222,21 +250,29 @@ async function createPrs(args, deps) {
222
250
 
223
251
  let issueTitle = null;
224
252
  let issueRefs = null;
225
- if (args.workItem) {
226
- const tracker = deps.trackerFactory(ws.workspace?.tracker);
253
+ if (args.workItem && ws.workspace?.tracker) {
254
+ const tracker = deps.trackerFactory(ws.workspace.tracker);
227
255
  issueTitle = (await tracker.getIssue(args.workItem)).title;
228
- issueRefs = new Map(active.map((t) => [t.repo, tracker.issueRef(args.workItem, { fromRepo: `${t.owner}/${t.name}` })]));
256
+ issueRefs = new Map(forgeActive.map((t) => [t.repo, tracker.issueRef(args.workItem, { fromRepo: `${t.owner}/${t.name}` })]));
229
257
  }
230
258
 
231
- const prs = [];
259
+ // Local entries are recorded up front, before anything can fail: they are
260
+ // the run's only side effect for those repos, and losing one to a later
261
+ // forge failure would leave --merge unable to finish the task.
262
+ const prs = active
263
+ .filter((t) => t.mode === 'local')
264
+ .map((t) => ({
265
+ repo: t.repo, mode: 'local', branch: args.branch, base: t.defaultBranch,
266
+ worktree: t.worktree, commits: t.commits,
267
+ }));
232
268
  const pushed = [];
233
269
  try {
234
- for (const t of active) {
270
+ for (const t of forgeActive) {
235
271
  pushBranch(deps.gitFn, t.worktree, t.repo, args.branch, { forceWithLease: args.forceWithLease });
236
272
  pushed.push(t.repo);
237
273
  }
238
274
 
239
- for (const t of active) {
275
+ for (const t of forgeActive) {
240
276
  const forge = deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${t.owner}/${t.name}` });
241
277
  // Idempotency: a re-run must not open a second PR for a branch that
242
278
  // already has one open against the same base. Reuse it as-is —
@@ -244,13 +280,13 @@ async function createPrs(args, deps) {
244
280
  // default, and the one already open is the one reviewers watch.
245
281
  const existing = (await forge.prList({ state: 'open', head: args.branch, base: t.defaultBranch }))
246
282
  .find((p) => p.headRefName === args.branch && p.baseRefName === t.defaultBranch);
247
- const title = issueTitle ?? firstCommitSubject(deps.gitFn, t.worktree, args.branch, t.defaultBranch);
283
+ const title = issueTitle ?? firstCommitSubject(deps.gitFn, t, args.branch);
248
284
  let body = readFileSync(args.bodyFiles.get(t.repo), 'utf8').replace(/\s*$/, '');
249
285
  if (issueRefs) body = `${body}\n\nCloses ${issueRefs.get(t.repo)}\n`;
250
286
  const pr = existing ?? await forge.prCreate({ title, body, head: args.branch, base: t.defaultBranch });
251
287
  prs.push({
252
- repo: t.repo, owner: t.owner, name: t.name,
253
- number: pr.number, id: pr.id, url: pr.url, isWorkspace: t.isWorkspace,
288
+ repo: t.repo, mode: 'forge', owner: t.owner, name: t.name,
289
+ number: pr.number, id: pr.id, url: pr.url, isWorkspace: t.isWorkspace, commits: t.commits,
254
290
  });
255
291
  }
256
292
  } catch (err) {
@@ -269,10 +305,40 @@ async function createPrs(args, deps) {
269
305
  return result;
270
306
  }
271
307
 
308
+ // Merge one local-mode entry in its repo's source clone — repos/{repo} for
309
+ // a project repo, the launcher itself for ".". The clone must be sitting
310
+ // clean on its default branch: merging anywhere else would strand the
311
+ // checkout or swallow uncommitted work. --ff-only because a task branch
312
+ // that diverged from the local default needs a rebase first — deciding how
313
+ // to combine is the user's, not this script's. A branch already contained
314
+ // in the default branch counts as merged, which is what makes a re-run
315
+ // after a partial failure safe.
316
+ function mergeLocalEntry(gitFn, rootDir, entry) {
317
+ const repoDir = repoDirFor(rootDir, entry.repo);
318
+ const base = entry.base || defaultBranchFor(rootDir, entry.repo, gitFn);
319
+ const onBranch = gitOut(gitFn, repoDir, ['branch', '--show-current']);
320
+ if (onBranch !== base) {
321
+ throw new Error(`its source clone is on ${onBranch || 'a detached HEAD'}, not ${base} — check out ${base} and re-run --merge`);
322
+ }
323
+ if (String(gitCheck(gitFn, repoDir, ['status', '--porcelain']).stdout || '').trim() !== '') {
324
+ throw new Error('its source clone has uncommitted changes — commit or stash them and re-run --merge');
325
+ }
326
+ if (gitCheck(gitFn, repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${entry.branch}`]).status !== 0) {
327
+ throw new Error(`branch ${entry.branch} not found in its source clone — nothing to merge`);
328
+ }
329
+ if (gitCheck(gitFn, repoDir, ['merge-base', '--is-ancestor', entry.branch, base]).status === 0) {
330
+ return { alreadyMerged: true };
331
+ }
332
+ const res = gitCheck(gitFn, repoDir, ['merge', '--ff-only', entry.branch]);
333
+ if (res.status !== 0) {
334
+ throw new Error(`${entry.branch} is not fast-forwardable onto ${base} — rebase the task branch onto ${base} and re-run --merge`);
335
+ }
336
+ return { alreadyMerged: false };
337
+ }
338
+
272
339
  async function mergePrs(args, deps) {
273
340
  const rootDir = resolve(args.root);
274
341
  const ws = readWorkspace(rootDir);
275
- assertForgeEnabled(ws);
276
342
 
277
343
  let parsed;
278
344
  try {
@@ -290,11 +356,25 @@ async function mergePrs(args, deps) {
290
356
  if (prs.length === 0) {
291
357
  throw new Error(`PRs file ${args.prs} lists no PRs — nothing to merge and nothing to close; --merge refuses to close an issue on an empty merge`);
292
358
  }
359
+ // Entries written before modes existed carry no `mode`; they are forge
360
+ // PRs, the only kind --create used to emit. A local entry's repo becomes
361
+ // a path under repos/, so the file cannot be allowed to name anything
362
+ // but "." or a plain single segment.
363
+ const modeOf = (p) => p?.mode ?? 'forge';
364
+ const REPO_NAME_RE = /^[A-Za-z0-9._-]+$/;
293
365
  for (const p of prs) {
294
- if (!p || !p.owner || !p.name || !p.id) {
366
+ if (!p) throw new Error(`PRs file entry is missing owner/name/id: ${JSON.stringify(p)}`);
367
+ if (p.repo !== WORKSPACE_REPO
368
+ && (typeof p.repo !== 'string' || p.repo === '..' || !REPO_NAME_RE.test(p.repo))) {
369
+ throw new Error(`PRs file entry has an invalid repo name: ${JSON.stringify(p.repo)} — expected "." or a plain repo name`);
370
+ }
371
+ if (modeOf(p) === 'local') {
372
+ if (!p.branch) throw new Error(`PRs file entry is missing branch: ${JSON.stringify(p)}`);
373
+ } else if (!p.owner || !p.name || !p.id) {
295
374
  throw new Error(`PRs file entry is missing owner/name/id: ${JSON.stringify(p)}`);
296
375
  }
297
376
  }
377
+ if (prs.some((p) => modeOf(p) === 'forge')) assertForgeEnabled(ws);
298
378
  const forgeFor = (p) => deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${p.owner}/${p.name}` });
299
379
 
300
380
  // State first, so a re-run knows what an earlier run already finished.
@@ -304,57 +384,84 @@ async function mergePrs(args, deps) {
304
384
  // produces the honest error when something is genuinely wrong.
305
385
  const alreadyMerged = new Set();
306
386
  for (const p of prs) {
387
+ if (modeOf(p) !== 'forge') continue;
307
388
  try {
308
389
  const view = await forgeFor(p).prView({ id: p.id });
309
390
  if (view?.state === 'MERGED') alreadyMerged.add(p.id);
310
391
  } catch { /* state unknown — the merge attempt below decides */ }
311
392
  }
312
393
 
394
+ // Ordering rule: the project repos merge first — forge PRs and local
395
+ // branches alike, in file order — and the workspace repo last, only when
396
+ // every project merge succeeded: the workspace branch's promoted context
397
+ // describes the project merges and must never land ahead of them.
313
398
  const merged = [];
314
399
  const failures = [];
315
- for (const p of prs.filter((x) => !x.isWorkspace)) {
316
- if (alreadyMerged.has(p.id)) { merged.push(p); continue; }
317
- try {
400
+ const workspaceEntry = prs.find((p) => p.repo === WORKSPACE_REPO) ?? null;
401
+ const mergeOne = async (p) => {
402
+ if (modeOf(p) === 'local') {
403
+ await mergeLocalEntry(deps.gitFn, rootDir, p);
404
+ } else if (!alreadyMerged.has(p.id)) {
318
405
  await forgeFor(p).prMerge({ id: p.id, strategy: 'squash', deleteBranch: true });
319
- merged.push(p);
320
- } catch (err) { failures.push(`${p.repo}: ${err.message}`); }
321
- }
322
- const workspacePr = prs.find((x) => x.isWorkspace) ?? null;
323
- if (failures.length === 0 && workspacePr) {
324
- if (alreadyMerged.has(workspacePr.id)) {
325
- merged.push(workspacePr);
326
- } else {
327
- try {
328
- await forgeFor(workspacePr).prMerge({ id: workspacePr.id, strategy: 'squash', deleteBranch: true });
329
- merged.push(workspacePr);
330
- } catch (err) { failures.push(`${workspacePr.repo}: ${err.message}`); }
331
406
  }
407
+ merged.push(p);
408
+ };
409
+ for (const p of prs.filter((x) => x !== workspaceEntry)) {
410
+ try { await mergeOne(p); } catch (err) { failures.push(`${p.repo}: ${err.message}`); }
411
+ }
412
+ if (failures.length === 0 && workspaceEntry) {
413
+ try { await mergeOne(workspaceEntry); } catch (err) { failures.push(`${workspaceEntry.repo}: ${err.message}`); }
332
414
  }
333
415
  if (failures.length > 0) {
334
- const open = prs.filter((p) => !merged.includes(p)).map((p) => `${p.repo}: ${p.url}`);
416
+ const open = prs.filter((p) => !merged.includes(p)).map((p) => `${p.repo}: ${p.url ?? p.branch}`);
335
417
  throw new Error(
336
- `merge stopped (${failures.join('; ')}). PRs still open: ${open.join(', ')}. The workspace PR is never merged ahead of the project PRs — fix the failure and re-run --merge.`,
418
+ `merge stopped (${failures.join('; ')}). Still open: ${open.join(', ')}. The workspace repo is never merged ahead of the project repos — fix the failure and re-run --merge.`,
337
419
  );
338
420
  }
339
421
 
340
- // Every PR in the file is merged from here on. The launcher pull is a
422
+ // Every entry in the file is merged from here on. The launcher pull is a
341
423
  // convenience, not a gate: it happens only when the launcher sits on the
342
- // workspace default branch, a failure is a reported flag rather than an
343
- // error (the merges stand and the issue still closes), and the user is
344
- // told to pull by hand before teardown.
345
- const result = { merged: merged.map(({ repo, number, url }) => ({ repo, number, url })), closed: null };
346
- const launcherBranch = gitOut(deps.gitFn, rootDir, ['branch', '--show-current']) || '(detached HEAD)';
347
- if (launcherBranch !== defaultBranchFor(rootDir, WORKSPACE_REPO, deps.gitFn)) {
348
- result.pullSkipped = `launcher on ${launcherBranch}`;
424
+ // workspace default branch and "." itself was not merged locally — that
425
+ // merge ran in the launcher, so it already has the work — a failure is a
426
+ // reported flag rather than an error (the merges stand and the issue
427
+ // still closes), and the user is told to pull by hand before teardown.
428
+ const result = {
429
+ merged: merged.map((p) => (modeOf(p) === 'local'
430
+ ? { repo: p.repo, mode: 'local', branch: p.branch, base: p.base }
431
+ : { repo: p.repo, mode: 'forge', number: p.number, url: p.url })),
432
+ closed: null,
433
+ };
434
+ if (merged.some((p) => p.repo === WORKSPACE_REPO && modeOf(p) === 'local')) {
435
+ result.pullSkipped = 'workspace merged locally';
349
436
  } else {
350
- const pull = gitCheck(deps.gitFn, rootDir, ['pull', '--ff-only']);
351
- if (pull.status !== 0) result.pullFailed = true;
437
+ const launcherBranch = gitOut(deps.gitFn, rootDir, ['branch', '--show-current']) || '(detached HEAD)';
438
+ if (launcherBranch !== defaultBranchFor(rootDir, WORKSPACE_REPO, deps.gitFn)) {
439
+ result.pullSkipped = `launcher on ${launcherBranch}`;
440
+ } else if (mergeModeFor(rootDir, WORKSPACE_REPO, { gitFn: deps.gitFn }) === 'local') {
441
+ // The workspace repo merges into the launcher itself — or was not part
442
+ // of this task at all; either way no forge merge happened that a pull
443
+ // could fetch.
444
+ result.pullSkipped = 'workspace repo is local';
445
+ } else if (gitCheck(deps.gitFn, rootDir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).status !== 0) {
446
+ result.pullSkipped = 'launcher has no upstream';
447
+ } else {
448
+ const pull = gitCheck(deps.gitFn, rootDir, ['pull', '--ff-only']);
449
+ if (pull.status !== 0) result.pullFailed = true;
450
+ }
352
451
  }
353
452
 
453
+ // The close needs both halves: a work item to close, and a tracker to
454
+ // close it on. A workspace without a tracker still merges everything —
455
+ // it just reports the skipped close instead of pretending one happened.
354
456
  if (args.workItem) {
355
- const tracker = deps.trackerFactory(ws.workspace?.tracker);
356
- await tracker.closeIssue(args.workItem, { comment: `Merged: ${merged.map((p) => p.url).join(' ')}` });
357
- result.closed = args.workItem;
457
+ if (!ws.workspace?.tracker) {
458
+ result.closeSkipped = 'no tracker configured';
459
+ } else {
460
+ const tracker = deps.trackerFactory(ws.workspace.tracker);
461
+ const parts = merged.map((p) => (modeOf(p) === 'local' ? `${p.repo} ${p.branch}->${p.base}` : p.url));
462
+ await tracker.closeIssue(args.workItem, { comment: `Merged: ${parts.join(' ')}` });
463
+ result.closed = args.workItem;
464
+ }
358
465
  }
359
466
  return result;
360
467
  }
@@ -444,4 +551,5 @@ if (isMainModule(import.meta.url)) {
444
551
  });
445
552
  }
446
553
 
447
- export { run, parseArgs, parseForgeRemote };
554
+ export { run, parseArgs };
555
+ export { parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
@@ -36,6 +36,7 @@ import { join, resolve, relative, sep, isAbsolute, basename, dirname } from 'nod
36
36
  import { spawnSync } from 'node:child_process';
37
37
  import { fileURLToPath } from 'node:url';
38
38
  import { readRecord } from './chat-record.mjs';
39
+ import { WORKSPACE_REPO, repoDirFor, mergeModeFor } from './merge-mode.mjs';
39
40
 
40
41
  function isMainModule(metaUrl) {
41
42
  if (!process.argv[1]) return false;
@@ -45,9 +46,6 @@ function isMainModule(metaUrl) {
45
46
  }
46
47
 
47
48
  const WORKTREES_DIR = join('.claude', 'worktrees');
48
- // The workspace repo (the launcher) is addressed as "." — the one repo
49
- // name that is not a directory under repos/.
50
- const WORKSPACE_REPO = '.';
51
49
  // Exclude patterns are git syntax: forward slashes on every platform.
52
50
  const EXCLUDE_LINE = '.claude/worktrees/';
53
51
 
@@ -65,12 +63,22 @@ function readConfig(root) {
65
63
 
66
64
  // The workspace repo has no workspace.json entry naming its default branch
67
65
  // — origin's HEAD is the authority, with "main" as the fallback a fresh
68
- // clone would get. Project repos keep their configured branch.
66
+ // clone would get. A launcher with no origin never gets an origin/HEAD at
67
+ // all, and no push will ever name its default branch either, so it keeps
68
+ // the branch it sits on when that is main/master rather than being told it
69
+ // is on the wrong branch. Project repos keep their configured branch.
69
70
  function defaultBranchFor(root, repo, gitFn = spawnSync) {
70
71
  if (isWorkspaceRepo(repo)) {
71
- const res = gitFn('git', ['-C', resolve(root), 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { encoding: 'utf8' });
72
- const name = res && res.status === 0 ? String(res.stdout).trim().replace(/^origin\//, '') : '';
73
- return name || 'main';
72
+ const rootDir = resolve(root);
73
+ const head = gitFn('git', ['-C', rootDir, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { encoding: 'utf8' });
74
+ const name = head && head.status === 0 ? String(head.stdout).trim().replace(/^origin\//, '') : '';
75
+ if (name) return name;
76
+ if (run(gitFn, rootDir, ['remote', 'get-url', 'origin']).status !== 0) {
77
+ const on = gitFn('git', ['-C', rootDir, 'branch', '--show-current'], { encoding: 'utf8' });
78
+ const current = on && on.status === 0 ? String(on.stdout).trim() : '';
79
+ if (current === 'main' || current === 'master') return current;
80
+ }
81
+ return 'main';
74
82
  }
75
83
  const branch = readConfig(root)?.repos?.[repo]?.branch;
76
84
  return typeof branch === 'string' && branch ? branch : 'main';
@@ -111,12 +119,6 @@ function taskWorktreePath(root, repo, branch) {
111
119
  return join(repoDirFor(resolve(root), repo), WORKTREES_DIR, slugForBranch(branch));
112
120
  }
113
121
 
114
- // "." is the workspace repo itself — the git repo at the root. Every other
115
- // name is a directory under repos/.
116
- function repoDirFor(rootDir, repo) {
117
- return isWorkspaceRepo(repo) ? rootDir : join(rootDir, 'repos', repo);
118
- }
119
-
120
122
  // .native resolves Windows 8.3 short names; the plain fallback covers
121
123
  // filesystems where the native binding is unavailable.
122
124
  function realPath(p) {
@@ -233,10 +235,17 @@ function createTaskWorktree(root, { repo, branch, base = null, gitFn = spawnSync
233
235
  gitFn('git', ['-C', repoDir, 'fetch', 'origin'], { encoding: 'utf8', timeout: 10000 });
234
236
  run(gitFn, repoDir, ['worktree', 'prune']);
235
237
 
236
- // The base is a starting point, not a freshness guarantee: it is
237
- // origin's default branch as of the best-effort fetch above.
238
+ // The base is a starting point, not a freshness guarantee: origin's
239
+ // default branch as of the best-effort fetch above — unless the repo
240
+ // completes in local mode (gh:173: no origin, or merge: "local"), in
241
+ // which case the local default branch is the base. Local merges land
242
+ // there and never advance the origin ref, so preferring origin's would
243
+ // hand every task after the first local merge a stale, unmergeable base.
238
244
  const defaultBranch = defaultBranchFor(rootDir, repo, gitFn);
239
245
  const resolvedBase = base
246
+ || (mergeModeFor(rootDir, repo, { gitFn }) === 'local'
247
+ ? defaultBranch
248
+ : null)
240
249
  || (refExists(gitFn, repoDir, `refs/remotes/origin/${defaultBranch}`)
241
250
  ? `origin/${defaultBranch}`
242
251
  : defaultBranch);
@@ -520,6 +529,7 @@ if (isMainModule(import.meta.url)) {
520
529
 
521
530
  export {
522
531
  slugForBranch, taskWorktreePath, createTaskWorktree, removeTaskWorktree,
523
- detectWorkModel, parseArgs, defaultBranchFor, WORKSPACE_REPO,
532
+ detectWorkModel, parseArgs, defaultBranchFor,
524
533
  WORKTREES_DIR, EXCLUDE_LINE,
525
534
  };
535
+ export { WORKSPACE_REPO, repoDirFor } from './merge-mode.mjs';