@aipper/aiws 0.0.23 → 0.0.25
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 +30 -2
- package/package.json +2 -2
- package/src/cli.js +45 -2
- package/src/codex-skills.js +4 -4
- package/src/commands/change-advice.js +188 -0
- package/src/commands/change-evidence-command.js +242 -0
- package/src/commands/change-evidence-entry.js +40 -0
- package/src/commands/change-evidence.js +338 -0
- package/src/commands/change-finish.js +484 -0
- package/src/commands/change-git-status.js +53 -0
- package/src/commands/change-lifecycle-entry.js +60 -0
- package/src/commands/change-lifecycle.js +219 -0
- package/src/commands/change-metrics-command.js +110 -0
- package/src/commands/change-next-command.js +127 -0
- package/src/commands/change-review-gates.js +315 -0
- package/src/commands/change-scope-gate.js +34 -0
- package/src/commands/change-start.js +138 -0
- package/src/commands/change-state-command.js +40 -0
- package/src/commands/change-status-command.js +126 -0
- package/src/commands/change-status-context.js +202 -0
- package/src/commands/change-validate-entry.js +45 -0
- package/src/commands/change-validate.js +127 -0
- package/src/commands/change.js +436 -1291
- package/src/commands/dashboard.js +38 -12
- package/src/commands/hooks-install.js +1 -2
- package/src/commands/hooks-status.js +9 -1
- package/src/commands/opencode-status.js +61 -0
- package/src/commands/validate.js +1 -1
- package/src/dashboard/app.js +205 -2
- package/src/dashboard/index.html +5 -2
- package/src/dashboard/style.css +86 -0
- package/src/governance.js +157 -0
- package/src/json-schema-lite.js +164 -0
- package/src/opencode-env.js +76 -0
- package/src/spec.js +131 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} gitRoot
|
|
5
|
+
* @param {string} branch
|
|
6
|
+
* @param {string | undefined} preferredRemote
|
|
7
|
+
* @param {{ runCommand: typeof import("../exec.js").runCommand }} deps
|
|
8
|
+
*/
|
|
9
|
+
export async function resolvePushTarget(gitRoot, branch, preferredRemote, deps) {
|
|
10
|
+
const remoteRaw = String(preferredRemote || "").trim();
|
|
11
|
+
const branchRemoteRes = await deps.runCommand("git", ["config", "--get", `branch.${branch}.remote`], { cwd: gitRoot });
|
|
12
|
+
const trackedRemote = String(branchRemoteRes.stdout || "").trim();
|
|
13
|
+
|
|
14
|
+
const branchMergeRes = await deps.runCommand("git", ["config", "--get", `branch.${branch}.merge`], { cwd: gitRoot });
|
|
15
|
+
const trackedMergeRef = String(branchMergeRes.stdout || "").trim();
|
|
16
|
+
const trackedBranch = trackedMergeRef.startsWith("refs/heads/") ? trackedMergeRef.slice("refs/heads/".length) : "";
|
|
17
|
+
|
|
18
|
+
const remote = remoteRaw || trackedRemote || "origin";
|
|
19
|
+
const remoteBranch = trackedBranch || branch;
|
|
20
|
+
const refspec = remoteBranch === branch ? branch : `${branch}:${remoteBranch}`;
|
|
21
|
+
|
|
22
|
+
return { remote, remoteBranch, refspec, trackedRemote, trackedMergeRef };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} gitRoot
|
|
27
|
+
* @param {string} worktreePath
|
|
28
|
+
* @param {{
|
|
29
|
+
* checkGitClean: (gitRoot: string) => Promise<{ clean: true } | { clean: false, details: string }>,
|
|
30
|
+
* listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, locked: boolean, lockReason: string }>>,
|
|
31
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
32
|
+
* runCommand: typeof import("../exec.js").runCommand,
|
|
33
|
+
* UserError: typeof import("../errors.js").UserError
|
|
34
|
+
* }} deps
|
|
35
|
+
*/
|
|
36
|
+
export async function cleanupWorktreePath(gitRoot, worktreePath, deps) {
|
|
37
|
+
const abs = path.resolve(worktreePath);
|
|
38
|
+
if (abs === path.resolve(gitRoot)) {
|
|
39
|
+
return { status: "skipped", reason: "current_worktree", worktree: abs };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const worktrees = await deps.listGitWorktrees(gitRoot);
|
|
43
|
+
const worktreeEntry = worktrees.find((worktree) => path.resolve(worktree.worktree) === abs);
|
|
44
|
+
if (worktreeEntry?.locked) {
|
|
45
|
+
return { status: "skipped", reason: "locked", worktree: abs, details: worktreeEntry.lockReason };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!(await deps.pathExists(abs))) {
|
|
49
|
+
const prune = await deps.runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
|
|
50
|
+
if (prune.code !== 0) {
|
|
51
|
+
throw new deps.UserError("Failed to prune stale git worktree metadata.", { details: prune.stderr || prune.stdout });
|
|
52
|
+
}
|
|
53
|
+
return { status: "pruned-missing", worktree: abs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clean = await deps.checkGitClean(abs);
|
|
57
|
+
if (!clean.clean) {
|
|
58
|
+
return { status: "skipped", reason: "dirty", worktree: abs, details: clean.details };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let rm = await deps.runCommand("git", ["worktree", "remove", abs], { cwd: gitRoot });
|
|
62
|
+
if (rm.code !== 0) {
|
|
63
|
+
const out = `${rm.stderr || ""}\n${rm.stdout || ""}`;
|
|
64
|
+
if (out.includes("working trees containing submodules cannot be moved or removed")) {
|
|
65
|
+
rm = await deps.runCommand("git", ["worktree", "remove", "--force", abs], { cwd: gitRoot });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (rm.code !== 0) {
|
|
69
|
+
throw new deps.UserError("Failed to remove change worktree.", { details: rm.stderr || rm.stdout });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const prune = await deps.runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
|
|
73
|
+
if (prune.code !== 0) {
|
|
74
|
+
throw new deps.UserError("Failed to prune git worktree metadata after remove.", { details: prune.stderr || prune.stdout });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { status: "removed", worktree: abs };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} gitRoot
|
|
82
|
+
* @param {{ pathExists: (path: string) => Promise<boolean>, runCommand: typeof import("../exec.js").runCommand }} deps
|
|
83
|
+
* @returns {Promise<Array<{ name: string, path: string }>>}
|
|
84
|
+
*/
|
|
85
|
+
export async function listSubmodulesFromGitmodules(gitRoot, deps) {
|
|
86
|
+
const gitmodules = path.join(gitRoot, ".gitmodules");
|
|
87
|
+
if (!(await deps.pathExists(gitmodules))) return [];
|
|
88
|
+
|
|
89
|
+
const list = await deps.runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: gitRoot });
|
|
90
|
+
if (list.code !== 0) return [];
|
|
91
|
+
|
|
92
|
+
/** @type {Array<{ name: string, path: string }>} */
|
|
93
|
+
const subs = [];
|
|
94
|
+
for (const raw of String(list.stdout || "").split("\n")) {
|
|
95
|
+
const line = raw.trim();
|
|
96
|
+
if (!line) continue;
|
|
97
|
+
const idx = line.indexOf(" ");
|
|
98
|
+
if (idx <= 0) continue;
|
|
99
|
+
const key = line.slice(0, idx).trim();
|
|
100
|
+
const subPath = line.slice(idx + 1).trim();
|
|
101
|
+
const match = key.match(/^submodule\.(.+)\.path$/);
|
|
102
|
+
if (!match) continue;
|
|
103
|
+
const name = String(match[1] || "").trim();
|
|
104
|
+
if (!name || !subPath) continue;
|
|
105
|
+
subs.push({ name, path: subPath });
|
|
106
|
+
}
|
|
107
|
+
return subs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} targetsPath
|
|
112
|
+
* @param {{ pathExists: (path: string) => Promise<boolean>, readText: (path: string) => Promise<string> }} deps
|
|
113
|
+
*/
|
|
114
|
+
export async function parseSubmoduleTargetsFile(targetsPath, deps) {
|
|
115
|
+
/** @type {Map<string, { branch: string, remote: string }>} */
|
|
116
|
+
const parsed = new Map();
|
|
117
|
+
/** @type {string[]} */
|
|
118
|
+
const errors = [];
|
|
119
|
+
|
|
120
|
+
if (!(await deps.pathExists(targetsPath))) {
|
|
121
|
+
return { parsed, errors };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const text = await deps.readText(targetsPath);
|
|
125
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
126
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
127
|
+
const raw = lines[i] || "";
|
|
128
|
+
const line = raw.trim();
|
|
129
|
+
if (!line || line.startsWith("#")) continue;
|
|
130
|
+
const parts = line.split(/\s+/).filter(Boolean);
|
|
131
|
+
if (parts.length < 2) {
|
|
132
|
+
errors.push(`${path.basename(targetsPath)}:${i + 1} invalid line (expected: <path> <target_branch> [remote])`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const subPath = String(parts[0] || "").trim();
|
|
136
|
+
const targetBranch = String(parts[1] || "").trim();
|
|
137
|
+
const remote = String(parts[2] || "origin").trim() || "origin";
|
|
138
|
+
if (!subPath) {
|
|
139
|
+
errors.push(`${path.basename(targetsPath)}:${i + 1} missing submodule path`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (parsed.has(subPath)) {
|
|
143
|
+
errors.push(`${path.basename(targetsPath)}:${i + 1} duplicate submodule path: ${subPath}`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!targetBranch || ["<fill-me>", "<fill_me>", "tbd", "TBD"].includes(targetBranch)) {
|
|
147
|
+
errors.push(`${path.basename(targetsPath)}:${i + 1} missing/placeholder target_branch for ${subPath}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
parsed.set(subPath, { branch: targetBranch, remote });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { parsed, errors };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} repoPath
|
|
158
|
+
* @param {string} rev
|
|
159
|
+
* @param {{ pathExists: (path: string) => Promise<boolean>, runCommand: typeof import("../exec.js").runCommand }} deps
|
|
160
|
+
*/
|
|
161
|
+
async function gitHasRevision(repoPath, rev, deps) {
|
|
162
|
+
if (!(await deps.pathExists(repoPath))) return false;
|
|
163
|
+
const res = await deps.runCommand("git", ["-C", repoPath, "cat-file", "-e", `${rev}^{commit}`], { cwd: repoPath });
|
|
164
|
+
return res.code === 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} repoPath
|
|
169
|
+
* @param {{ pathExists: (path: string) => Promise<boolean>, runCommand: typeof import("../exec.js").runCommand }} deps
|
|
170
|
+
* @returns {Promise<boolean>}
|
|
171
|
+
*/
|
|
172
|
+
export async function isGitRepository(repoPath, deps) {
|
|
173
|
+
if (!(await deps.pathExists(repoPath))) return false;
|
|
174
|
+
const res = await deps.runCommand("git", ["-C", repoPath, "rev-parse", "--git-dir"], { cwd: repoPath });
|
|
175
|
+
return res.code === 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} targetRepoPath
|
|
180
|
+
* @param {string} sha
|
|
181
|
+
* @param {string | undefined} fallbackRepoPath
|
|
182
|
+
* @param {{
|
|
183
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
184
|
+
* runCommand: typeof import("../exec.js").runCommand,
|
|
185
|
+
* UserError: typeof import("../errors.js").UserError
|
|
186
|
+
* }} deps
|
|
187
|
+
*/
|
|
188
|
+
async function ensureRevisionAvailable(targetRepoPath, sha, fallbackRepoPath, deps) {
|
|
189
|
+
if (await gitHasRevision(targetRepoPath, sha, deps)) return;
|
|
190
|
+
const fallback = String(fallbackRepoPath || "").trim();
|
|
191
|
+
if (!fallback) {
|
|
192
|
+
throw new deps.UserError("Required submodule commit is not available locally.", {
|
|
193
|
+
details: `repo: ${targetRepoPath}\ncommit: ${sha}`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (!(await gitHasRevision(fallback, sha, deps))) {
|
|
197
|
+
throw new deps.UserError("Required submodule commit is missing in both target and change worktrees.", {
|
|
198
|
+
details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const fetch = await deps.runCommand("git", ["-C", targetRepoPath, "fetch", fallback, sha], { cwd: targetRepoPath });
|
|
202
|
+
if (fetch.code !== 0 || !(await gitHasRevision(targetRepoPath, sha, deps))) {
|
|
203
|
+
throw new deps.UserError("Failed to import submodule commit from change worktree.", {
|
|
204
|
+
details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}\n\n${fetch.stderr || fetch.stdout}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @param {string} repoPath
|
|
211
|
+
* @param {string} remote
|
|
212
|
+
* @param {string} branch
|
|
213
|
+
* @param {string} sha
|
|
214
|
+
* @param {{ runCommand: typeof import("../exec.js").runCommand, UserError: typeof import("../errors.js").UserError }} deps
|
|
215
|
+
*/
|
|
216
|
+
async function pushSubmoduleBranch(repoPath, remote, branch, sha, deps) {
|
|
217
|
+
const fetch = await deps.runCommand("git", ["-C", repoPath, "fetch", remote, "--prune"], { cwd: repoPath });
|
|
218
|
+
if (fetch.code !== 0) {
|
|
219
|
+
throw new deps.UserError("Failed to fetch submodule remote before push.", {
|
|
220
|
+
details: `repo: ${repoPath}\nremote: ${remote}\n\n${fetch.stderr || fetch.stdout}`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const remoteRef = `refs/remotes/${remote}/${branch}`;
|
|
225
|
+
const hasRemoteBranch = await deps
|
|
226
|
+
.runCommand("git", ["-C", repoPath, "show-ref", "--verify", "--quiet", remoteRef], { cwd: repoPath })
|
|
227
|
+
.then((result) => result.code === 0);
|
|
228
|
+
if (!hasRemoteBranch) {
|
|
229
|
+
throw new deps.UserError("Submodule target branch does not exist on remote.", {
|
|
230
|
+
details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const remoteAncestor = await deps
|
|
235
|
+
.runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", remoteRef, sha], { cwd: repoPath })
|
|
236
|
+
.then((result) => result.code === 0);
|
|
237
|
+
if (remoteAncestor) {
|
|
238
|
+
const push = await deps.runCommand("git", ["-C", repoPath, "push", remote, `${sha}:refs/heads/${branch}`], { cwd: repoPath });
|
|
239
|
+
if (push.code !== 0) {
|
|
240
|
+
throw new deps.UserError("Failed to fast-forward push submodule target branch.", {
|
|
241
|
+
details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\n${push.stderr || push.stdout}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return { status: "pushed", remoteRef };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const shaContained = await deps
|
|
248
|
+
.runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", sha, remoteRef], { cwd: repoPath })
|
|
249
|
+
.then((result) => result.code === 0);
|
|
250
|
+
if (shaContained) {
|
|
251
|
+
return { status: "already-contained", remoteRef };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
throw new deps.UserError("Submodule target branch diverged from gitlink commit.", {
|
|
255
|
+
details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\nHint: reconcile the submodule branch manually, then retry.`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {string} gitRoot
|
|
261
|
+
* @param {string} changeId
|
|
262
|
+
* @param {string} baseBranch
|
|
263
|
+
* @param {string | undefined} changeWorktreePath
|
|
264
|
+
* @param {{
|
|
265
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
266
|
+
* readText: (path: string) => Promise<string>,
|
|
267
|
+
* runCommand: typeof import("../exec.js").runCommand,
|
|
268
|
+
* UserError: typeof import("../errors.js").UserError
|
|
269
|
+
* }} deps
|
|
270
|
+
*/
|
|
271
|
+
export async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, changeWorktreePath, deps) {
|
|
272
|
+
const subs = await listSubmodulesFromGitmodules(gitRoot, deps);
|
|
273
|
+
if (subs.length === 0) return { processed: 0 };
|
|
274
|
+
|
|
275
|
+
const targetsPath = path.join(gitRoot, "changes", changeId, "submodules.targets");
|
|
276
|
+
if (!(await deps.pathExists(targetsPath))) {
|
|
277
|
+
throw new deps.UserError("Missing submodules.targets for submodule-aware finish.", {
|
|
278
|
+
details: `required: ${path.relative(gitRoot, targetsPath)}\n\nHint: add one entry per submodule path before running \`aiws change finish --push\`.`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const parsedTargets = await parseSubmoduleTargetsFile(targetsPath, deps);
|
|
283
|
+
if (parsedTargets.errors.length > 0) {
|
|
284
|
+
throw new deps.UserError("Invalid submodules.targets.", { details: parsedTargets.errors.join("\n") });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const missingPaths = subs.filter((submodule) => !parsedTargets.parsed.has(submodule.path)).map((submodule) => submodule.path);
|
|
288
|
+
if (missingPaths.length > 0) {
|
|
289
|
+
throw new deps.UserError("submodules.targets missing declared submodule paths.", {
|
|
290
|
+
details: `file: ${path.relative(gitRoot, targetsPath)}\nmissing: ${missingPaths.join(", ")}`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @type {Array<{ name: string, path: string, sha: string, branch: string, remote: string, pinBranch: string }>} */
|
|
295
|
+
const specs = [];
|
|
296
|
+
for (const sub of subs) {
|
|
297
|
+
const target = parsedTargets.parsed.get(sub.path);
|
|
298
|
+
if (!target) continue;
|
|
299
|
+
const targetBranch = target.branch === "." ? baseBranch : target.branch;
|
|
300
|
+
const gitlink = await deps.runCommand("git", ["rev-parse", `HEAD:${sub.path}`], { cwd: gitRoot });
|
|
301
|
+
if (gitlink.code !== 0) {
|
|
302
|
+
throw new deps.UserError("Failed to resolve submodule gitlink commit from merged superproject.", {
|
|
303
|
+
details: `path: ${sub.path}\n\n${gitlink.stderr || gitlink.stdout}`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const sha = String(gitlink.stdout || "").trim();
|
|
307
|
+
specs.push({
|
|
308
|
+
name: sub.name,
|
|
309
|
+
path: sub.path,
|
|
310
|
+
sha,
|
|
311
|
+
branch: targetBranch,
|
|
312
|
+
remote: target.remote,
|
|
313
|
+
pinBranch: `aiws/pin/${targetBranch}`,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const spec of specs) {
|
|
318
|
+
const targetRepoPath = path.join(gitRoot, spec.path);
|
|
319
|
+
const changeRepoPath = changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "";
|
|
320
|
+
const targetRepoReady = await isGitRepository(targetRepoPath, deps);
|
|
321
|
+
const changeRepoReady = changeRepoPath ? await isGitRepository(changeRepoPath, deps) : false;
|
|
322
|
+
if (!targetRepoReady && !changeRepoReady) {
|
|
323
|
+
throw new deps.UserError("Submodule repository is not initialized in either target or change worktree.", {
|
|
324
|
+
details: `path: ${spec.path}\nHint: initialize submodules before finish, or keep using worktree mode for changes involving submodules.`,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const sourceRepoPath =
|
|
329
|
+
(targetRepoReady && (await gitHasRevision(targetRepoPath, spec.sha, deps))) ? targetRepoPath : changeRepoReady ? changeRepoPath : targetRepoPath;
|
|
330
|
+
if (!(await gitHasRevision(sourceRepoPath, spec.sha, deps))) {
|
|
331
|
+
throw new deps.UserError("Merged submodule gitlink commit is not available locally.", {
|
|
332
|
+
details: `path: ${spec.path}\ncommit: ${spec.sha}\nsource_repo: ${sourceRepoPath}`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const pushResult = await pushSubmoduleBranch(sourceRepoPath, spec.remote, spec.branch, spec.sha, deps);
|
|
337
|
+
console.log(
|
|
338
|
+
`submodule push: ${spec.path} (${spec.remote} ${spec.branch}${pushResult.status === "pushed" ? ` <= ${spec.sha}` : ", already contained"})`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const sync = await deps.runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: gitRoot });
|
|
343
|
+
if (sync.code !== 0) {
|
|
344
|
+
throw new deps.UserError("Submodule push succeeded, but failed to sync target worktree submodules.", {
|
|
345
|
+
details: sync.stderr || sync.stdout,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const spec of specs) {
|
|
350
|
+
const targetRepoPath = path.join(gitRoot, spec.path);
|
|
351
|
+
if (!(await isGitRepository(targetRepoPath, deps))) {
|
|
352
|
+
throw new deps.UserError("Submodule repo missing after sync.", { details: `path: ${spec.path}` });
|
|
353
|
+
}
|
|
354
|
+
await ensureRevisionAvailable(targetRepoPath, spec.sha, changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "", deps);
|
|
355
|
+
const fetch = await deps.runCommand("git", ["-C", targetRepoPath, "fetch", spec.remote, "--prune"], { cwd: targetRepoPath });
|
|
356
|
+
if (fetch.code !== 0) {
|
|
357
|
+
throw new deps.UserError("Failed to refresh target worktree submodule remote.", {
|
|
358
|
+
details: `path: ${spec.path}\nremote: ${spec.remote}\n\n${fetch.stderr || fetch.stdout}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
const checkout = await deps.runCommand("git", ["-C", targetRepoPath, "checkout", "-B", spec.pinBranch, spec.sha], { cwd: targetRepoPath });
|
|
362
|
+
if (checkout.code !== 0) {
|
|
363
|
+
throw new deps.UserError("Failed to attach target worktree submodule to pin branch.", {
|
|
364
|
+
details: `path: ${spec.path}\npin_branch: ${spec.pinBranch}\ncommit: ${spec.sha}\n\n${checkout.stderr || checkout.stdout}`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
await deps.runCommand("git", ["-C", targetRepoPath, "branch", "--set-upstream-to", `${spec.remote}/${spec.branch}`, spec.pinBranch], {
|
|
368
|
+
cwd: targetRepoPath,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { processed: specs.length };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* @param {string} gitRoot
|
|
377
|
+
* @param {{ changeId?: string, into?: string, base?: string }} options
|
|
378
|
+
* @param {{
|
|
379
|
+
* assertValidChangeId: (changeId: string) => void,
|
|
380
|
+
* checkGitClean: (gitRoot: string) => Promise<{ clean: true } | { clean: false, details: string }>,
|
|
381
|
+
* currentBranch: (gitRoot: string) => Promise<string>,
|
|
382
|
+
* listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, branch: string }>>,
|
|
383
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
384
|
+
* readText: (path: string) => Promise<string>,
|
|
385
|
+
* resolveChangeId: (gitRoot: string, changeId: string | undefined, options: { command: string }) => Promise<string>,
|
|
386
|
+
* runCommand: typeof import("../exec.js").runCommand,
|
|
387
|
+
* UserError: typeof import("../errors.js").UserError
|
|
388
|
+
* }} deps
|
|
389
|
+
*/
|
|
390
|
+
export async function resolveFinishContext(gitRoot, options, deps) {
|
|
391
|
+
const changeId = await deps.resolveChangeId(gitRoot, options.changeId, { command: "finish" });
|
|
392
|
+
deps.assertValidChangeId(changeId);
|
|
393
|
+
|
|
394
|
+
const changeBranch = `change/${changeId}`;
|
|
395
|
+
const changeBranchRef = `refs/heads/${changeBranch}`;
|
|
396
|
+
|
|
397
|
+
const hasChangeBranch = await deps
|
|
398
|
+
.runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot })
|
|
399
|
+
.then((result) => result.code === 0);
|
|
400
|
+
if (!hasChangeBranch) {
|
|
401
|
+
throw new deps.UserError(`Missing change branch: ${changeBranch}`, {
|
|
402
|
+
details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const clean = await deps.checkGitClean(gitRoot);
|
|
407
|
+
if (!clean.clean) {
|
|
408
|
+
throw new deps.UserError("Refusing to finish with a dirty working tree.", {
|
|
409
|
+
details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const cur = await deps.currentBranch(gitRoot);
|
|
414
|
+
const intoRaw = String(options.into || "").trim();
|
|
415
|
+
const baseRaw = String(options.base || "").trim();
|
|
416
|
+
if (intoRaw && baseRaw && intoRaw !== baseRaw) {
|
|
417
|
+
throw new deps.UserError("change finish: cannot combine --into with --base (values differ).", {
|
|
418
|
+
details: `--into=${intoRaw}\n--base=${baseRaw}`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** @type {string} */
|
|
423
|
+
let into = intoRaw || baseRaw;
|
|
424
|
+
|
|
425
|
+
if (!into) {
|
|
426
|
+
if (!cur) {
|
|
427
|
+
throw new deps.UserError("Detached HEAD: cannot infer target branch for finish.", {
|
|
428
|
+
details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (cur !== changeBranch) {
|
|
432
|
+
into = cur;
|
|
433
|
+
} else {
|
|
434
|
+
const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
|
|
435
|
+
/** @type {any} */
|
|
436
|
+
let meta = null;
|
|
437
|
+
if (await deps.pathExists(metaPath)) {
|
|
438
|
+
try {
|
|
439
|
+
meta = JSON.parse(await deps.readText(metaPath));
|
|
440
|
+
} catch {
|
|
441
|
+
meta = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!meta) {
|
|
445
|
+
const show = await deps.runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
|
|
446
|
+
if (show.code === 0) {
|
|
447
|
+
try {
|
|
448
|
+
meta = JSON.parse(String(show.stdout || ""));
|
|
449
|
+
} catch {
|
|
450
|
+
meta = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
|
|
455
|
+
if (!inferred) {
|
|
456
|
+
throw new deps.UserError("Cannot infer base branch for finish.", {
|
|
457
|
+
details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
into = inferred;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (into === changeBranch) {
|
|
465
|
+
throw new deps.UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const worktrees = await deps.listGitWorktrees(gitRoot);
|
|
469
|
+
const intoRef = `refs/heads/${into}`;
|
|
470
|
+
const intoWt = worktrees.find((worktree) => String(worktree.branch || "") === intoRef);
|
|
471
|
+
if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
|
|
472
|
+
throw new deps.UserError("Target branch is checked out in another worktree.", {
|
|
473
|
+
details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const hasIntoBranch = await deps.runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((result) => result.code === 0);
|
|
478
|
+
if (!hasIntoBranch) {
|
|
479
|
+
throw new deps.UserError("Target branch does not exist.", { details: `branch: ${into}` });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const changeWt = worktrees.find((worktree) => String(worktree.branch || "") === changeBranchRef);
|
|
483
|
+
return { changeId, changeBranch, changeBranchRef, into, cur, worktrees, changeWt };
|
|
484
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { runCommand } from "../exec.js";
|
|
2
|
+
import { UserError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
const CONFLICT_STATUS_RE = /^(DD|AU|UD|UA|DU|AA|UU)$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} output
|
|
8
|
+
*/
|
|
9
|
+
export function parseGitStatusPorcelain(output) {
|
|
10
|
+
const lines = String(output || "")
|
|
11
|
+
.split(/\r?\n/)
|
|
12
|
+
.map((line) => line.trimEnd())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
|
|
15
|
+
let staged = 0;
|
|
16
|
+
let unstaged = 0;
|
|
17
|
+
let untracked = 0;
|
|
18
|
+
let conflicted = 0;
|
|
19
|
+
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
const xy = line.slice(0, 2);
|
|
22
|
+
if (xy === "??") {
|
|
23
|
+
untracked += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (CONFLICT_STATUS_RE.test(xy)) {
|
|
27
|
+
conflicted += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (xy[0] && xy[0] !== " " && xy[0] !== "?") staged += 1;
|
|
31
|
+
if (xy[1] && xy[1] !== " ") unstaged += 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
clean: lines.length === 0,
|
|
36
|
+
total: lines.length,
|
|
37
|
+
staged,
|
|
38
|
+
unstaged,
|
|
39
|
+
untracked,
|
|
40
|
+
conflicted,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} gitRoot
|
|
46
|
+
*/
|
|
47
|
+
export async function collectGitStatus(gitRoot) {
|
|
48
|
+
const res = await runCommand("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: gitRoot });
|
|
49
|
+
if (res.code !== 0) {
|
|
50
|
+
throw new UserError("Failed to inspect git status for change governance.", { details: res.stderr || res.stdout });
|
|
51
|
+
}
|
|
52
|
+
return parseGitStatusPorcelain(res.stdout || "");
|
|
53
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{
|
|
5
|
+
* gitRoot: string,
|
|
6
|
+
* changeId: string
|
|
7
|
+
* }} input
|
|
8
|
+
* @param {{
|
|
9
|
+
* changeDirAbs: (gitRoot: string, changeId: string) => string,
|
|
10
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
11
|
+
* runChangeSyncWorkflow: (input: any, deps: any) => Promise<{ output: string }>,
|
|
12
|
+
* workflowDeps: Record<string, any>,
|
|
13
|
+
* UserError: typeof import("../errors.js").UserError
|
|
14
|
+
* }} deps
|
|
15
|
+
*/
|
|
16
|
+
export async function runChangeSyncCommand(input, deps) {
|
|
17
|
+
const changeDir = deps.changeDirAbs(input.gitRoot, input.changeId);
|
|
18
|
+
if (!(await deps.pathExists(changeDir))) {
|
|
19
|
+
throw new deps.UserError(`Missing change dir: ${path.relative(input.gitRoot, changeDir)}`);
|
|
20
|
+
}
|
|
21
|
+
return deps.runChangeSyncWorkflow(
|
|
22
|
+
{
|
|
23
|
+
gitRoot: input.gitRoot,
|
|
24
|
+
changeId: input.changeId,
|
|
25
|
+
changeDir,
|
|
26
|
+
},
|
|
27
|
+
deps.workflowDeps,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{
|
|
33
|
+
* gitRoot: string,
|
|
34
|
+
* changeId: string,
|
|
35
|
+
* options: { datePrefix?: string, force: boolean }
|
|
36
|
+
* }} input
|
|
37
|
+
* @param {{
|
|
38
|
+
* changeDirAbs: (gitRoot: string, changeId: string) => string,
|
|
39
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
40
|
+
* runChangeArchiveWorkflow: (input: any, deps: any) => Promise<{ output: string }>,
|
|
41
|
+
* workflowDeps: Record<string, any>,
|
|
42
|
+
* UserError: typeof import("../errors.js").UserError
|
|
43
|
+
* }} deps
|
|
44
|
+
*/
|
|
45
|
+
export async function runChangeArchiveCommand(input, deps) {
|
|
46
|
+
const changeDir = deps.changeDirAbs(input.gitRoot, input.changeId);
|
|
47
|
+
if (!(await deps.pathExists(changeDir))) {
|
|
48
|
+
throw new deps.UserError(`Missing change dir: ${path.relative(input.gitRoot, changeDir)}`);
|
|
49
|
+
}
|
|
50
|
+
return deps.runChangeArchiveWorkflow(
|
|
51
|
+
{
|
|
52
|
+
gitRoot: input.gitRoot,
|
|
53
|
+
changeId: input.changeId,
|
|
54
|
+
changeDir,
|
|
55
|
+
datePrefix: input.options.datePrefix,
|
|
56
|
+
force: input.options.force,
|
|
57
|
+
},
|
|
58
|
+
deps.workflowDeps,
|
|
59
|
+
);
|
|
60
|
+
}
|