@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 +2 -2
- package/src/cli.js +6 -1
- package/src/commands/change.js +490 -95
- package/src/commands/hooks-install.js +1 -2
- package/src/commands/hooks-status.js +9 -1
- package/src/commands/validate.js +1 -1
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aipper/aiws",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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.
|
package/src/commands/change.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(" -
|
|
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
|
}
|
package/src/commands/validate.js
CHANGED
|
@@ -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\.(
|
|
39
|
+
const m = key.match(/^submodule\.(.+)\.path$/);
|
|
40
40
|
if (!m) continue;
|
|
41
41
|
const name = m[1] || "";
|
|
42
42
|
if (!name || !subPath) continue;
|