@aipper/aiws 0.0.21 → 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/README.md +1 -1
- package/package.json +2 -2
- package/src/cli.js +11 -3
- package/src/commands/change.js +535 -103
- package/src/commands/hooks-install.js +1 -2
- package/src/commands/hooks-status.js +9 -1
- package/src/commands/update.js +20 -0
- package/src/commands/validate.js +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ aiws codex <install-skills|status-skills|uninstall-skills|install-prompts|status
|
|
|
47
47
|
|
|
48
48
|
## Codex(repo skills 优先)
|
|
49
49
|
|
|
50
|
-
- `aiws init .` 会生成 `.agents/skills/`(随仓库共享),在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$ws-dev` / `$ws-review` / `$ws-commit`
|
|
50
|
+
- `aiws init .` 会生成 `.agents/skills/`(随仓库共享),在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$p-tasks-plan` / `$ws-handoff` / `$ws-dev` / `$ws-review` / `$ws-commit`
|
|
51
51
|
- 交付收尾(submodules+superproject 分步提交 + 安全合并):`$ws-deliver`
|
|
52
52
|
- 收尾(安全合并回目标分支):`$ws-finish`(底层调用 `aiws change finish`,默认 fast-forward)
|
|
53
53
|
- 可选:安装全局 skills 到 `~/.codex/skills/`(或 `$CODEX_HOME/skills`):
|
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
|
@@ -260,6 +260,7 @@ export async function cliMain(argv) {
|
|
|
260
260
|
hooks: { type: "boolean" },
|
|
261
261
|
"no-switch": { type: "boolean" },
|
|
262
262
|
switch: { type: "boolean" },
|
|
263
|
+
"allow-dirty": { type: "boolean" },
|
|
263
264
|
worktree: { type: "boolean" },
|
|
264
265
|
"worktree-dir": { type: "string" },
|
|
265
266
|
submodules: { type: "boolean" },
|
|
@@ -268,7 +269,7 @@ export async function cliMain(argv) {
|
|
|
268
269
|
if (!changeId)
|
|
269
270
|
throw new UserError("change start requires <change-id>", {
|
|
270
271
|
details:
|
|
271
|
-
"Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>] [--submodules]",
|
|
272
|
+
"Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>] [--submodules]",
|
|
272
273
|
});
|
|
273
274
|
await changeStartCommand({
|
|
274
275
|
changeId,
|
|
@@ -277,6 +278,7 @@ export async function cliMain(argv) {
|
|
|
277
278
|
enableHooks: options.hooks === true,
|
|
278
279
|
noSwitch: options["no-switch"] === true,
|
|
279
280
|
forceSwitch: options.switch === true,
|
|
281
|
+
allowDirty: options["allow-dirty"] === true,
|
|
280
282
|
worktree: options.worktree === true,
|
|
281
283
|
worktreeDir: options["worktree-dir"],
|
|
282
284
|
submodules: options.submodules === true,
|
|
@@ -368,12 +370,16 @@ export async function cliMain(argv) {
|
|
|
368
370
|
const { positionals, options } = parseArgs(args, {
|
|
369
371
|
into: { type: "string" },
|
|
370
372
|
base: { type: "string" },
|
|
373
|
+
push: { type: "boolean" },
|
|
374
|
+
remote: { type: "string" },
|
|
371
375
|
});
|
|
372
376
|
const changeId = positionals[0];
|
|
373
377
|
await changeFinishCommand({
|
|
374
378
|
changeId,
|
|
375
379
|
into: options.into,
|
|
376
380
|
base: options.base,
|
|
381
|
+
push: options.push === true,
|
|
382
|
+
remote: options.remote,
|
|
377
383
|
});
|
|
378
384
|
return 0;
|
|
379
385
|
}
|
|
@@ -490,8 +496,8 @@ function printChangeHelp() {
|
|
|
490
496
|
|
|
491
497
|
Usage:
|
|
492
498
|
aiws change list
|
|
493
|
-
aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>]
|
|
494
|
-
aiws change finish [change-id] [--into <branch> | --base <branch>]
|
|
499
|
+
aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>]
|
|
500
|
+
aiws change finish [change-id] [--into <branch> | --base <branch>] [--push] [--remote <name>]
|
|
495
501
|
aiws change new <change-id> [--title <title>] [--no-design]
|
|
496
502
|
aiws change status [change-id]
|
|
497
503
|
aiws change next [change-id]
|
|
@@ -510,6 +516,8 @@ Notes:
|
|
|
510
516
|
- Execution quality gate first: aiws change validate [change-id] --strict
|
|
511
517
|
(equivalent to running $ws-plan-verify in AI tools).
|
|
512
518
|
- status/next now prioritize quality-gate guidance before coding ($ws-dev).
|
|
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.
|
|
513
521
|
- If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
|
|
514
522
|
start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
|
|
515
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
|
|
@@ -1417,7 +1857,7 @@ export async function changeNewCommand(options) {
|
|
|
1417
1857
|
/**
|
|
1418
1858
|
* aiws change start
|
|
1419
1859
|
*
|
|
1420
|
-
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
|
|
1860
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
|
|
1421
1861
|
*/
|
|
1422
1862
|
export async function changeStartCommand(options) {
|
|
1423
1863
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
@@ -1429,6 +1869,7 @@ export async function changeStartCommand(options) {
|
|
|
1429
1869
|
const startFromBranch = await currentBranch(gitRoot);
|
|
1430
1870
|
const branch = `change/${changeId}`;
|
|
1431
1871
|
const branchRef = `refs/heads/${branch}`;
|
|
1872
|
+
const allowDirty = options.allowDirty === true;
|
|
1432
1873
|
|
|
1433
1874
|
if (options.worktree === true && options.noSwitch === true) {
|
|
1434
1875
|
throw new UserError("change start: cannot combine --worktree with --no-switch");
|
|
@@ -1468,6 +1909,24 @@ export async function changeStartCommand(options) {
|
|
|
1468
1909
|
console.error("warn: recommended for submodules: aiws change start <change-id> --worktree");
|
|
1469
1910
|
}
|
|
1470
1911
|
|
|
1912
|
+
if (!allowDirty && (effectiveWorktree || !effectiveNoSwitch)) {
|
|
1913
|
+
const clean = await checkGitClean(gitRoot);
|
|
1914
|
+
if (!clean.clean) {
|
|
1915
|
+
const mode = effectiveWorktree ? "worktree" : "switch";
|
|
1916
|
+
throw new UserError(`Refusing to start change with a dirty working tree (${mode} mode).`, {
|
|
1917
|
+
details:
|
|
1918
|
+
`${clean.details}\n\n` +
|
|
1919
|
+
`Why: starting in ${mode} mode changes your checkout context; uncommitted changes may not appear where you expect.\n` +
|
|
1920
|
+
"Fix: commit first, then retry:\n" +
|
|
1921
|
+
" git add -A && git commit -m \"wip: save before change start\"\n" +
|
|
1922
|
+
"Fix: or stash:\n" +
|
|
1923
|
+
" git stash\n" +
|
|
1924
|
+
`Alt: prepare artifacts without switching: aiws change start ${changeId} --no-switch\n` +
|
|
1925
|
+
"Alt: proceed anyway (not recommended): add --allow-dirty",
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1471
1930
|
if (effectiveWorktree) {
|
|
1472
1931
|
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
1473
1932
|
if (!prereq.ok) {
|
|
@@ -1545,12 +2004,30 @@ export async function changeStartCommand(options) {
|
|
|
1545
2004
|
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
1546
2005
|
}
|
|
1547
2006
|
|
|
1548
|
-
if
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
2007
|
+
// Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
|
|
2008
|
+
// --submodules is now implicit (kept for backwards compat but has no effect).
|
|
2009
|
+
if (hasGitmodules) {
|
|
2010
|
+
const hasEntries = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: destKey }).then(
|
|
2011
|
+
(r) => r.code === 0 && String(r.stdout || "").trim().length > 0
|
|
2012
|
+
);
|
|
2013
|
+
if (hasEntries) {
|
|
2014
|
+
console.error("info: .gitmodules detected; initializing submodules in the new worktree.");
|
|
2015
|
+
const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
|
|
2016
|
+
if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
|
|
2017
|
+
|
|
2018
|
+
// Sanity check: fail fast if anything remains uninitialized or conflicted.
|
|
2019
|
+
const st = await runCommand("git", ["submodule", "status", "--recursive"], { cwd: destKey });
|
|
2020
|
+
const lines = String(st.stdout || "")
|
|
2021
|
+
.split("\n")
|
|
2022
|
+
.map((l) => l.trimEnd())
|
|
2023
|
+
.filter(Boolean);
|
|
2024
|
+
const bad = lines.filter((l) => l.startsWith("-") || l.startsWith("U"));
|
|
2025
|
+
if (bad.length > 0) {
|
|
2026
|
+
throw new UserError("Submodules not fully initialized in worktree.", {
|
|
2027
|
+
details: `${bad.slice(0, 20).join("\n")}${bad.length > 20 ? "\n..." : ""}\n\nHint: run in the worktree:\n git submodule update --init --recursive`,
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
1554
2031
|
}
|
|
1555
2032
|
|
|
1556
2033
|
if (options.enableHooks) {
|
|
@@ -1562,7 +2039,7 @@ export async function changeStartCommand(options) {
|
|
|
1562
2039
|
worktree: destKey,
|
|
1563
2040
|
branch,
|
|
1564
2041
|
from_branch: startFromBranch || "",
|
|
1565
|
-
submodules:
|
|
2042
|
+
submodules: true,
|
|
1566
2043
|
hooks: options.enableHooks === true,
|
|
1567
2044
|
});
|
|
1568
2045
|
|
|
@@ -1689,98 +2166,13 @@ export async function changeStartCommand(options) {
|
|
|
1689
2166
|
/**
|
|
1690
2167
|
* aiws change finish
|
|
1691
2168
|
*
|
|
1692
|
-
* @param {{ changeId?: string, into?: string, base?: string }} options
|
|
2169
|
+
* @param {{ changeId?: string, into?: string, base?: string, push?: boolean, remote?: string }} options
|
|
1693
2170
|
*/
|
|
1694
2171
|
export async function changeFinishCommand(options) {
|
|
1695
2172
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
1696
2173
|
await ensureTruthFiles(gitRoot);
|
|
1697
2174
|
|
|
1698
|
-
const changeId = await
|
|
1699
|
-
assertValidChangeId(changeId);
|
|
1700
|
-
|
|
1701
|
-
const changeBranch = `change/${changeId}`;
|
|
1702
|
-
const changeBranchRef = `refs/heads/${changeBranch}`;
|
|
1703
|
-
|
|
1704
|
-
const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
1705
|
-
if (!hasChangeBranch) {
|
|
1706
|
-
throw new UserError(`Missing change branch: ${changeBranch}`, {
|
|
1707
|
-
details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
|
|
1708
|
-
});
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
const clean = await checkGitClean(gitRoot);
|
|
1712
|
-
if (!clean.clean) {
|
|
1713
|
-
throw new UserError("Refusing to finish with a dirty working tree.", {
|
|
1714
|
-
details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
const cur = await currentBranch(gitRoot);
|
|
1719
|
-
const intoRaw = String(options.into || "").trim();
|
|
1720
|
-
const baseRaw = String(options.base || "").trim();
|
|
1721
|
-
if (intoRaw && baseRaw && intoRaw !== baseRaw) {
|
|
1722
|
-
throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
/** @type {string} */
|
|
1726
|
-
let into = intoRaw || baseRaw;
|
|
1727
|
-
|
|
1728
|
-
if (!into) {
|
|
1729
|
-
if (!cur) {
|
|
1730
|
-
throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
|
|
1731
|
-
details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
|
|
1732
|
-
});
|
|
1733
|
-
}
|
|
1734
|
-
if (cur !== changeBranch) {
|
|
1735
|
-
into = cur;
|
|
1736
|
-
} else {
|
|
1737
|
-
const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
|
|
1738
|
-
/** @type {any} */
|
|
1739
|
-
let meta = null;
|
|
1740
|
-
if (await pathExists(metaPath)) {
|
|
1741
|
-
try {
|
|
1742
|
-
meta = JSON.parse(await readText(metaPath));
|
|
1743
|
-
} catch {
|
|
1744
|
-
meta = null;
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
if (!meta) {
|
|
1748
|
-
const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
|
|
1749
|
-
if (show.code === 0) {
|
|
1750
|
-
try {
|
|
1751
|
-
meta = JSON.parse(String(show.stdout || ""));
|
|
1752
|
-
} catch {
|
|
1753
|
-
meta = null;
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
|
|
1758
|
-
if (!inferred) {
|
|
1759
|
-
throw new UserError("Cannot infer base branch for finish.", {
|
|
1760
|
-
details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
|
|
1761
|
-
});
|
|
1762
|
-
}
|
|
1763
|
-
into = inferred;
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
if (into === changeBranch) {
|
|
1768
|
-
throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
const worktrees = await listGitWorktrees(gitRoot);
|
|
1772
|
-
const intoRef = `refs/heads/${into}`;
|
|
1773
|
-
const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
|
|
1774
|
-
if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
|
|
1775
|
-
throw new UserError("Target branch is checked out in another worktree.", {
|
|
1776
|
-
details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
|
|
1777
|
-
});
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
1781
|
-
if (!hasIntoBranch) {
|
|
1782
|
-
throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
|
|
1783
|
-
}
|
|
2175
|
+
const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, options);
|
|
1784
2176
|
|
|
1785
2177
|
if (cur && cur !== into) {
|
|
1786
2178
|
const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
|
|
@@ -1792,8 +2184,6 @@ export async function changeFinishCommand(options) {
|
|
|
1792
2184
|
|
|
1793
2185
|
const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
|
|
1794
2186
|
if (merge.code !== 0) {
|
|
1795
|
-
const changeRef = changeBranchRef;
|
|
1796
|
-
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
1797
2187
|
const extra =
|
|
1798
2188
|
changeWt && changeWt.worktree
|
|
1799
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}`
|
|
@@ -1806,9 +2196,51 @@ export async function changeFinishCommand(options) {
|
|
|
1806
2196
|
console.log(`ok: finished change: ${changeId}`);
|
|
1807
2197
|
console.log(`into: ${into}`);
|
|
1808
2198
|
console.log(`from: ${changeBranch}`);
|
|
1809
|
-
|
|
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) {
|
|
1810
2242
|
console.log("next:");
|
|
1811
|
-
console.log(" -
|
|
2243
|
+
console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if another worktree changed them later");
|
|
1812
2244
|
}
|
|
1813
2245
|
}
|
|
1814
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/update.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { loadTemplate } from "../spec.js";
|
|
3
4
|
import { loadAiwsPackage } from "../aiws-package.js";
|
|
@@ -59,13 +60,32 @@ export async function updateCommand(options) {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
const removeFiles = (update.remove || []).map(normalizeRel);
|
|
64
|
+
|
|
62
65
|
const backup = new BackupSession({ workspaceRoot, operation: "update" });
|
|
63
66
|
// Backup every file we might touch (including missing, for rollback deletions).
|
|
64
67
|
await backup.recordFile(".aiws/manifest.json", { recordMissing: true });
|
|
65
68
|
for (const f of replaceFiles) await backup.recordFile(f, { recordMissing: true });
|
|
66
69
|
for (const f of Object.keys(managedBlocks)) await backup.recordFile(f, { recordMissing: true });
|
|
70
|
+
for (const f of removeFiles) await backup.recordFile(f);
|
|
67
71
|
await backup.finalize({ extra: { template_id: tpl.templateId } });
|
|
68
72
|
|
|
73
|
+
// Remove deprecated files (backed up above; restorable via `aiws rollback`).
|
|
74
|
+
let removedCount = 0;
|
|
75
|
+
for (const rel of removeFiles) {
|
|
76
|
+
if (!rel) continue;
|
|
77
|
+
const abs = joinRel(workspaceRoot, rel);
|
|
78
|
+
if (await pathExists(abs)) {
|
|
79
|
+
await fs.rm(abs, { force: true });
|
|
80
|
+
removedCount++;
|
|
81
|
+
// Remove empty parent directory (safe: rmdir fails on non-empty).
|
|
82
|
+
try { await fs.rmdir(path.dirname(abs)); } catch { /* non-empty or gone */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (removedCount > 0) {
|
|
86
|
+
console.log(` removed ${removedCount} deprecated file(s)`);
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
// Replace files (except manifest, generated later).
|
|
70
90
|
for (const rel of replaceFiles) {
|
|
71
91
|
if (!rel || rel === ".aiws/manifest.json") continue;
|
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;
|