@aipper/aiws 0.0.27 → 0.0.29
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 +18 -2
- package/package.json +2 -2
- package/src/cli.js +125 -12
- package/src/commands/change-advice.js +8 -27
- package/src/commands/change-finish.js +16 -87
- package/src/commands/change-next-command.js +5 -1
- package/src/commands/change-review-gates.js +16 -0
- package/src/commands/change-start.js +0 -35
- package/src/commands/change-status-command.js +4 -13
- package/src/commands/change-status-context.js +6 -35
- package/src/commands/change.js +194 -452
- package/src/commands/init.js +8 -0
- package/src/commands/opencode-auto.js +167 -0
- package/src/commands/opencode-status.js +44 -2
- package/src/commands/opencode-supervise.js +200 -0
- package/src/commands/update.js +17 -5
- package/src/commands/validate.js +7 -1
- package/src/commands/ws-commit.js +44 -0
- package/src/commands/ws-deliver.js +67 -0
- package/src/commands/ws-finish.js +83 -0
- package/src/commands/ws-handoff.js +40 -0
- package/src/commands/ws-migrate.js +41 -0
- package/src/commands/ws-plan-verify.js +44 -0
- package/src/commands/ws-pull.js +30 -0
- package/src/commands/ws-push.js +71 -0
- package/src/commands/ws-submodule-setup.js +65 -0
- package/src/commands/ws-verify-before-complete.js +39 -0
- package/src/manifest.js +25 -1
- package/src/opencode-env.js +132 -0
package/src/commands/change.js
CHANGED
|
@@ -26,23 +26,12 @@ import { runChangeValidateCommand } from "./change-validate-entry.js";
|
|
|
26
26
|
import {
|
|
27
27
|
computeChangeNextAdvice as computeChangeNextAdviceModel,
|
|
28
28
|
renderChangeStateMarkdown,
|
|
29
|
-
resolveChangeWorktreeHint,
|
|
30
29
|
} from "./change-advice.js";
|
|
31
30
|
import {
|
|
32
|
-
cleanupWorktreePath,
|
|
33
31
|
isGitRepository,
|
|
34
32
|
listSubmodulesFromGitmodules,
|
|
35
|
-
pushSubmodulesForFinish,
|
|
36
33
|
resolveFinishContext,
|
|
37
|
-
resolvePushTarget,
|
|
38
34
|
} from "./change-finish.js";
|
|
39
|
-
import {
|
|
40
|
-
checkWorktreePrereqs,
|
|
41
|
-
maybeRecordBaseBranch,
|
|
42
|
-
prepareNoSwitchBranch,
|
|
43
|
-
resolveDefaultWorktreeDir,
|
|
44
|
-
switchOrCreateStartBranch,
|
|
45
|
-
} from "./change-start.js";
|
|
46
35
|
import {
|
|
47
36
|
collectFinishGateErrors,
|
|
48
37
|
collectReviewGates,
|
|
@@ -50,9 +39,6 @@ import {
|
|
|
50
39
|
} from "./change-review-gates.js";
|
|
51
40
|
import {
|
|
52
41
|
computeScopeGate,
|
|
53
|
-
scopeGateFailureAction,
|
|
54
|
-
scopeGatePassedRecommendation,
|
|
55
|
-
scopeGatePendingRecommendation,
|
|
56
42
|
} from "./change-scope-gate.js";
|
|
57
43
|
import {
|
|
58
44
|
readChangeMetrics,
|
|
@@ -62,10 +48,176 @@ import {
|
|
|
62
48
|
} from "./change-status-context.js";
|
|
63
49
|
import { renderChangeStatusOutput } from "./change-status-command.js";
|
|
64
50
|
import { validateChangeArtifacts as validateChangeArtifactsModel } from "./change-validate.js";
|
|
65
|
-
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} text
|
|
54
|
+
*/
|
|
55
|
+
function countWsTodo(text) {
|
|
56
|
+
return (text.match(/WS:TODO/g) || []).length;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} text
|
|
61
|
+
*/
|
|
62
|
+
function countPlaceholders(text) {
|
|
63
|
+
return (text.match(/{{CHANGE_ID}}|{{TITLE}}|{{CREATED_AT}}/g) || []).length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} changeDir
|
|
68
|
+
* @param {string} rel
|
|
69
|
+
*/
|
|
70
|
+
async function fileState(changeDir, rel) {
|
|
71
|
+
const abs = path.join(changeDir, rel);
|
|
72
|
+
if (!(await pathExists(abs))) return { state: "missing", wsTodo: 0, placeholders: 0, abs, text: "" };
|
|
73
|
+
const st = await fs.stat(abs);
|
|
74
|
+
if (st.size === 0) return { state: "empty", wsTodo: 0, placeholders: 0, abs, text: "" };
|
|
75
|
+
const text = await readText(abs);
|
|
76
|
+
return { state: "ok", wsTodo: countWsTodo(text), placeholders: countPlaceholders(text), abs, text };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Append a change lifecycle event into changes/<id>/metrics.json.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} gitRoot
|
|
83
|
+
* @param {string} changeId
|
|
84
|
+
* @param {string} type
|
|
85
|
+
* @param {any} payload
|
|
86
|
+
*/
|
|
87
|
+
async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
|
|
88
|
+
const metricsAbs = path.join(changeDir, "metrics.json");
|
|
89
|
+
/** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
|
|
90
|
+
let cur = null;
|
|
91
|
+
if (await pathExists(metricsAbs)) {
|
|
92
|
+
try {
|
|
93
|
+
cur = JSON.parse(await readText(metricsAbs));
|
|
94
|
+
} catch {
|
|
95
|
+
cur = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!cur || typeof cur !== "object") {
|
|
99
|
+
cur = { version: 1, change_id: changeId, events: [] };
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(cur.events)) cur.events = [];
|
|
102
|
+
cur.events.push({ type, timestamp: nowIsoUtc(), payload: payload ?? null });
|
|
103
|
+
cur.events = cur.events.slice(-200);
|
|
104
|
+
cur.updated_at = nowIsoUtc();
|
|
105
|
+
await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function appendMetricsEvent(gitRoot, changeId, type, payload) {
|
|
109
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
110
|
+
await appendMetricsEventAtDir(changeDir, changeId, type, payload);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} gitRoot
|
|
115
|
+
*/
|
|
116
|
+
async function resolveTemplateIdForRepo(gitRoot) {
|
|
117
|
+
const manifestPath = path.join(gitRoot, ".aiws", "manifest.json");
|
|
118
|
+
if (!(await pathExists(manifestPath))) return "workspace";
|
|
119
|
+
try {
|
|
120
|
+
const stored = JSON.parse(await readText(manifestPath));
|
|
121
|
+
const templateId = String(stored.template_id || "").trim();
|
|
122
|
+
return templateId || "workspace";
|
|
123
|
+
} catch {
|
|
124
|
+
return "workspace";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} gitRoot
|
|
130
|
+
* @returns {Promise<{ templateDir: string, resolvedFrom: string }>}
|
|
131
|
+
*/
|
|
132
|
+
async function resolveChangeTemplatesDir(gitRoot) {
|
|
133
|
+
const candidates = [
|
|
134
|
+
{ dir: path.join(gitRoot, "changes", "templates"), label: `${gitRoot}/changes/templates` },
|
|
135
|
+
{ dir: path.join(gitRoot, "workflow", "changes", "templates"), label: `${gitRoot}/workflow/changes/templates` },
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
for (const c of candidates) {
|
|
139
|
+
if (!(await pathExists(path.join(c.dir, "proposal.md")))) continue;
|
|
140
|
+
if (!(await pathExists(path.join(c.dir, "tasks.md")))) continue;
|
|
141
|
+
return { templateDir: c.dir, resolvedFrom: c.label };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
145
|
+
const tpl = await loadTemplate(templateId);
|
|
146
|
+
const fallback = path.join(tpl.templateDir, "changes", "templates");
|
|
147
|
+
|
|
148
|
+
if (!(await pathExists(path.join(fallback, "proposal.md"))) || !(await pathExists(path.join(fallback, "tasks.md")))) {
|
|
149
|
+
throw new UserError("Missing change templates.", { details: `Expected proposal.md and tasks.md under: ${fallback}` });
|
|
150
|
+
}
|
|
151
|
+
return { templateDir: fallback, resolvedFrom: `@aipper/aiws-spec:${tpl.templateId}` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} templateText
|
|
156
|
+
* @param {{ changeId: string, title: string, createdAt: string }} vars
|
|
157
|
+
*/
|
|
158
|
+
function renderTemplate(templateText, vars) {
|
|
159
|
+
return templateText.replaceAll("{{CHANGE_ID}}", vars.changeId).replaceAll("{{TITLE}}", vars.title).replaceAll("{{CREATED_AT}}", vars.createdAt);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {string} gitRoot
|
|
164
|
+
* @returns {Promise<{ path: string, args: string[] }>}
|
|
165
|
+
*/
|
|
166
|
+
async function resolveWsChangeChecker(gitRoot) {
|
|
167
|
+
const local = path.join(gitRoot, "tools", "ws_change_check.py");
|
|
168
|
+
if (await pathExists(local)) return { path: local, args: [local] };
|
|
169
|
+
|
|
170
|
+
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
171
|
+
const tpl = await loadTemplate(templateId);
|
|
172
|
+
const fallback = path.join(tpl.templateDir, "tools", "ws_change_check.py");
|
|
173
|
+
if (!(await pathExists(fallback))) {
|
|
174
|
+
throw new UserError("Missing ws_change_check.py.", { details: "Hint: run `aiws init .` to install tools/ws_change_check.py in your repo." });
|
|
175
|
+
}
|
|
176
|
+
return { path: fallback, args: [fallback] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} gitRoot
|
|
181
|
+
* @param {string[]} args
|
|
182
|
+
*/
|
|
183
|
+
async function runPython(gitRoot, args) {
|
|
184
|
+
let res;
|
|
185
|
+
try {
|
|
186
|
+
res = await runCommand("python3", args, { cwd: gitRoot });
|
|
187
|
+
} catch (e) {
|
|
188
|
+
throw new UserError("python3 is required.", { details: e instanceof Error ? e.message : String(e) });
|
|
189
|
+
}
|
|
190
|
+
return res;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} gitRoot
|
|
195
|
+
* @param {string | undefined} changeId
|
|
196
|
+
* @param {{ command: string, allowFinishedUnarchived?: boolean }} options
|
|
197
|
+
*/
|
|
198
|
+
async function resolveChangeId(gitRoot, changeId, options) {
|
|
199
|
+
const resolved = changeId || inferChangeIdFromBranch(await currentBranch(gitRoot));
|
|
200
|
+
if (resolved) {
|
|
201
|
+
const terminated = await detectTerminatedChange(gitRoot, resolved);
|
|
202
|
+
if (!isTerminatedAllowed(options.command, terminated, { allowFinishedUnarchived: options.allowFinishedUnarchived === true })) {
|
|
203
|
+
throw terminatedChangeError(gitRoot, resolved, terminated, `aiws change ${options.command}`);
|
|
204
|
+
}
|
|
205
|
+
return resolved;
|
|
206
|
+
}
|
|
207
|
+
throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {string} gitRoot
|
|
212
|
+
* @param {string} changeId
|
|
213
|
+
*/
|
|
214
|
+
function changeDirAbs(gitRoot, changeId) {
|
|
215
|
+
return path.join(gitRoot, "changes", changeId);
|
|
216
|
+
}
|
|
66
217
|
|
|
67
218
|
const CHANGE_BRANCH_RE = /^(change|changes|ws|ws-change)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
68
219
|
const CHANGE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
220
|
+
|
|
69
221
|
/**
|
|
70
222
|
* @param {string} changeId
|
|
71
223
|
*/
|
|
@@ -162,53 +314,8 @@ async function checkGitClean(gitRoot) {
|
|
|
162
314
|
return { clean: false, details: truncated };
|
|
163
315
|
}
|
|
164
316
|
|
|
165
|
-
/**
|
|
166
|
-
* @param {string} gitRoot
|
|
167
|
-
* @returns {Promise<Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>>}
|
|
168
|
-
*/
|
|
169
|
-
async function listGitWorktrees(gitRoot) {
|
|
170
|
-
const res = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot });
|
|
171
|
-
if (res.code !== 0) {
|
|
172
|
-
throw new UserError("Failed to list git worktrees.", { details: res.stderr || res.stdout });
|
|
173
|
-
}
|
|
174
|
-
const lines = String(res.stdout || "")
|
|
175
|
-
.split(/\r?\n/)
|
|
176
|
-
.map((l) => l.trimEnd());
|
|
177
|
-
|
|
178
|
-
/** @type {Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>} */
|
|
179
|
-
const out = [];
|
|
180
|
-
/** @type {{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string } | null} */
|
|
181
|
-
let cur = null;
|
|
182
|
-
|
|
183
|
-
for (const line of lines) {
|
|
184
|
-
if (!line) continue;
|
|
185
|
-
if (line.startsWith("worktree ")) {
|
|
186
|
-
if (cur) out.push(cur);
|
|
187
|
-
cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "", locked: false, lockReason: "" };
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (!cur) continue;
|
|
191
|
-
if (line.startsWith("HEAD ")) {
|
|
192
|
-
cur.head = line.slice("HEAD ".length).trim();
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
|
-
if (line.startsWith("branch ")) {
|
|
196
|
-
cur.branch = line.slice("branch ".length).trim();
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
if (line === "locked" || line.startsWith("locked ")) {
|
|
200
|
-
cur.locked = true;
|
|
201
|
-
cur.lockReason = line === "locked" ? "" : line.slice("locked ".length).trim();
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (cur) out.push(cur);
|
|
206
|
-
return out;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
317
|
/**
|
|
210
318
|
* @param {string} branch
|
|
211
|
-
* @returns {string}
|
|
212
319
|
*/
|
|
213
320
|
function inferChangeIdFromBranch(branch) {
|
|
214
321
|
const m = CHANGE_BRANCH_RE.exec(branch || "");
|
|
@@ -681,12 +788,6 @@ function inferChangePhase(blockersStrict, tasks) {
|
|
|
681
788
|
return "deliver";
|
|
682
789
|
}
|
|
683
790
|
|
|
684
|
-
/**
|
|
685
|
-
* Compute change status as JSON-friendly object (used by dashboard and CLI).
|
|
686
|
-
*
|
|
687
|
-
* @param {string} gitRoot
|
|
688
|
-
* @param {string} changeId
|
|
689
|
-
*/
|
|
690
791
|
export async function computeChangeStatus(gitRoot, changeId) {
|
|
691
792
|
assertValidChangeId(changeId);
|
|
692
793
|
await ensureTruthFiles(gitRoot);
|
|
@@ -695,7 +796,6 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
695
796
|
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
696
797
|
const context = await resolveChangeStatusContext(gitRoot, changeId, {
|
|
697
798
|
currentBranch,
|
|
698
|
-
listGitWorktrees,
|
|
699
799
|
pathExists,
|
|
700
800
|
isGitRepository: (repoPath) => isGitRepository(repoPath, { pathExists, runCommand }),
|
|
701
801
|
inferChangeIdFromBranch,
|
|
@@ -868,190 +968,21 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
868
968
|
};
|
|
869
969
|
}
|
|
870
970
|
|
|
871
|
-
/**
|
|
872
|
-
* Run ws_change_check.py and return parsed results for dashboards/tools.
|
|
873
|
-
*
|
|
874
|
-
* @param {string} gitRoot
|
|
875
|
-
* @param {string} changeId
|
|
876
|
-
* @param {{ strict: boolean, allowTruthDrift: boolean, checkEvidence?: boolean, checkScope?: boolean }} options
|
|
877
|
-
*/
|
|
878
971
|
export async function validateChangeArtifacts(gitRoot, changeId, options) {
|
|
879
972
|
return validateChangeArtifactsModel(gitRoot, changeId, options, {
|
|
880
973
|
assertValidChangeId,
|
|
881
974
|
collectFinishGateErrors,
|
|
882
975
|
computeChangeStatus,
|
|
883
976
|
ensureTruthFiles,
|
|
977
|
+
pathExists,
|
|
978
|
+
readText,
|
|
884
979
|
resolveWsChangeChecker,
|
|
980
|
+
runCommand,
|
|
885
981
|
runPython,
|
|
982
|
+
UserError,
|
|
886
983
|
});
|
|
887
984
|
}
|
|
888
985
|
|
|
889
|
-
/**
|
|
890
|
-
* @param {string} text
|
|
891
|
-
*/
|
|
892
|
-
function countWsTodo(text) {
|
|
893
|
-
return (text.match(/WS:TODO/g) || []).length;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* @param {string} text
|
|
898
|
-
*/
|
|
899
|
-
function countPlaceholders(text) {
|
|
900
|
-
return (text.match(/{{CHANGE_ID}}|{{TITLE}}|{{CREATED_AT}}/g) || []).length;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* @param {string} changeDir
|
|
905
|
-
* @param {string} rel
|
|
906
|
-
*/
|
|
907
|
-
async function fileState(changeDir, rel) {
|
|
908
|
-
const abs = path.join(changeDir, rel);
|
|
909
|
-
if (!(await pathExists(abs))) return { state: "missing", wsTodo: 0, placeholders: 0, abs, text: "" };
|
|
910
|
-
const st = await fs.stat(abs);
|
|
911
|
-
if (st.size === 0) return { state: "empty", wsTodo: 0, placeholders: 0, abs, text: "" };
|
|
912
|
-
const text = await readText(abs);
|
|
913
|
-
return { state: "ok", wsTodo: countWsTodo(text), placeholders: countPlaceholders(text), abs, text };
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
/**
|
|
917
|
-
* Append a change lifecycle event into changes/<id>/metrics.json.
|
|
918
|
-
*
|
|
919
|
-
* @param {string} gitRoot
|
|
920
|
-
* @param {string} changeId
|
|
921
|
-
* @param {string} type
|
|
922
|
-
* @param {any} payload
|
|
923
|
-
*/
|
|
924
|
-
async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
|
|
925
|
-
const metricsAbs = path.join(changeDir, "metrics.json");
|
|
926
|
-
/** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
|
|
927
|
-
let cur = null;
|
|
928
|
-
if (await pathExists(metricsAbs)) {
|
|
929
|
-
try {
|
|
930
|
-
cur = JSON.parse(await readText(metricsAbs));
|
|
931
|
-
} catch {
|
|
932
|
-
cur = null;
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
if (!cur || typeof cur !== "object") {
|
|
936
|
-
cur = { version: 1, change_id: changeId, events: [] };
|
|
937
|
-
}
|
|
938
|
-
if (!Array.isArray(cur.events)) cur.events = [];
|
|
939
|
-
cur.events.push({ type, timestamp: nowIsoUtc(), payload: payload ?? null });
|
|
940
|
-
cur.events = cur.events.slice(-200);
|
|
941
|
-
cur.updated_at = nowIsoUtc();
|
|
942
|
-
await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
async function appendMetricsEvent(gitRoot, changeId, type, payload) {
|
|
946
|
-
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
947
|
-
await appendMetricsEventAtDir(changeDir, changeId, type, payload);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* @param {string} gitRoot
|
|
952
|
-
*/
|
|
953
|
-
async function resolveTemplateIdForRepo(gitRoot) {
|
|
954
|
-
const manifestPath = path.join(gitRoot, ".aiws", "manifest.json");
|
|
955
|
-
if (!(await pathExists(manifestPath))) return "workspace";
|
|
956
|
-
try {
|
|
957
|
-
const stored = JSON.parse(await readText(manifestPath));
|
|
958
|
-
const templateId = String(stored.template_id || "").trim();
|
|
959
|
-
return templateId || "workspace";
|
|
960
|
-
} catch {
|
|
961
|
-
return "workspace";
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
/**
|
|
966
|
-
* @param {string} gitRoot
|
|
967
|
-
* @returns {Promise<{ templateDir: string, resolvedFrom: string }>}
|
|
968
|
-
*/
|
|
969
|
-
async function resolveChangeTemplatesDir(gitRoot) {
|
|
970
|
-
const candidates = [
|
|
971
|
-
{ dir: path.join(gitRoot, "changes", "templates"), label: `${gitRoot}/changes/templates` },
|
|
972
|
-
{ dir: path.join(gitRoot, "workflow", "changes", "templates"), label: `${gitRoot}/workflow/changes/templates` },
|
|
973
|
-
];
|
|
974
|
-
|
|
975
|
-
for (const c of candidates) {
|
|
976
|
-
if (!(await pathExists(path.join(c.dir, "proposal.md")))) continue;
|
|
977
|
-
if (!(await pathExists(path.join(c.dir, "tasks.md")))) continue;
|
|
978
|
-
return { templateDir: c.dir, resolvedFrom: c.label };
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
982
|
-
const tpl = await loadTemplate(templateId);
|
|
983
|
-
const fallback = path.join(tpl.templateDir, "changes", "templates");
|
|
984
|
-
|
|
985
|
-
if (!(await pathExists(path.join(fallback, "proposal.md"))) || !(await pathExists(path.join(fallback, "tasks.md")))) {
|
|
986
|
-
throw new UserError("Missing change templates.", { details: `Expected proposal.md and tasks.md under: ${fallback}` });
|
|
987
|
-
}
|
|
988
|
-
return { templateDir: fallback, resolvedFrom: `@aipper/aiws-spec:${tpl.templateId}` };
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
/**
|
|
992
|
-
* @param {string} templateText
|
|
993
|
-
* @param {{ changeId: string, title: string, createdAt: string }} vars
|
|
994
|
-
*/
|
|
995
|
-
function renderTemplate(templateText, vars) {
|
|
996
|
-
return templateText.replaceAll("{{CHANGE_ID}}", vars.changeId).replaceAll("{{TITLE}}", vars.title).replaceAll("{{CREATED_AT}}", vars.createdAt);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* @param {string} gitRoot
|
|
1001
|
-
* @returns {Promise<{ path: string, args: string[] }>}
|
|
1002
|
-
*/
|
|
1003
|
-
async function resolveWsChangeChecker(gitRoot) {
|
|
1004
|
-
const local = path.join(gitRoot, "tools", "ws_change_check.py");
|
|
1005
|
-
if (await pathExists(local)) return { path: local, args: [local] };
|
|
1006
|
-
|
|
1007
|
-
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
1008
|
-
const tpl = await loadTemplate(templateId);
|
|
1009
|
-
const fallback = path.join(tpl.templateDir, "tools", "ws_change_check.py");
|
|
1010
|
-
if (!(await pathExists(fallback))) {
|
|
1011
|
-
throw new UserError("Missing ws_change_check.py.", { details: "Hint: run `aiws init .` to install tools/ws_change_check.py in your repo." });
|
|
1012
|
-
}
|
|
1013
|
-
return { path: fallback, args: [fallback] };
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* @param {string} gitRoot
|
|
1018
|
-
* @param {string[]} args
|
|
1019
|
-
*/
|
|
1020
|
-
async function runPython(gitRoot, args) {
|
|
1021
|
-
let res;
|
|
1022
|
-
try {
|
|
1023
|
-
res = await runCommand("python3", args, { cwd: gitRoot });
|
|
1024
|
-
} catch (e) {
|
|
1025
|
-
throw new UserError("python3 is required.", { details: e instanceof Error ? e.message : String(e) });
|
|
1026
|
-
}
|
|
1027
|
-
return res;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
/**
|
|
1031
|
-
* @param {string} gitRoot
|
|
1032
|
-
* @param {string | undefined} changeId
|
|
1033
|
-
* @param {{ command: string, allowFinishedUnarchived?: boolean }} options
|
|
1034
|
-
*/
|
|
1035
|
-
async function resolveChangeId(gitRoot, changeId, options) {
|
|
1036
|
-
const resolved = changeId || inferChangeIdFromBranch(await currentBranch(gitRoot));
|
|
1037
|
-
if (resolved) {
|
|
1038
|
-
const terminated = await detectTerminatedChange(gitRoot, resolved);
|
|
1039
|
-
if (!isTerminatedAllowed(options.command, terminated, { allowFinishedUnarchived: options.allowFinishedUnarchived === true })) {
|
|
1040
|
-
throw terminatedChangeError(gitRoot, resolved, terminated, `aiws change ${options.command}`);
|
|
1041
|
-
}
|
|
1042
|
-
return resolved;
|
|
1043
|
-
}
|
|
1044
|
-
throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/**
|
|
1048
|
-
* @param {string} gitRoot
|
|
1049
|
-
* @param {string} changeId
|
|
1050
|
-
*/
|
|
1051
|
-
function changeDirAbs(gitRoot, changeId) {
|
|
1052
|
-
return path.join(gitRoot, "changes", changeId);
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
986
|
/**
|
|
1056
987
|
* aiws change list
|
|
1057
988
|
*/
|
|
@@ -1289,7 +1220,7 @@ export async function changeNewCommand(options) {
|
|
|
1289
1220
|
/**
|
|
1290
1221
|
* aiws change start
|
|
1291
1222
|
*
|
|
1292
|
-
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean
|
|
1223
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean }} options
|
|
1293
1224
|
*/
|
|
1294
1225
|
export async function changeStartCommand(options) {
|
|
1295
1226
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
@@ -1302,55 +1233,32 @@ export async function changeStartCommand(options) {
|
|
|
1302
1233
|
|
|
1303
1234
|
const startFromBranch = await currentBranch(gitRoot);
|
|
1304
1235
|
const branch = `change/${changeId}`;
|
|
1305
|
-
const branchRef = `refs/heads/${branch}`;
|
|
1306
1236
|
const allowDirty = options.allowDirty === true;
|
|
1307
1237
|
|
|
1308
|
-
if (options.worktree === true && options.noSwitch === true) {
|
|
1309
|
-
throw new UserError("change start: cannot combine --worktree with --no-switch");
|
|
1310
|
-
}
|
|
1311
1238
|
if (options.forceSwitch === true && options.noSwitch === true) {
|
|
1312
1239
|
throw new UserError("change start: cannot combine --switch with --no-switch");
|
|
1313
1240
|
}
|
|
1314
|
-
if (options.forceSwitch === true && options.worktree === true) {
|
|
1315
|
-
throw new UserError("change start: cannot combine --switch with --worktree");
|
|
1316
|
-
}
|
|
1317
1241
|
|
|
1318
1242
|
const hasBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
1319
1243
|
const hasGitmodules = await pathExists(path.join(gitRoot, ".gitmodules"));
|
|
1320
1244
|
let effectiveNoSwitch = options.noSwitch === true;
|
|
1321
1245
|
const effectiveForceSwitch = options.forceSwitch === true;
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
effectiveWorktree = true;
|
|
1328
|
-
console.error("warn: .gitmodules detected (git submodules). defaulting to --worktree to avoid switching the superproject branch.");
|
|
1329
|
-
console.error("warn: to avoid worktree and just prepare artifacts: aiws change start <change-id> --no-switch");
|
|
1330
|
-
console.error("warn: to switch anyway: aiws change start <change-id> --switch");
|
|
1331
|
-
} else {
|
|
1332
|
-
effectiveNoSwitch = true;
|
|
1333
|
-
if (prereq.reason === "no_commit") {
|
|
1334
|
-
console.error("warn: .gitmodules detected (git submodules). cannot use --worktree on a repo with no commits; defaulting to --no-switch.");
|
|
1335
|
-
} else {
|
|
1336
|
-
console.error("warn: .gitmodules detected (git submodules). cannot use --worktree without truth files committed in HEAD; defaulting to --no-switch.");
|
|
1337
|
-
}
|
|
1338
|
-
console.error("warn: to switch anyway: aiws change start <change-id> --switch");
|
|
1339
|
-
console.error("warn: recommended for submodules: aiws change start <change-id> --worktree (after committing truth files)");
|
|
1340
|
-
}
|
|
1246
|
+
|
|
1247
|
+
if (hasGitmodules && !effectiveNoSwitch && !effectiveForceSwitch) {
|
|
1248
|
+
effectiveNoSwitch = true;
|
|
1249
|
+
console.error("warn: .gitmodules detected (git submodules). defaulting to --no-switch (prepare artifacts only).");
|
|
1250
|
+
console.error("warn: to switch anyway: aiws change start <change-id> --switch");
|
|
1341
1251
|
} else if (hasGitmodules && effectiveForceSwitch) {
|
|
1342
1252
|
console.error("warn: .gitmodules detected (git submodules). switching the superproject branch due to --switch.");
|
|
1343
|
-
console.error("warn: recommended for submodules: aiws change start <change-id> --
|
|
1253
|
+
console.error("warn: recommended for submodules: aiws change start <change-id> --no-switch");
|
|
1344
1254
|
}
|
|
1345
1255
|
|
|
1346
|
-
if (!allowDirty &&
|
|
1256
|
+
if (!allowDirty && !effectiveNoSwitch) {
|
|
1347
1257
|
const clean = await checkGitClean(gitRoot);
|
|
1348
1258
|
if (!clean.clean) {
|
|
1349
|
-
|
|
1350
|
-
throw new UserError(`Refusing to start change with a dirty working tree (${mode} mode).`, {
|
|
1259
|
+
throw new UserError("Refusing to start change with a dirty working tree.", {
|
|
1351
1260
|
details:
|
|
1352
1261
|
`${clean.details}\n\n` +
|
|
1353
|
-
`Why: starting in ${mode} mode changes your checkout context; uncommitted changes may not appear where you expect.\n` +
|
|
1354
1262
|
"Fix: commit first, then retry:\n" +
|
|
1355
1263
|
" git add -A && git commit -m \"wip: save before change start\"\n" +
|
|
1356
1264
|
"Fix: or stash:\n" +
|
|
@@ -1361,139 +1269,6 @@ export async function changeStartCommand(options) {
|
|
|
1361
1269
|
}
|
|
1362
1270
|
}
|
|
1363
1271
|
|
|
1364
|
-
if (effectiveWorktree) {
|
|
1365
|
-
const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
|
|
1366
|
-
if (!prereq.ok) {
|
|
1367
|
-
if (prereq.reason === "no_commit") {
|
|
1368
|
-
throw new UserError("change start --worktree requires a repo with at least one commit.", { details: "Hint: make an initial commit, then retry." });
|
|
1369
|
-
}
|
|
1370
|
-
const missingInHead = Array.isArray(prereq.missing) ? prereq.missing : [];
|
|
1371
|
-
throw new UserError("change start --worktree requires truth files committed in HEAD.", {
|
|
1372
|
-
details:
|
|
1373
|
-
`Missing in HEAD:\n${missingInHead.map((f) => `- ${f}`).join("\n")}\n\n` +
|
|
1374
|
-
"Why: git worktree checks out from HEAD, so uncommitted files won't be present in the new worktree.\n" +
|
|
1375
|
-
"Hint: commit the truth files first, or use `aiws change start --no-switch`.",
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
|
-
const worktreeDir = resolveDefaultWorktreeDir(gitRoot, options.worktreeDir, changeId);
|
|
1379
|
-
|
|
1380
|
-
const rel = path.relative(gitRoot, worktreeDir);
|
|
1381
|
-
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
|
1382
|
-
throw new UserError("worktree dir must be outside the git root.", {
|
|
1383
|
-
details: `git_root: ${gitRoot}\nworktree_dir: ${worktreeDir}\nHint: use a sibling dir like ../<repo>-<change-id> (default) or pass --worktree-dir <path>.`,
|
|
1384
|
-
});
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
const worktrees = await listGitWorktrees(gitRoot);
|
|
1388
|
-
/** @type {Map<string, { worktree: string, head: string, branch: string }>} */
|
|
1389
|
-
const byPath = new Map();
|
|
1390
|
-
/** @type {Map<string, string>} */
|
|
1391
|
-
const byBranch = new Map();
|
|
1392
|
-
for (const w of worktrees) {
|
|
1393
|
-
const k = path.resolve(w.worktree);
|
|
1394
|
-
byPath.set(k, w);
|
|
1395
|
-
if (w.branch) byBranch.set(w.branch, k);
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
const destKey = path.resolve(worktreeDir);
|
|
1399
|
-
const destExisting = byPath.get(destKey);
|
|
1400
|
-
|
|
1401
|
-
if (!destExisting) {
|
|
1402
|
-
if (await pathExists(destKey)) {
|
|
1403
|
-
throw new UserError("worktree dir already exists and is not a registered worktree; refusing to overwrite.", {
|
|
1404
|
-
details: `worktree_dir: ${destKey}`,
|
|
1405
|
-
});
|
|
1406
|
-
}
|
|
1407
|
-
const used = byBranch.get(branchRef);
|
|
1408
|
-
if (used) {
|
|
1409
|
-
throw new UserError("change branch is already checked out in another worktree.", {
|
|
1410
|
-
details: `branch: ${branch}\nworktree: ${used}\nHint: use that worktree, or pick another change-id.`,
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
const args = hasBranch ? ["worktree", "add", destKey, branch] : ["worktree", "add", "-b", branch, destKey];
|
|
1415
|
-
const add = await runCommand("git", args, { cwd: gitRoot });
|
|
1416
|
-
if (add.code !== 0) {
|
|
1417
|
-
throw new UserError("Failed to create git worktree.", { details: add.stderr || add.stdout });
|
|
1418
|
-
}
|
|
1419
|
-
} else if (destExisting.branch && destExisting.branch !== branchRef) {
|
|
1420
|
-
throw new UserError("worktree dir already exists but is on a different branch.", {
|
|
1421
|
-
details: `worktree_dir: ${destKey}\nexpected_branch: ${branchRef}\nactual_branch: ${destExisting.branch}`,
|
|
1422
|
-
});
|
|
1423
|
-
} else if (!destExisting.branch) {
|
|
1424
|
-
throw new UserError("worktree dir already exists but is detached; refusing to proceed.", {
|
|
1425
|
-
details: `worktree_dir: ${destKey}\nHint: inspect with: git -C ${destKey} status`,
|
|
1426
|
-
});
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
const changeDir = changeDirAbs(destKey, changeId);
|
|
1430
|
-
if (!(await pathExists(changeDir))) {
|
|
1431
|
-
await changeNewAtGitRoot(destKey, {
|
|
1432
|
-
changeId,
|
|
1433
|
-
title: options.title,
|
|
1434
|
-
noDesign: options.noDesign,
|
|
1435
|
-
baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
|
|
1436
|
-
});
|
|
1437
|
-
} else {
|
|
1438
|
-
await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
|
|
1442
|
-
// --submodules is now implicit (kept for backwards compat but has no effect).
|
|
1443
|
-
if (hasGitmodules) {
|
|
1444
|
-
const hasEntries = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: destKey }).then(
|
|
1445
|
-
(r) => r.code === 0 && String(r.stdout || "").trim().length > 0
|
|
1446
|
-
);
|
|
1447
|
-
if (hasEntries) {
|
|
1448
|
-
console.error("info: .gitmodules detected; initializing submodules in the new worktree.");
|
|
1449
|
-
const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
|
|
1450
|
-
if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
|
|
1451
|
-
|
|
1452
|
-
// Sanity check: fail fast if anything remains uninitialized or conflicted.
|
|
1453
|
-
const st = await runCommand("git", ["submodule", "status", "--recursive"], { cwd: destKey });
|
|
1454
|
-
const lines = String(st.stdout || "")
|
|
1455
|
-
.split("\n")
|
|
1456
|
-
.map((l) => l.trimEnd())
|
|
1457
|
-
.filter(Boolean);
|
|
1458
|
-
const bad = lines.filter((l) => l.startsWith("-") || l.startsWith("U"));
|
|
1459
|
-
if (bad.length > 0) {
|
|
1460
|
-
throw new UserError("Submodules not fully initialized in worktree.", {
|
|
1461
|
-
details: `${bad.slice(0, 20).join("\n")}${bad.length > 20 ? "\n..." : ""}\n\nHint: run in the worktree:\n git submodule update --init --recursive`,
|
|
1462
|
-
});
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
if (options.enableHooks) {
|
|
1468
|
-
await hooksInstallCommand({ targetPath: gitRoot });
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
await appendMetricsEvent(destKey, changeId, "change_start", {
|
|
1472
|
-
mode: "worktree",
|
|
1473
|
-
worktree: destKey,
|
|
1474
|
-
branch,
|
|
1475
|
-
from_branch: startFromBranch || "",
|
|
1476
|
-
submodules: true,
|
|
1477
|
-
hooks: options.enableHooks === true,
|
|
1478
|
-
});
|
|
1479
|
-
|
|
1480
|
-
// Check Depends_On dependencies (worktree mode)
|
|
1481
|
-
await checkDependenciesAndPrintHandoff(destKey, changeId, changeDir);
|
|
1482
|
-
|
|
1483
|
-
console.log(`ok: change worktree ready: ${changeId}`);
|
|
1484
|
-
console.log(`worktree: ${destKey}`);
|
|
1485
|
-
console.log(`branch: ${branch}`);
|
|
1486
|
-
console.log("next:");
|
|
1487
|
-
console.log(` - cd: cd ${destKey}`);
|
|
1488
|
-
console.log(" - status: aiws change status");
|
|
1489
|
-
console.log(" - next: aiws change next");
|
|
1490
|
-
console.log(" - validate: aiws change validate --strict");
|
|
1491
|
-
if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
|
|
1492
|
-
console.log("finish (safe merge):");
|
|
1493
|
-
console.log(` - from main worktree: git merge --ff-only ${branch}`);
|
|
1494
|
-
return;
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
1272
|
const headReady = await hasHeadCommit(gitRoot);
|
|
1498
1273
|
let branchReady = hasBranch;
|
|
1499
1274
|
|
|
@@ -1516,7 +1291,6 @@ export async function changeStartCommand(options) {
|
|
|
1516
1291
|
await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
|
|
1517
1292
|
}
|
|
1518
1293
|
|
|
1519
|
-
// Check Depends_On dependencies (non-worktree mode)
|
|
1520
1294
|
await checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir);
|
|
1521
1295
|
|
|
1522
1296
|
if (options.enableHooks) {
|
|
@@ -1527,7 +1301,6 @@ export async function changeStartCommand(options) {
|
|
|
1527
1301
|
const curLabel = cur || "(detached)";
|
|
1528
1302
|
await appendMetricsEvent(gitRoot, changeId, "change_start", {
|
|
1529
1303
|
mode: effectiveNoSwitch ? "no-switch" : "switch",
|
|
1530
|
-
worktree: gitRoot,
|
|
1531
1304
|
branch,
|
|
1532
1305
|
from_branch: startFromBranch || "",
|
|
1533
1306
|
current_branch: curLabel,
|
|
@@ -1580,18 +1353,17 @@ export async function changeFinishCommand(options) {
|
|
|
1580
1353
|
`fallback: if you intentionally only need local archive recovery, run \`aiws change archive ${resolvedChangeId}\``,
|
|
1581
1354
|
});
|
|
1582
1355
|
}
|
|
1583
|
-
const { changeId, changeBranch, into, cur
|
|
1356
|
+
const { changeId, changeBranch, into, cur } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
|
|
1584
1357
|
assertValidChangeId,
|
|
1585
1358
|
checkGitClean,
|
|
1586
1359
|
currentBranch,
|
|
1587
|
-
listGitWorktrees,
|
|
1588
1360
|
pathExists,
|
|
1589
1361
|
readText,
|
|
1590
1362
|
resolveChangeId,
|
|
1591
1363
|
runCommand,
|
|
1592
1364
|
UserError,
|
|
1593
1365
|
});
|
|
1594
|
-
const finishStatusRoot =
|
|
1366
|
+
const finishStatusRoot = gitRoot;
|
|
1595
1367
|
const finishStatus = await computeChangeStatus(finishStatusRoot, changeId);
|
|
1596
1368
|
const finishGateErrors = collectFinishGateErrors(changeId, finishStatus.reviewGates);
|
|
1597
1369
|
if (finishGateErrors.length > 0) {
|
|
@@ -1633,11 +1405,12 @@ export async function changeFinishCommand(options) {
|
|
|
1633
1405
|
|
|
1634
1406
|
const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
|
|
1635
1407
|
if (merge.code !== 0) {
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1408
|
+
throw new UserError("Failed to fast-forward merge change branch into target.", {
|
|
1409
|
+
details:
|
|
1410
|
+
`${merge.stderr || merge.stdout}` +
|
|
1411
|
+
`\n\nIf not fast-forward, rebase the change branch then retry:\n` +
|
|
1412
|
+
` git switch ${changeBranch}\n git rebase ${into}\n git switch ${into}\n aiws change finish ${changeId}`,
|
|
1413
|
+
});
|
|
1641
1414
|
}
|
|
1642
1415
|
|
|
1643
1416
|
await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
|
|
@@ -1651,7 +1424,7 @@ export async function changeFinishCommand(options) {
|
|
|
1651
1424
|
let handoffRel = "";
|
|
1652
1425
|
if (options.push === true) {
|
|
1653
1426
|
try {
|
|
1654
|
-
const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into,
|
|
1427
|
+
const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, {
|
|
1655
1428
|
pathExists,
|
|
1656
1429
|
readText,
|
|
1657
1430
|
runCommand,
|
|
@@ -1676,37 +1449,9 @@ export async function changeFinishCommand(options) {
|
|
|
1676
1449
|
|
|
1677
1450
|
console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
|
|
1678
1451
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
listGitWorktrees,
|
|
1683
|
-
pathExists,
|
|
1684
|
-
runCommand,
|
|
1685
|
-
UserError,
|
|
1686
|
-
});
|
|
1687
|
-
cleanupNote = cleanup.status === "skipped" ? `skipped:${cleanup.reason || "unknown"}` : cleanup.status;
|
|
1688
|
-
finishCompleted =
|
|
1689
|
-
cleanup.status === "removed" ||
|
|
1690
|
-
cleanup.status === "pruned-missing" ||
|
|
1691
|
-
(cleanup.status === "skipped" && cleanup.reason === "current_worktree");
|
|
1692
|
-
if (cleanup.status === "removed") {
|
|
1693
|
-
console.log(`cleanup: removed worktree ${cleanup.worktree}`);
|
|
1694
|
-
} else if (cleanup.status === "pruned-missing") {
|
|
1695
|
-
console.log(`cleanup: pruned stale worktree metadata for ${cleanup.worktree}`);
|
|
1696
|
-
} else if (cleanup.reason === "current_worktree") {
|
|
1697
|
-
console.log("cleanup: skipped (change worktree is current worktree)");
|
|
1698
|
-
} else if (cleanup.reason === "locked") {
|
|
1699
|
-
console.log(`cleanup: skipped locked worktree ${cleanup.worktree}`);
|
|
1700
|
-
} else if (cleanup.reason === "dirty") {
|
|
1701
|
-
console.log(`cleanup: skipped dirty worktree ${cleanup.worktree}`);
|
|
1702
|
-
} else {
|
|
1703
|
-
console.log("cleanup: skipped");
|
|
1704
|
-
}
|
|
1705
|
-
} else {
|
|
1706
|
-
cleanupNote = "skipped:no_separate_change_worktree";
|
|
1707
|
-
finishCompleted = true;
|
|
1708
|
-
console.log("cleanup: skipped (no separate change worktree)");
|
|
1709
|
-
}
|
|
1452
|
+
cleanupNote = "skipped:current_worktree";
|
|
1453
|
+
finishCompleted = true;
|
|
1454
|
+
console.log("cleanup: skipped (no separate worktree)");
|
|
1710
1455
|
} catch (error) {
|
|
1711
1456
|
await appendMetricsEvent(gitRoot, changeId, "finish_failed", {
|
|
1712
1457
|
...finishBasePayload,
|
|
@@ -1727,7 +1472,7 @@ export async function changeFinishCommand(options) {
|
|
|
1727
1472
|
details:
|
|
1728
1473
|
`change: ${changeId}\n` +
|
|
1729
1474
|
`next: cleanup pending (${cleanupReason || "unknown"})\n` +
|
|
1730
|
-
` -
|
|
1475
|
+
` - then rerun: aiws change finish ${changeId} --push${options.remote ? ` --remote ${options.remote}` : ""}`,
|
|
1731
1476
|
});
|
|
1732
1477
|
}
|
|
1733
1478
|
|
|
@@ -1788,7 +1533,7 @@ export async function changeFinishCommand(options) {
|
|
|
1788
1533
|
|
|
1789
1534
|
if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
|
|
1790
1535
|
console.log("next:");
|
|
1791
|
-
console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if
|
|
1536
|
+
console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if needed when submodule state changes");
|
|
1792
1537
|
}
|
|
1793
1538
|
}
|
|
1794
1539
|
|
|
@@ -1804,12 +1549,10 @@ export async function changeStatusCommand(options) {
|
|
|
1804
1549
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
|
|
1805
1550
|
const st = await computeChangeStatus(gitRoot, changeId);
|
|
1806
1551
|
const branch = await currentBranch(gitRoot);
|
|
1807
|
-
const changeWorktreeHint = await resolveChangeWorktreeHint(gitRoot, changeId, { listGitWorktrees });
|
|
1808
1552
|
const output = await renderChangeStatusOutput(
|
|
1809
1553
|
{
|
|
1810
1554
|
gitRoot,
|
|
1811
1555
|
branch,
|
|
1812
|
-
changeWorktreeHint,
|
|
1813
1556
|
status: st,
|
|
1814
1557
|
},
|
|
1815
1558
|
{
|
|
@@ -1865,7 +1608,6 @@ export async function computeChangeNextAdvice(gitRoot, changeId) {
|
|
|
1865
1608
|
computeChangeStatus,
|
|
1866
1609
|
currentBranch,
|
|
1867
1610
|
inferChangeIdFromBranch,
|
|
1868
|
-
listGitWorktrees,
|
|
1869
1611
|
changeDirAbs,
|
|
1870
1612
|
governanceGuidanceLines,
|
|
1871
1613
|
scopeGateFailureAction,
|