@aipper/aiws 0.0.23 → 0.0.24

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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@aipper/aiws",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex / iFlow.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aiws": "./bin/aiws.js"
8
8
  },
9
9
  "dependencies": {
10
- "@aipper/aiws-spec": "0.0.23"
10
+ "@aipper/aiws-spec": "0.0.24"
11
11
  },
12
12
  "files": [
13
13
  "bin",
package/src/cli.js CHANGED
@@ -370,12 +370,16 @@ export async function cliMain(argv) {
370
370
  const { positionals, options } = parseArgs(args, {
371
371
  into: { type: "string" },
372
372
  base: { type: "string" },
373
+ push: { type: "boolean" },
374
+ remote: { type: "string" },
373
375
  });
374
376
  const changeId = positionals[0];
375
377
  await changeFinishCommand({
376
378
  changeId,
377
379
  into: options.into,
378
380
  base: options.base,
381
+ push: options.push === true,
382
+ remote: options.remote,
379
383
  });
380
384
  return 0;
381
385
  }
@@ -493,7 +497,7 @@ function printChangeHelp() {
493
497
  Usage:
494
498
  aiws change list
495
499
  aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>]
496
- aiws change finish [change-id] [--into <branch> | --base <branch>]
500
+ aiws change finish [change-id] [--into <branch> | --base <branch>] [--push] [--remote <name>]
497
501
  aiws change new <change-id> [--title <title>] [--no-design]
498
502
  aiws change status [change-id]
499
503
  aiws change next [change-id]
@@ -513,6 +517,7 @@ Notes:
513
517
  (equivalent to running $ws-plan-verify in AI tools).
514
518
  - status/next now prioritize quality-gate guidance before coding ($ws-dev).
515
519
  - start refuses to switch/create worktree with a dirty working tree (unless --allow-dirty).
520
+ - finish --push is submodule-aware: when .gitmodules exists it pushes submodules first, then the target branch, then safely cleans the separate change worktree if possible.
516
521
  - If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
517
522
  start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
518
523
  - archive runs strict validation and (by default) requires all tasks checked.
@@ -165,7 +165,7 @@ async function maybeRecordBaseBranch(changeDir, baseBranch, changeBranch) {
165
165
 
166
166
  /**
167
167
  * @param {string} gitRoot
168
- * @returns {Promise<Array<{ worktree: string, head: string, branch: string }>>}
168
+ * @returns {Promise<Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>>}
169
169
  */
170
170
  async function listGitWorktrees(gitRoot) {
171
171
  const res = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot });
@@ -176,16 +176,16 @@ async function listGitWorktrees(gitRoot) {
176
176
  .split(/\r?\n/)
177
177
  .map((l) => l.trimEnd());
178
178
 
179
- /** @type {Array<{ worktree: string, head: string, branch: string }>} */
179
+ /** @type {Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>} */
180
180
  const out = [];
181
- /** @type {{ worktree: string, head: string, branch: string } | null} */
181
+ /** @type {{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string } | null} */
182
182
  let cur = null;
183
183
 
184
184
  for (const line of lines) {
185
185
  if (!line) continue;
186
186
  if (line.startsWith("worktree ")) {
187
187
  if (cur) out.push(cur);
188
- cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "" };
188
+ cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "", locked: false, lockReason: "" };
189
189
  continue;
190
190
  }
191
191
  if (!cur) continue;
@@ -197,11 +197,451 @@ async function listGitWorktrees(gitRoot) {
197
197
  cur.branch = line.slice("branch ".length).trim();
198
198
  continue;
199
199
  }
200
+ if (line === "locked" || line.startsWith("locked ")) {
201
+ cur.locked = true;
202
+ cur.lockReason = line === "locked" ? "" : line.slice("locked ".length).trim();
203
+ continue;
204
+ }
200
205
  }
201
206
  if (cur) out.push(cur);
202
207
  return out;
203
208
  }
204
209
 
210
+ /**
211
+ * @param {string} gitRoot
212
+ * @param {string} branch
213
+ * @param {string | undefined} preferredRemote
214
+ */
215
+ async function resolvePushTarget(gitRoot, branch, preferredRemote) {
216
+ const remoteRaw = String(preferredRemote || "").trim();
217
+ const branchRemoteRes = await runCommand("git", ["config", "--get", `branch.${branch}.remote`], { cwd: gitRoot });
218
+ const trackedRemote = String(branchRemoteRes.stdout || "").trim();
219
+
220
+ const branchMergeRes = await runCommand("git", ["config", "--get", `branch.${branch}.merge`], { cwd: gitRoot });
221
+ const trackedMergeRef = String(branchMergeRes.stdout || "").trim();
222
+ const trackedBranch = trackedMergeRef.startsWith("refs/heads/") ? trackedMergeRef.slice("refs/heads/".length) : "";
223
+
224
+ const remote = remoteRaw || trackedRemote || "origin";
225
+ const remoteBranch = trackedBranch || branch;
226
+ const refspec = remoteBranch === branch ? branch : `${branch}:${remoteBranch}`;
227
+
228
+ return { remote, remoteBranch, refspec, trackedRemote, trackedMergeRef };
229
+ }
230
+
231
+ /**
232
+ * @param {string} gitRoot
233
+ * @param {string} worktreePath
234
+ */
235
+ async function cleanupWorktreePath(gitRoot, worktreePath) {
236
+ const abs = path.resolve(worktreePath);
237
+ if (abs === path.resolve(gitRoot)) {
238
+ return { status: "skipped", reason: "current_worktree", worktree: abs };
239
+ }
240
+
241
+ const worktrees = await listGitWorktrees(gitRoot);
242
+ const worktreeEntry = worktrees.find((w) => path.resolve(w.worktree) === abs);
243
+ if (worktreeEntry?.locked) {
244
+ return { status: "skipped", reason: "locked", worktree: abs, details: worktreeEntry.lockReason };
245
+ }
246
+
247
+ if (!(await pathExists(abs))) {
248
+ const prune = await runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
249
+ if (prune.code !== 0) {
250
+ throw new UserError("Failed to prune stale git worktree metadata.", { details: prune.stderr || prune.stdout });
251
+ }
252
+ return { status: "pruned-missing", worktree: abs };
253
+ }
254
+
255
+ const clean = await checkGitClean(abs);
256
+ if (!clean.clean) {
257
+ return { status: "skipped", reason: "dirty", worktree: abs, details: clean.details };
258
+ }
259
+
260
+ let rm = await runCommand("git", ["worktree", "remove", abs], { cwd: gitRoot });
261
+ if (rm.code !== 0) {
262
+ const out = `${rm.stderr || ""}\n${rm.stdout || ""}`;
263
+ if (out.includes("working trees containing submodules cannot be moved or removed")) {
264
+ rm = await runCommand("git", ["worktree", "remove", "--force", abs], { cwd: gitRoot });
265
+ }
266
+ }
267
+ if (rm.code !== 0) {
268
+ throw new UserError("Failed to remove change worktree.", { details: rm.stderr || rm.stdout });
269
+ }
270
+
271
+ const prune = await runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
272
+ if (prune.code !== 0) {
273
+ throw new UserError("Failed to prune git worktree metadata after remove.", { details: prune.stderr || prune.stdout });
274
+ }
275
+
276
+ return { status: "removed", worktree: abs };
277
+ }
278
+
279
+ /**
280
+ * @param {string} gitRoot
281
+ * @returns {Promise<Array<{ name: string, path: string }>>}
282
+ */
283
+ async function listSubmodulesFromGitmodules(gitRoot) {
284
+ const gitmodules = path.join(gitRoot, ".gitmodules");
285
+ if (!(await pathExists(gitmodules))) return [];
286
+
287
+ const list = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: gitRoot });
288
+ if (list.code !== 0) return [];
289
+
290
+ /** @type {Array<{ name: string, path: string }>} */
291
+ const subs = [];
292
+ for (const raw of String(list.stdout || "").split("\n")) {
293
+ const line = raw.trim();
294
+ if (!line) continue;
295
+ const idx = line.indexOf(" ");
296
+ if (idx <= 0) continue;
297
+ const key = line.slice(0, idx).trim();
298
+ const subPath = line.slice(idx + 1).trim();
299
+ const m = key.match(/^submodule\.(.+)\.path$/);
300
+ if (!m) continue;
301
+ const name = String(m[1] || "").trim();
302
+ if (!name || !subPath) continue;
303
+ subs.push({ name, path: subPath });
304
+ }
305
+ return subs;
306
+ }
307
+
308
+ /**
309
+ * @param {string} targetsPath
310
+ */
311
+ async function parseSubmoduleTargetsFile(targetsPath) {
312
+ /** @type {Map<string, { branch: string, remote: string }>} */
313
+ const parsed = new Map();
314
+ /** @type {string[]} */
315
+ const errors = [];
316
+
317
+ if (!(await pathExists(targetsPath))) {
318
+ return { parsed, errors };
319
+ }
320
+
321
+ const text = await readText(targetsPath);
322
+ const lines = String(text || "").split(/\r?\n/);
323
+ for (let i = 0; i < lines.length; i += 1) {
324
+ const raw = lines[i] || "";
325
+ const line = raw.trim();
326
+ if (!line || line.startsWith("#")) continue;
327
+ const parts = line.split(/\s+/).filter(Boolean);
328
+ if (parts.length < 2) {
329
+ errors.push(`${path.basename(targetsPath)}:${i + 1} invalid line (expected: <path> <target_branch> [remote])`);
330
+ continue;
331
+ }
332
+ const subPath = String(parts[0] || "").trim();
333
+ const targetBranch = String(parts[1] || "").trim();
334
+ const remote = String(parts[2] || "origin").trim() || "origin";
335
+ if (!subPath) {
336
+ errors.push(`${path.basename(targetsPath)}:${i + 1} missing submodule path`);
337
+ continue;
338
+ }
339
+ if (parsed.has(subPath)) {
340
+ errors.push(`${path.basename(targetsPath)}:${i + 1} duplicate submodule path: ${subPath}`);
341
+ continue;
342
+ }
343
+ if (!targetBranch || ["<fill-me>", "<fill_me>", "tbd", "TBD"].includes(targetBranch)) {
344
+ errors.push(`${path.basename(targetsPath)}:${i + 1} missing/placeholder target_branch for ${subPath}`);
345
+ continue;
346
+ }
347
+ parsed.set(subPath, { branch: targetBranch, remote });
348
+ }
349
+
350
+ return { parsed, errors };
351
+ }
352
+
353
+ /**
354
+ * @param {string} repoPath
355
+ * @param {string} rev
356
+ */
357
+ async function gitHasRevision(repoPath, rev) {
358
+ if (!(await pathExists(repoPath))) return false;
359
+ const res = await runCommand("git", ["-C", repoPath, "cat-file", "-e", `${rev}^{commit}`], { cwd: repoPath });
360
+ return res.code === 0;
361
+ }
362
+
363
+ /**
364
+ * @param {string} repoPath
365
+ * @returns {Promise<boolean>}
366
+ */
367
+ async function isGitRepository(repoPath) {
368
+ if (!(await pathExists(repoPath))) return false;
369
+ const res = await runCommand("git", ["-C", repoPath, "rev-parse", "--git-dir"], { cwd: repoPath });
370
+ return res.code === 0;
371
+ }
372
+
373
+ /**
374
+ * @param {string} targetRepoPath
375
+ * @param {string} sha
376
+ * @param {string | undefined} fallbackRepoPath
377
+ */
378
+ async function ensureRevisionAvailable(targetRepoPath, sha, fallbackRepoPath) {
379
+ if (await gitHasRevision(targetRepoPath, sha)) return;
380
+ const fallback = String(fallbackRepoPath || "").trim();
381
+ if (!fallback) {
382
+ throw new UserError("Required submodule commit is not available locally.", {
383
+ details: `repo: ${targetRepoPath}\ncommit: ${sha}`,
384
+ });
385
+ }
386
+ if (!(await gitHasRevision(fallback, sha))) {
387
+ throw new UserError("Required submodule commit is missing in both target and change worktrees.", {
388
+ details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}`,
389
+ });
390
+ }
391
+ const fetch = await runCommand("git", ["-C", targetRepoPath, "fetch", fallback, sha], { cwd: targetRepoPath });
392
+ if (fetch.code !== 0 || !(await gitHasRevision(targetRepoPath, sha))) {
393
+ throw new UserError("Failed to import submodule commit from change worktree.", {
394
+ details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}\n\n${fetch.stderr || fetch.stdout}`,
395
+ });
396
+ }
397
+ }
398
+
399
+ /**
400
+ * @param {string} repoPath
401
+ * @param {string} remote
402
+ * @param {string} branch
403
+ * @param {string} sha
404
+ */
405
+ async function pushSubmoduleBranch(repoPath, remote, branch, sha) {
406
+ const fetch = await runCommand("git", ["-C", repoPath, "fetch", remote, "--prune"], { cwd: repoPath });
407
+ if (fetch.code !== 0) {
408
+ throw new UserError("Failed to fetch submodule remote before push.", {
409
+ details: `repo: ${repoPath}\nremote: ${remote}\n\n${fetch.stderr || fetch.stdout}`,
410
+ });
411
+ }
412
+
413
+ const remoteRef = `refs/remotes/${remote}/${branch}`;
414
+ const hasRemoteBranch = await runCommand("git", ["-C", repoPath, "show-ref", "--verify", "--quiet", remoteRef], { cwd: repoPath }).then((r) => r.code === 0);
415
+ if (!hasRemoteBranch) {
416
+ throw new UserError("Submodule target branch does not exist on remote.", {
417
+ details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}`,
418
+ });
419
+ }
420
+
421
+ const remoteAncestor = await runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", remoteRef, sha], { cwd: repoPath }).then((r) => r.code === 0);
422
+ if (remoteAncestor) {
423
+ const push = await runCommand("git", ["-C", repoPath, "push", remote, `${sha}:refs/heads/${branch}`], { cwd: repoPath });
424
+ if (push.code !== 0) {
425
+ throw new UserError("Failed to fast-forward push submodule target branch.", {
426
+ details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\n${push.stderr || push.stdout}`,
427
+ });
428
+ }
429
+ return { status: "pushed", remoteRef };
430
+ }
431
+
432
+ const shaContained = await runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", sha, remoteRef], { cwd: repoPath }).then((r) => r.code === 0);
433
+ if (shaContained) {
434
+ return { status: "already-contained", remoteRef };
435
+ }
436
+
437
+ throw new UserError("Submodule target branch diverged from gitlink commit.", {
438
+ details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\nHint: reconcile the submodule branch manually, then retry.`,
439
+ });
440
+ }
441
+
442
+ /**
443
+ * @param {string} gitRoot
444
+ * @param {string} changeId
445
+ * @param {string} baseBranch
446
+ * @param {string | undefined} changeWorktreePath
447
+ */
448
+ async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, changeWorktreePath) {
449
+ const subs = await listSubmodulesFromGitmodules(gitRoot);
450
+ if (subs.length === 0) return { processed: 0 };
451
+
452
+ const targetsPath = path.join(gitRoot, "changes", changeId, "submodules.targets");
453
+ if (!(await pathExists(targetsPath))) {
454
+ throw new UserError("Missing submodules.targets for submodule-aware finish.", {
455
+ details: `required: ${path.relative(gitRoot, targetsPath)}\n\nHint: add one entry per submodule path before running \`aiws change finish --push\`.`,
456
+ });
457
+ }
458
+
459
+ const parsedTargets = await parseSubmoduleTargetsFile(targetsPath);
460
+ if (parsedTargets.errors.length > 0) {
461
+ throw new UserError("Invalid submodules.targets.", { details: parsedTargets.errors.join("\n") });
462
+ }
463
+
464
+ const missingPaths = subs.filter((s) => !parsedTargets.parsed.has(s.path)).map((s) => s.path);
465
+ if (missingPaths.length > 0) {
466
+ throw new UserError("submodules.targets missing declared submodule paths.", {
467
+ details: `file: ${path.relative(gitRoot, targetsPath)}\nmissing: ${missingPaths.join(", ")}`,
468
+ });
469
+ }
470
+
471
+ /** @type {Array<{ name: string, path: string, sha: string, branch: string, remote: string, pinBranch: string }>} */
472
+ const specs = [];
473
+ for (const sub of subs) {
474
+ const target = parsedTargets.parsed.get(sub.path);
475
+ if (!target) continue;
476
+ const targetBranch = target.branch === "." ? baseBranch : target.branch;
477
+ const gitlink = await runCommand("git", ["rev-parse", `HEAD:${sub.path}`], { cwd: gitRoot });
478
+ if (gitlink.code !== 0) {
479
+ throw new UserError("Failed to resolve submodule gitlink commit from merged superproject.", {
480
+ details: `path: ${sub.path}\n\n${gitlink.stderr || gitlink.stdout}`,
481
+ });
482
+ }
483
+ const sha = String(gitlink.stdout || "").trim();
484
+ specs.push({
485
+ name: sub.name,
486
+ path: sub.path,
487
+ sha,
488
+ branch: targetBranch,
489
+ remote: target.remote,
490
+ pinBranch: `aiws/pin/${targetBranch}`,
491
+ });
492
+ }
493
+
494
+ for (const spec of specs) {
495
+ const targetRepoPath = path.join(gitRoot, spec.path);
496
+ const changeRepoPath = changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "";
497
+ const targetRepoReady = await isGitRepository(targetRepoPath);
498
+ const changeRepoReady = changeRepoPath ? await isGitRepository(changeRepoPath) : false;
499
+ if (!targetRepoReady && !changeRepoReady) {
500
+ throw new UserError("Submodule repository is not initialized in either target or change worktree.", {
501
+ details: `path: ${spec.path}\nHint: initialize submodules before finish, or keep using worktree mode for changes involving submodules.`,
502
+ });
503
+ }
504
+
505
+ const sourceRepoPath = (targetRepoReady && (await gitHasRevision(targetRepoPath, spec.sha))) ? targetRepoPath : changeRepoReady ? changeRepoPath : targetRepoPath;
506
+ if (!(await gitHasRevision(sourceRepoPath, spec.sha))) {
507
+ throw new UserError("Merged submodule gitlink commit is not available locally.", {
508
+ details: `path: ${spec.path}\ncommit: ${spec.sha}\nsource_repo: ${sourceRepoPath}`,
509
+ });
510
+ }
511
+
512
+ const pushResult = await pushSubmoduleBranch(sourceRepoPath, spec.remote, spec.branch, spec.sha);
513
+ console.log(
514
+ `submodule push: ${spec.path} (${spec.remote} ${spec.branch}${pushResult.status === "pushed" ? ` <= ${spec.sha}` : ", already contained"})`,
515
+ );
516
+ }
517
+
518
+ const sync = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: gitRoot });
519
+ if (sync.code !== 0) {
520
+ throw new UserError("Submodule push succeeded, but failed to sync target worktree submodules.", {
521
+ details: sync.stderr || sync.stdout,
522
+ });
523
+ }
524
+
525
+ for (const spec of specs) {
526
+ const targetRepoPath = path.join(gitRoot, spec.path);
527
+ if (!(await isGitRepository(targetRepoPath))) {
528
+ throw new UserError("Submodule repo missing after sync.", { details: `path: ${spec.path}` });
529
+ }
530
+ await ensureRevisionAvailable(targetRepoPath, spec.sha, changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "");
531
+ const fetch = await runCommand("git", ["-C", targetRepoPath, "fetch", spec.remote, "--prune"], { cwd: targetRepoPath });
532
+ if (fetch.code !== 0) {
533
+ throw new UserError("Failed to refresh target worktree submodule remote.", {
534
+ details: `path: ${spec.path}\nremote: ${spec.remote}\n\n${fetch.stderr || fetch.stdout}`,
535
+ });
536
+ }
537
+ const checkout = await runCommand("git", ["-C", targetRepoPath, "checkout", "-B", spec.pinBranch, spec.sha], { cwd: targetRepoPath });
538
+ if (checkout.code !== 0) {
539
+ throw new UserError("Failed to attach target worktree submodule to pin branch.", {
540
+ details: `path: ${spec.path}\npin_branch: ${spec.pinBranch}\ncommit: ${spec.sha}\n\n${checkout.stderr || checkout.stdout}`,
541
+ });
542
+ }
543
+ await runCommand("git", ["-C", targetRepoPath, "branch", "--set-upstream-to", `${spec.remote}/${spec.branch}`, spec.pinBranch], { cwd: targetRepoPath });
544
+ }
545
+
546
+ return { processed: specs.length };
547
+ }
548
+
549
+ /**
550
+ * @param {string} gitRoot
551
+ * @param {{ changeId?: string, into?: string, base?: string }} options
552
+ */
553
+ async function resolveFinishContext(gitRoot, options) {
554
+ const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
555
+ assertValidChangeId(changeId);
556
+
557
+ const changeBranch = `change/${changeId}`;
558
+ const changeBranchRef = `refs/heads/${changeBranch}`;
559
+
560
+ const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
561
+ if (!hasChangeBranch) {
562
+ throw new UserError(`Missing change branch: ${changeBranch}`, {
563
+ details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
564
+ });
565
+ }
566
+
567
+ const clean = await checkGitClean(gitRoot);
568
+ if (!clean.clean) {
569
+ throw new UserError("Refusing to finish with a dirty working tree.", {
570
+ details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
571
+ });
572
+ }
573
+
574
+ const cur = await currentBranch(gitRoot);
575
+ const intoRaw = String(options.into || "").trim();
576
+ const baseRaw = String(options.base || "").trim();
577
+ if (intoRaw && baseRaw && intoRaw !== baseRaw) {
578
+ throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
579
+ }
580
+
581
+ /** @type {string} */
582
+ let into = intoRaw || baseRaw;
583
+
584
+ if (!into) {
585
+ if (!cur) {
586
+ throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
587
+ details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
588
+ });
589
+ }
590
+ if (cur !== changeBranch) {
591
+ into = cur;
592
+ } else {
593
+ const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
594
+ /** @type {any} */
595
+ let meta = null;
596
+ if (await pathExists(metaPath)) {
597
+ try {
598
+ meta = JSON.parse(await readText(metaPath));
599
+ } catch {
600
+ meta = null;
601
+ }
602
+ }
603
+ if (!meta) {
604
+ const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
605
+ if (show.code === 0) {
606
+ try {
607
+ meta = JSON.parse(String(show.stdout || ""));
608
+ } catch {
609
+ meta = null;
610
+ }
611
+ }
612
+ }
613
+ const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
614
+ if (!inferred) {
615
+ throw new UserError("Cannot infer base branch for finish.", {
616
+ details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
617
+ });
618
+ }
619
+ into = inferred;
620
+ }
621
+ }
622
+
623
+ if (into === changeBranch) {
624
+ throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
625
+ }
626
+
627
+ const worktrees = await listGitWorktrees(gitRoot);
628
+ const intoRef = `refs/heads/${into}`;
629
+ const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
630
+ if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
631
+ throw new UserError("Target branch is checked out in another worktree.", {
632
+ details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
633
+ });
634
+ }
635
+
636
+ const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
637
+ if (!hasIntoBranch) {
638
+ throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
639
+ }
640
+
641
+ const changeWt = worktrees.find((w) => String(w.branch || "") === changeBranchRef);
642
+ return { changeId, changeBranch, changeBranchRef, into, cur, worktrees, changeWt };
643
+ }
644
+
205
645
  /**
206
646
  * @param {string} gitRoot
207
647
  * @param {string | undefined} worktreeDir
@@ -1726,98 +2166,13 @@ export async function changeStartCommand(options) {
1726
2166
  /**
1727
2167
  * aiws change finish
1728
2168
  *
1729
- * @param {{ changeId?: string, into?: string, base?: string }} options
2169
+ * @param {{ changeId?: string, into?: string, base?: string, push?: boolean, remote?: string }} options
1730
2170
  */
1731
2171
  export async function changeFinishCommand(options) {
1732
2172
  const gitRoot = await resolveGitRoot(process.cwd());
1733
2173
  await ensureTruthFiles(gitRoot);
1734
2174
 
1735
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1736
- assertValidChangeId(changeId);
1737
-
1738
- const changeBranch = `change/${changeId}`;
1739
- const changeBranchRef = `refs/heads/${changeBranch}`;
1740
-
1741
- const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
1742
- if (!hasChangeBranch) {
1743
- throw new UserError(`Missing change branch: ${changeBranch}`, {
1744
- details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
1745
- });
1746
- }
1747
-
1748
- const clean = await checkGitClean(gitRoot);
1749
- if (!clean.clean) {
1750
- throw new UserError("Refusing to finish with a dirty working tree.", {
1751
- details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
1752
- });
1753
- }
1754
-
1755
- const cur = await currentBranch(gitRoot);
1756
- const intoRaw = String(options.into || "").trim();
1757
- const baseRaw = String(options.base || "").trim();
1758
- if (intoRaw && baseRaw && intoRaw !== baseRaw) {
1759
- throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
1760
- }
1761
-
1762
- /** @type {string} */
1763
- let into = intoRaw || baseRaw;
1764
-
1765
- if (!into) {
1766
- if (!cur) {
1767
- throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
1768
- details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
1769
- });
1770
- }
1771
- if (cur !== changeBranch) {
1772
- into = cur;
1773
- } else {
1774
- const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
1775
- /** @type {any} */
1776
- let meta = null;
1777
- if (await pathExists(metaPath)) {
1778
- try {
1779
- meta = JSON.parse(await readText(metaPath));
1780
- } catch {
1781
- meta = null;
1782
- }
1783
- }
1784
- if (!meta) {
1785
- const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
1786
- if (show.code === 0) {
1787
- try {
1788
- meta = JSON.parse(String(show.stdout || ""));
1789
- } catch {
1790
- meta = null;
1791
- }
1792
- }
1793
- }
1794
- const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
1795
- if (!inferred) {
1796
- throw new UserError("Cannot infer base branch for finish.", {
1797
- details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
1798
- });
1799
- }
1800
- into = inferred;
1801
- }
1802
- }
1803
-
1804
- if (into === changeBranch) {
1805
- throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
1806
- }
1807
-
1808
- const worktrees = await listGitWorktrees(gitRoot);
1809
- const intoRef = `refs/heads/${into}`;
1810
- const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
1811
- if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
1812
- throw new UserError("Target branch is checked out in another worktree.", {
1813
- details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
1814
- });
1815
- }
1816
-
1817
- const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
1818
- if (!hasIntoBranch) {
1819
- throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
1820
- }
2175
+ const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, options);
1821
2176
 
1822
2177
  if (cur && cur !== into) {
1823
2178
  const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
@@ -1829,8 +2184,6 @@ export async function changeFinishCommand(options) {
1829
2184
 
1830
2185
  const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
1831
2186
  if (merge.code !== 0) {
1832
- const changeRef = changeBranchRef;
1833
- const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
1834
2187
  const extra =
1835
2188
  changeWt && changeWt.worktree
1836
2189
  ? `\n\nIf not fast-forward, rebase the change branch then retry:\n cd ${changeWt.worktree}\n git rebase ${into}\n cd ${gitRoot}\n aiws change finish ${changeId}`
@@ -1843,9 +2196,51 @@ export async function changeFinishCommand(options) {
1843
2196
  console.log(`ok: finished change: ${changeId}`);
1844
2197
  console.log(`into: ${into}`);
1845
2198
  console.log(`from: ${changeBranch}`);
1846
- if (await pathExists(path.join(gitRoot, ".gitmodules"))) {
2199
+
2200
+ if (options.push === true) {
2201
+ const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree);
2202
+ if (submodulePush.processed > 0) {
2203
+ console.log(`submodule push: ok (${submodulePush.processed} repos)`);
2204
+ }
2205
+
2206
+ const pushTarget = await resolvePushTarget(gitRoot, into, options.remote);
2207
+ const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
2208
+ if (push.code !== 0) {
2209
+ throw new UserError("Finished locally, but failed to push target branch.", {
2210
+ details:
2211
+ `remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
2212
+ (pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
2213
+ `\n${push.stderr || push.stdout}`,
2214
+ });
2215
+ }
2216
+
2217
+ console.log(`push: ok (${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""})`);
2218
+
2219
+ console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
2220
+
2221
+ if (changeWt?.worktree) {
2222
+ const cleanup = await cleanupWorktreePath(gitRoot, changeWt.worktree);
2223
+ if (cleanup.status === "removed") {
2224
+ console.log(`cleanup: removed worktree ${cleanup.worktree}`);
2225
+ } else if (cleanup.status === "pruned-missing") {
2226
+ console.log(`cleanup: pruned stale worktree metadata for ${cleanup.worktree}`);
2227
+ } else if (cleanup.reason === "current_worktree") {
2228
+ console.log("cleanup: skipped (change worktree is current worktree)");
2229
+ } else if (cleanup.reason === "locked") {
2230
+ console.log(`cleanup: skipped locked worktree ${cleanup.worktree}`);
2231
+ } else if (cleanup.reason === "dirty") {
2232
+ console.log(`cleanup: skipped dirty worktree ${cleanup.worktree}`);
2233
+ } else {
2234
+ console.log("cleanup: skipped");
2235
+ }
2236
+ } else {
2237
+ console.log("cleanup: skipped (no separate change worktree)");
2238
+ }
2239
+ }
2240
+
2241
+ if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot)).length > 0) {
1847
2242
  console.log("next:");
1848
- console.log(" - (optional) update submodules: git submodule update --init --recursive");
2243
+ console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if another worktree changed them later");
1849
2244
  }
1850
2245
  }
1851
2246
 
@@ -61,7 +61,7 @@ export async function hooksInstallCommand(options) {
61
61
  const templateId = await resolveTemplateIdForRepo(gitRoot);
62
62
  const tpl = await loadTemplate(templateId);
63
63
 
64
- const hookFiles = [".githooks/pre-commit", ".githooks/pre-push"];
64
+ const hookFiles = [".githooks/commit-msg", ".githooks/pre-commit", ".githooks/pre-push"];
65
65
  /** @type {string[]} */
66
66
  const created = [];
67
67
  /** @type {string[]} */
@@ -90,4 +90,3 @@ export async function hooksInstallCommand(options) {
90
90
  if (created.length > 0) console.log(`created: ${created.join(", ")}`);
91
91
  if (chmodded.length > 0) console.log(`chmod: ${chmodded.join(", ")}`);
92
92
  }
93
-
@@ -51,15 +51,22 @@ export async function hooksStatusCommand(options) {
51
51
  const normalizedHooksPath = hooksPath.replace(/[\\/]+$/, "");
52
52
 
53
53
  const githooksDir = path.join(gitRoot, ".githooks");
54
+ const commitMsg = path.join(githooksDir, "commit-msg");
54
55
  const preCommit = path.join(githooksDir, "pre-commit");
55
56
  const prePush = path.join(githooksDir, "pre-push");
56
57
 
57
58
  const githooksExists = await pathExists(githooksDir);
59
+ const commitMsgExists = await pathExists(commitMsg);
58
60
  const preCommitExists = await pathExists(preCommit);
59
61
  const prePushExists = await pathExists(prePush);
60
62
 
63
+ let commitMsgExec = false;
61
64
  let preCommitExec = false;
62
65
  let prePushExec = false;
66
+ if (commitMsgExists) {
67
+ const st = await fs.stat(commitMsg);
68
+ commitMsgExec = isExecutable(st);
69
+ }
63
70
  if (preCommitExists) {
64
71
  const st = await fs.stat(preCommit);
65
72
  preCommitExec = isExecutable(st);
@@ -72,6 +79,7 @@ export async function hooksStatusCommand(options) {
72
79
  console.log(`✓ aiws hooks status: ${gitRoot}`);
73
80
  console.log(`core.hooksPath: ${hooksPath ? hooksPath : "(default: .git/hooks)"}`);
74
81
  console.log(`.githooks/: ${githooksExists ? "ok" : "missing"}`);
82
+ console.log(`.githooks/commit-msg: ${commitMsgExists ? (commitMsgExec ? "ok" : "not-executable") : "missing"}`);
75
83
  console.log(`.githooks/pre-commit: ${preCommitExists ? (preCommitExec ? "ok" : "not-executable") : "missing"}`);
76
84
  console.log(`.githooks/pre-push: ${prePushExists ? (prePushExec ? "ok" : "not-executable") : "missing"}`);
77
85
 
@@ -80,7 +88,7 @@ export async function hooksStatusCommand(options) {
80
88
  console.log("Next: aiws hooks install .");
81
89
  return;
82
90
  }
83
- if (!githooksExists || !preCommitExists || !prePushExists || !preCommitExec || !prePushExec) {
91
+ if (!githooksExists || !commitMsgExists || !preCommitExists || !prePushExists || !commitMsgExec || !preCommitExec || !prePushExec) {
84
92
  console.log("Next: aiws hooks install .");
85
93
  return;
86
94
  }
@@ -36,7 +36,7 @@ async function validateSubmoduleBranchPolicy(workspaceRoot) {
36
36
  if (idx <= 0) continue;
37
37
  const key = t.slice(0, idx).trim();
38
38
  const subPath = t.slice(idx + 1).trim();
39
- const m = key.match(/^submodule\.([^.]+)\.path$/);
39
+ const m = key.match(/^submodule\.(.+)\.path$/);
40
40
  if (!m) continue;
41
41
  const name = m[1] || "";
42
42
  if (!name || !subPath) continue;