@treeseed/sdk 0.6.7 → 0.6.8
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/dist/copilot.d.ts +15 -0
- package/dist/copilot.js +75 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/managed-dependencies.d.ts +56 -0
- package/dist/managed-dependencies.js +668 -0
- package/dist/operations/providers/default.js +30 -1
- package/dist/operations/services/commit-message-provider.d.ts +33 -0
- package/dist/operations/services/commit-message-provider.js +319 -0
- package/dist/operations/services/config-runtime.js +41 -20
- package/dist/operations/services/git-remote-policy.d.ts +9 -0
- package/dist/operations/services/git-remote-policy.js +55 -0
- package/dist/operations/services/git-workflow.js +22 -3
- package/dist/operations/services/github-api.js +9 -4
- package/dist/operations/services/knowledge-coop-launch.js +4 -0
- package/dist/operations/services/local-dev.js +7 -2
- package/dist/operations/services/package-reference-policy.d.ts +70 -0
- package/dist/operations/services/package-reference-policy.js +314 -0
- package/dist/operations/services/project-platform.d.ts +4 -0
- package/dist/operations/services/project-platform.js +28 -4
- package/dist/operations/services/railway-deploy.d.ts +4 -1
- package/dist/operations/services/railway-deploy.js +76 -38
- package/dist/operations/services/repository-save-orchestrator.d.ts +172 -0
- package/dist/operations/services/repository-save-orchestrator.js +1462 -0
- package/dist/operations/services/workspace-dependency-mode.d.ts +70 -0
- package/dist/operations/services/workspace-dependency-mode.js +404 -0
- package/dist/operations/services/workspace-preflight.js +5 -0
- package/dist/operations/services/workspace-save.js +10 -6
- package/dist/operations-registry.js +5 -0
- package/dist/operations-types.d.ts +1 -0
- package/dist/platform/books-data.js +4 -1
- package/dist/platform/env.yaml +6 -3
- package/dist/reconcile/builtin-adapters.js +37 -7
- package/dist/scripts/cleanup-markdown.js +4 -0
- package/dist/scripts/publish-package.js +5 -0
- package/dist/scripts/tenant-workflow-action.js +11 -2
- package/dist/verification.js +24 -12
- package/dist/workflow/operations.d.ts +381 -55
- package/dist/workflow/operations.js +718 -258
- package/dist/workflow-state.d.ts +40 -1
- package/dist/workflow-state.js +220 -17
- package/dist/workflow-support.d.ts +3 -0
- package/dist/workflow-support.js +34 -0
- package/dist/workflow.d.ts +19 -3
- package/dist/workflow.js +3 -3
- package/dist/wrangler-d1.js +6 -1
- package/package.json +17 -1
- package/templates/github/deploy.workflow.yml +24 -14
|
@@ -0,0 +1,1462 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, resolve, relative } from "node:path";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { getGitHubAutomationMode } from "./github-automation.js";
|
|
6
|
+
import {
|
|
7
|
+
ensureSshPushUrlForOrigin,
|
|
8
|
+
remoteWriteUrl,
|
|
9
|
+
sshPushUrlForRemote
|
|
10
|
+
} from "./git-remote-policy.js";
|
|
11
|
+
import {
|
|
12
|
+
generateRepositoryCommitMessage
|
|
13
|
+
} from "./commit-message-provider.js";
|
|
14
|
+
import {
|
|
15
|
+
createDevTagMessage,
|
|
16
|
+
createPackageDependencyReference,
|
|
17
|
+
updateInternalDependencySpecs
|
|
18
|
+
} from "./package-reference-policy.js";
|
|
19
|
+
import {
|
|
20
|
+
PRODUCTION_BRANCH,
|
|
21
|
+
branchExists,
|
|
22
|
+
headCommit,
|
|
23
|
+
remoteBranchExists,
|
|
24
|
+
STAGING_BRANCH
|
|
25
|
+
} from "./git-workflow.js";
|
|
26
|
+
import {
|
|
27
|
+
collectMergeConflictReport,
|
|
28
|
+
currentBranch,
|
|
29
|
+
formatMergeConflictReport,
|
|
30
|
+
hasMeaningfulChanges,
|
|
31
|
+
incrementVersion,
|
|
32
|
+
originRemoteUrl,
|
|
33
|
+
repoRoot
|
|
34
|
+
} from "./workspace-save.js";
|
|
35
|
+
import {
|
|
36
|
+
hasCompleteTreeseedPackageCheckout,
|
|
37
|
+
run,
|
|
38
|
+
sortWorkspacePackages,
|
|
39
|
+
workspacePackages
|
|
40
|
+
} from "./workspace-tools.js";
|
|
41
|
+
import { collectDeploymentLockfileWorkspaceIssues } from "./workspace-dependency-mode.js";
|
|
42
|
+
class RepositorySaveError extends Error {
|
|
43
|
+
exitCode;
|
|
44
|
+
details;
|
|
45
|
+
constructor(message, options = {}) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "RepositorySaveError";
|
|
48
|
+
this.exitCode = options.exitCode;
|
|
49
|
+
this.details = options.details;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function readJson(filePath) {
|
|
53
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
function writeJson(filePath, value) {
|
|
56
|
+
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}
|
|
57
|
+
`, "utf8");
|
|
58
|
+
}
|
|
59
|
+
function progressPrefix(node, phase) {
|
|
60
|
+
return `[${node.name}][${phase}]`;
|
|
61
|
+
}
|
|
62
|
+
function emitProgress(options, node, phase, message, stream = "stdout") {
|
|
63
|
+
const lines = String(message ?? "").split(/\r?\n/u).map((line) => line.trimEnd()).filter(Boolean);
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
options.onProgress?.(`${progressPrefix(node, phase)} ${line}`, stream);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function prefixedOutput(node, phase, output) {
|
|
69
|
+
return String(output ?? "").split(/\r?\n/u).map((line) => line.trimEnd()).filter(Boolean).map((line) => `${progressPrefix(node, phase)} ${line}`).join("\n");
|
|
70
|
+
}
|
|
71
|
+
function runCapturedCommand(node, options, phase, command, args, commandOptions = {}) {
|
|
72
|
+
emitProgress(options, node, phase, `$ ${command} ${args.join(" ")}`);
|
|
73
|
+
const result = spawnSync(command, args, {
|
|
74
|
+
cwd: commandOptions.cwd ?? node.path,
|
|
75
|
+
env: { ...process.env, ...commandOptions.env ?? {} },
|
|
76
|
+
stdio: "pipe",
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
timeout: commandOptions.timeoutMs
|
|
79
|
+
});
|
|
80
|
+
const stdout = result.stdout?.trim() ?? "";
|
|
81
|
+
const stderr = result.stderr?.trim() ?? "";
|
|
82
|
+
if (stdout) emitProgress(options, node, phase, stdout);
|
|
83
|
+
if (stderr) emitProgress(options, node, phase, stderr, "stderr");
|
|
84
|
+
if (result.status !== 0) {
|
|
85
|
+
const message = (result.error?.message ? `${result.error.message}
|
|
86
|
+
` : "") + (prefixedOutput(node, phase, stderr) || prefixedOutput(node, phase, stdout) || `${progressPrefix(node, phase)} ${command} ${args.join(" ")} failed`);
|
|
87
|
+
throw new RepositorySaveError(message, {
|
|
88
|
+
details: {
|
|
89
|
+
failingRepo: node.name,
|
|
90
|
+
phase,
|
|
91
|
+
command: `${command} ${args.join(" ")}`
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return stdout;
|
|
96
|
+
}
|
|
97
|
+
function isNoOpGitCommitError(error) {
|
|
98
|
+
if (!(error instanceof RepositorySaveError)) return false;
|
|
99
|
+
const command = typeof error.details?.command === "string" ? error.details.command : "";
|
|
100
|
+
if (!command.startsWith("git commit ")) return false;
|
|
101
|
+
return /nothing to commit|no changes added to commit/u.test(error.message);
|
|
102
|
+
}
|
|
103
|
+
function runQuietCommand(node, phase, command, args, commandOptions = {}) {
|
|
104
|
+
const result = spawnSync(command, args, {
|
|
105
|
+
cwd: commandOptions.cwd ?? node.path,
|
|
106
|
+
env: { ...process.env, ...commandOptions.env ?? {} },
|
|
107
|
+
stdio: "pipe",
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
timeout: commandOptions.timeoutMs
|
|
110
|
+
});
|
|
111
|
+
const stdout = result.stdout?.trim() ?? "";
|
|
112
|
+
const stderr = result.stderr?.trim() ?? "";
|
|
113
|
+
if (result.status !== 0) {
|
|
114
|
+
throw new RepositorySaveError(
|
|
115
|
+
[
|
|
116
|
+
`${progressPrefix(node, phase)} ${command} ${args.join(" ")} failed`,
|
|
117
|
+
stderr || stdout
|
|
118
|
+
].filter(Boolean).join("\n"),
|
|
119
|
+
{
|
|
120
|
+
details: {
|
|
121
|
+
failingRepo: node.name,
|
|
122
|
+
phase,
|
|
123
|
+
command: `${command} ${args.join(" ")}`
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return stdout;
|
|
129
|
+
}
|
|
130
|
+
async function runStreamingCommand(node, options, phase, command, args, commandOptions = {}) {
|
|
131
|
+
emitProgress(options, node, phase, `$ ${command} ${args.join(" ")}`);
|
|
132
|
+
return await new Promise((resolvePromise, reject) => {
|
|
133
|
+
const child = spawn(command, args, {
|
|
134
|
+
cwd: commandOptions.cwd ?? node.path,
|
|
135
|
+
env: { ...process.env, ...commandOptions.env ?? {} },
|
|
136
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
137
|
+
});
|
|
138
|
+
let stdout = "";
|
|
139
|
+
let stderr = "";
|
|
140
|
+
let stdoutRemainder = "";
|
|
141
|
+
let stderrRemainder = "";
|
|
142
|
+
let settled = false;
|
|
143
|
+
const flush = (chunk, stream) => {
|
|
144
|
+
const combined = stream === "stdout" ? stdoutRemainder + chunk : stderrRemainder + chunk;
|
|
145
|
+
const parts = combined.split(/\r?\n/u);
|
|
146
|
+
const complete = parts.slice(0, -1);
|
|
147
|
+
if (stream === "stdout") stdoutRemainder = parts.at(-1) ?? "";
|
|
148
|
+
else stderrRemainder = parts.at(-1) ?? "";
|
|
149
|
+
for (const line of complete) {
|
|
150
|
+
emitProgress(options, node, phase, line, stream);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const timeout = commandOptions.timeoutMs ? setTimeout(() => {
|
|
154
|
+
if (settled) return;
|
|
155
|
+
settled = true;
|
|
156
|
+
child.kill("SIGTERM");
|
|
157
|
+
reject(new Error(`${progressPrefix(node, phase)} ${command} ${args.join(" ")} timed out after ${commandOptions.timeoutMs}ms`));
|
|
158
|
+
}, commandOptions.timeoutMs) : null;
|
|
159
|
+
child.stdout?.on("data", (chunk) => {
|
|
160
|
+
const text = chunk.toString();
|
|
161
|
+
stdout += text;
|
|
162
|
+
flush(text, "stdout");
|
|
163
|
+
});
|
|
164
|
+
child.stderr?.on("data", (chunk) => {
|
|
165
|
+
const text = chunk.toString();
|
|
166
|
+
stderr += text;
|
|
167
|
+
flush(text, "stderr");
|
|
168
|
+
});
|
|
169
|
+
child.on("error", (error) => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
if (timeout) clearTimeout(timeout);
|
|
173
|
+
reject(error);
|
|
174
|
+
});
|
|
175
|
+
child.on("close", (code) => {
|
|
176
|
+
if (settled) return;
|
|
177
|
+
settled = true;
|
|
178
|
+
if (timeout) clearTimeout(timeout);
|
|
179
|
+
if (stdoutRemainder) emitProgress(options, node, phase, stdoutRemainder);
|
|
180
|
+
if (stderrRemainder) emitProgress(options, node, phase, stderrRemainder, "stderr");
|
|
181
|
+
if (code === 0) {
|
|
182
|
+
resolvePromise({ stdout, stderr });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
reject(new RepositorySaveError(
|
|
186
|
+
prefixedOutput(node, phase, stderr) || prefixedOutput(node, phase, stdout) || `${progressPrefix(node, phase)} ${command} ${args.join(" ")} failed with exit code ${code ?? "unknown"}`,
|
|
187
|
+
{
|
|
188
|
+
details: {
|
|
189
|
+
failingRepo: node.name,
|
|
190
|
+
phase,
|
|
191
|
+
command: `${command} ${args.join(" ")}`
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
));
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function packageScripts(packageJson) {
|
|
199
|
+
const scripts = packageJson?.scripts;
|
|
200
|
+
return scripts && typeof scripts === "object" && !Array.isArray(scripts) ? Object.fromEntries(Object.entries(scripts).map(([key, value]) => [key, String(value)])) : {};
|
|
201
|
+
}
|
|
202
|
+
function classifyRepoKind(packageJson) {
|
|
203
|
+
if (typeof packageJson?.name !== "string" || typeof packageJson?.version !== "string") {
|
|
204
|
+
return "project";
|
|
205
|
+
}
|
|
206
|
+
if (packageJson.private === true) {
|
|
207
|
+
return "project";
|
|
208
|
+
}
|
|
209
|
+
const scripts = packageScripts(packageJson);
|
|
210
|
+
const publishConfig = packageJson.publishConfig;
|
|
211
|
+
return typeof scripts["release:publish"] === "string" || publishConfig !== null && typeof publishConfig === "object" && !Array.isArray(publishConfig) ? "package" : "project";
|
|
212
|
+
}
|
|
213
|
+
function dependencyFields(packageJson) {
|
|
214
|
+
if (!packageJson) return [];
|
|
215
|
+
return ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"].filter((field) => packageJson[field] && typeof packageJson[field] === "object" && !Array.isArray(packageJson[field]));
|
|
216
|
+
}
|
|
217
|
+
function repoIdForPath(root, repoDir) {
|
|
218
|
+
return relative(root, repoDir).replaceAll("\\", "/") || ".";
|
|
219
|
+
}
|
|
220
|
+
function isGitRepo(repoDir) {
|
|
221
|
+
try {
|
|
222
|
+
run("git", ["rev-parse", "--is-inside-work-tree"], { cwd: repoDir, capture: true });
|
|
223
|
+
return true;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function originRemoteUrlSafe(repoDir) {
|
|
229
|
+
try {
|
|
230
|
+
return originRemoteUrl(repoDir);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function ensureWritableRemote(node, options) {
|
|
236
|
+
if (!node.remoteUrl || (options.gitRemoteWriteMode ?? "ssh-pushurl") === "off") return;
|
|
237
|
+
const result = ensureSshPushUrlForOrigin(node.path, node.remoteUrl, options.gitRemoteWriteMode ?? "ssh-pushurl");
|
|
238
|
+
if (result.changed && result.pushUrl) {
|
|
239
|
+
emitProgress(options, node, "remote", `Configured origin push URL ${result.pushUrl}; keeping ${node.remoteUrl} for reads.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function repoDisplayName(repoDir, packageJson) {
|
|
243
|
+
return typeof packageJson?.name === "string" && packageJson.name.length > 0 ? packageJson.name : basename(repoDir);
|
|
244
|
+
}
|
|
245
|
+
function parseGitmodules(root) {
|
|
246
|
+
const gitmodulesPath = resolve(root, ".gitmodules");
|
|
247
|
+
if (!existsSync(gitmodulesPath)) {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
const source = readFileSync(gitmodulesPath, "utf8");
|
|
251
|
+
const paths = [];
|
|
252
|
+
for (const match of source.matchAll(/^\s*path\s*=\s*(.+?)\s*$/gmu)) {
|
|
253
|
+
paths.push(match[1].replaceAll("\\", "/"));
|
|
254
|
+
}
|
|
255
|
+
return paths;
|
|
256
|
+
}
|
|
257
|
+
function slugBranch(branch) {
|
|
258
|
+
return branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "dev";
|
|
259
|
+
}
|
|
260
|
+
function timestampLabel(date = /* @__PURE__ */ new Date()) {
|
|
261
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/u, "Z");
|
|
262
|
+
}
|
|
263
|
+
function nextDevVersion(version, branch, date = /* @__PURE__ */ new Date()) {
|
|
264
|
+
return `${incrementVersion(version, "patch")}-dev.${slugBranch(branch)}.${timestampLabel(date)}`;
|
|
265
|
+
}
|
|
266
|
+
function escapeRegExp(value) {
|
|
267
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
268
|
+
}
|
|
269
|
+
function isStableSemverVersion(version) {
|
|
270
|
+
return /^\d+\.\d+\.\d+$/u.test(version);
|
|
271
|
+
}
|
|
272
|
+
function isDevVersionForBranch(version, branch) {
|
|
273
|
+
const branchSlug = escapeRegExp(slugBranch(branch));
|
|
274
|
+
return new RegExp(`^\\d+\\.\\d+\\.\\d+-dev\\.${branchSlug}\\.\\d{8}T\\d{6}Z$`, "u").test(version);
|
|
275
|
+
}
|
|
276
|
+
function packageVersionAtHead(node) {
|
|
277
|
+
if (!node.packageJsonPath) return null;
|
|
278
|
+
try {
|
|
279
|
+
const source = run("git", ["show", "HEAD:package.json"], { cwd: node.path, capture: true });
|
|
280
|
+
const packageJson = JSON.parse(source);
|
|
281
|
+
return typeof packageJson.version === "string" ? packageJson.version : null;
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function packageVersionEligibleForBranch(node, version, options) {
|
|
287
|
+
return node.branchMode === "package-release-main" ? isStableSemverVersion(version) : isDevVersionForBranch(version, node.branch || options.branch);
|
|
288
|
+
}
|
|
289
|
+
function selectPackageVersion(node, options) {
|
|
290
|
+
const current = String(node.packageJson?.version ?? "0.0.0");
|
|
291
|
+
if (node.branchMode === "package-dev-save" && isDevVersionForBranch(current, node.branch || options.branch) && !tagExists(node.path, current)) {
|
|
292
|
+
return { version: current, reused: true };
|
|
293
|
+
}
|
|
294
|
+
if (node.branchMode === "package-release-main") {
|
|
295
|
+
const headVersion = packageVersionAtHead(node);
|
|
296
|
+
if (headVersion && current === incrementVersion(headVersion, options.bump ?? "patch") && !tagExists(node.path, current)) {
|
|
297
|
+
return { version: current, reused: true };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { version: planPackageVersion(node, options), reused: false };
|
|
301
|
+
}
|
|
302
|
+
function createReport(node) {
|
|
303
|
+
return {
|
|
304
|
+
name: node.name,
|
|
305
|
+
path: node.path,
|
|
306
|
+
branch: node.branch,
|
|
307
|
+
dirty: hasMeaningfulChanges(node.path),
|
|
308
|
+
created: false,
|
|
309
|
+
resumed: false,
|
|
310
|
+
merged: false,
|
|
311
|
+
verified: false,
|
|
312
|
+
committed: false,
|
|
313
|
+
pushed: false,
|
|
314
|
+
deletedLocal: false,
|
|
315
|
+
deletedRemote: false,
|
|
316
|
+
tagName: null,
|
|
317
|
+
commitSha: node.branch ? headCommit(node.path) : null,
|
|
318
|
+
skippedReason: null,
|
|
319
|
+
publishWait: null,
|
|
320
|
+
version: typeof node.packageJson?.version === "string" ? node.packageJson.version : null,
|
|
321
|
+
dependencySpec: node.plannedDependencySpec,
|
|
322
|
+
devTagMetadata: null,
|
|
323
|
+
replacedDevTags: [],
|
|
324
|
+
branchMode: node.branchMode,
|
|
325
|
+
verification: null,
|
|
326
|
+
install: null,
|
|
327
|
+
lockfileValidation: null,
|
|
328
|
+
commitMessage: null,
|
|
329
|
+
commitMessageProvider: null,
|
|
330
|
+
commitMessageFallbackUsed: false,
|
|
331
|
+
commitMessageError: null
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function discoverRepositorySaveNodes(root, gitRoot = repoRoot(root), branch = currentBranch(gitRoot), options = {}) {
|
|
335
|
+
const repoDirs = /* @__PURE__ */ new Map();
|
|
336
|
+
repoDirs.set(".", gitRoot);
|
|
337
|
+
if (hasCompleteTreeseedPackageCheckout(root)) {
|
|
338
|
+
for (const pkg of workspacePackages(root)) {
|
|
339
|
+
if (isGitRepo(pkg.dir)) {
|
|
340
|
+
repoDirs.set(pkg.relativeDir, pkg.dir);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const submodulePath of parseGitmodules(root)) {
|
|
345
|
+
const dir = resolve(root, submodulePath);
|
|
346
|
+
if (existsSync(dir) && isGitRepo(dir)) {
|
|
347
|
+
repoDirs.set(submodulePath, dir);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const nodes = [...repoDirs.entries()].map(([relativePath, repoDir]) => {
|
|
351
|
+
const packageJsonPath = resolve(repoDir, "package.json");
|
|
352
|
+
const packageJson = existsSync(packageJsonPath) ? readJson(packageJsonPath) : null;
|
|
353
|
+
const kind = classifyRepoKind(packageJson);
|
|
354
|
+
const repoBranch = relativePath === "." ? currentBranch(repoDir) || branch || null : branch || currentBranch(repoDir) || null;
|
|
355
|
+
const branchMode = kind === "project" ? "project-save" : options.stablePackageRelease === true && repoBranch === PRODUCTION_BRANCH ? "package-release-main" : "package-dev-save";
|
|
356
|
+
return {
|
|
357
|
+
id: relativePath,
|
|
358
|
+
name: repoDisplayName(repoDir, packageJson),
|
|
359
|
+
path: repoDir,
|
|
360
|
+
relativePath,
|
|
361
|
+
kind,
|
|
362
|
+
branch: repoBranch,
|
|
363
|
+
branchMode,
|
|
364
|
+
packageJsonPath: packageJson ? packageJsonPath : null,
|
|
365
|
+
packageJson,
|
|
366
|
+
scripts: packageScripts(packageJson),
|
|
367
|
+
remoteUrl: originRemoteUrlSafe(repoDir),
|
|
368
|
+
dependencies: [],
|
|
369
|
+
dependents: [],
|
|
370
|
+
submoduleDependencies: [],
|
|
371
|
+
plannedVersion: null,
|
|
372
|
+
plannedTag: null,
|
|
373
|
+
plannedDependencySpec: null
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
return deriveRepositoryGraph(root, nodes);
|
|
377
|
+
}
|
|
378
|
+
function deriveRepositoryGraph(root, nodes) {
|
|
379
|
+
const byPackageName = new Map(nodes.filter((node) => node.kind === "package").map((node) => [String(node.packageJson?.name), node]));
|
|
380
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
381
|
+
const dependencies = new Map(nodes.map((node) => [node.id, /* @__PURE__ */ new Set()]));
|
|
382
|
+
const dependents = new Map(nodes.map((node) => [node.id, /* @__PURE__ */ new Set()]));
|
|
383
|
+
for (const node of nodes) {
|
|
384
|
+
for (const field of dependencyFields(node.packageJson)) {
|
|
385
|
+
const values = node.packageJson?.[field];
|
|
386
|
+
for (const depName of Object.keys(values)) {
|
|
387
|
+
const dependency = byPackageName.get(depName);
|
|
388
|
+
if (!dependency || dependency.id === node.id) continue;
|
|
389
|
+
dependencies.get(node.id)?.add(dependency.id);
|
|
390
|
+
dependents.get(dependency.id)?.add(node.id);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const submodulePath of parseGitmodules(node.path)) {
|
|
394
|
+
const absolute = resolve(node.path, submodulePath);
|
|
395
|
+
const relativeToRoot = repoIdForPath(root, absolute);
|
|
396
|
+
const dependency = byId.get(relativeToRoot);
|
|
397
|
+
if (!dependency || dependency.id === node.id) continue;
|
|
398
|
+
dependencies.get(node.id)?.add(dependency.id);
|
|
399
|
+
dependents.get(dependency.id)?.add(node.id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return nodes.map((node) => ({
|
|
403
|
+
...node,
|
|
404
|
+
dependencies: [...dependencies.get(node.id) ?? []].sort(),
|
|
405
|
+
dependents: [...dependents.get(node.id) ?? []].sort(),
|
|
406
|
+
submoduleDependencies: [...dependencies.get(node.id) ?? []].filter((id) => node.id === "." || id.startsWith(`${node.id}/`)).sort()
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
function repositorySaveWaves(nodes) {
|
|
410
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
411
|
+
const dependencies = new Map(nodes.map((node) => [node.id, new Set(node.dependencies.filter((id) => nodeIds.has(id)))]));
|
|
412
|
+
const dependents = new Map(nodes.map((node) => [node.id, new Set(node.dependents.filter((id) => nodeIds.has(id)))]));
|
|
413
|
+
const ready = [...nodes].filter((node) => (dependencies.get(node.id)?.size ?? 0) === 0).sort(compareNodes);
|
|
414
|
+
const waves = [];
|
|
415
|
+
const processed = /* @__PURE__ */ new Set();
|
|
416
|
+
while (ready.length > 0) {
|
|
417
|
+
const wave = ready.splice(0).filter((node) => !processed.has(node.id));
|
|
418
|
+
if (wave.length === 0) continue;
|
|
419
|
+
waves.push(wave);
|
|
420
|
+
for (const node of wave) {
|
|
421
|
+
processed.add(node.id);
|
|
422
|
+
for (const dependentId of dependents.get(node.id) ?? []) {
|
|
423
|
+
const remaining = dependencies.get(dependentId);
|
|
424
|
+
remaining?.delete(node.id);
|
|
425
|
+
if (remaining && remaining.size === 0 && !processed.has(dependentId)) {
|
|
426
|
+
const dependent = nodes.find((candidate) => candidate.id === dependentId);
|
|
427
|
+
if (dependent) ready.push(dependent);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
ready.sort(compareNodes);
|
|
432
|
+
}
|
|
433
|
+
if (processed.size !== nodes.length) {
|
|
434
|
+
const unresolved = nodes.filter((node) => !processed.has(node.id)).map((node) => `${node.name} depends on ${(dependencies.get(node.id) ? [...dependencies.get(node.id)] : []).join(", ")}`);
|
|
435
|
+
throw new RepositorySaveError(`Repository dependency cycle detected:
|
|
436
|
+
${unresolved.join("\n")}`, {
|
|
437
|
+
details: { unresolved }
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return waves;
|
|
441
|
+
}
|
|
442
|
+
function compareNodes(left, right) {
|
|
443
|
+
if (left.id === ".") return 1;
|
|
444
|
+
if (right.id === ".") return -1;
|
|
445
|
+
const sorted = sortWorkspacePackages([
|
|
446
|
+
{ name: left.name, relativeDir: left.relativePath, dir: left.path, packageJson: left.packageJson ?? {} },
|
|
447
|
+
{ name: right.name, relativeDir: right.relativePath, dir: right.path, packageJson: right.packageJson ?? {} }
|
|
448
|
+
]);
|
|
449
|
+
return sorted[0]?.name === left.name ? -1 : 1;
|
|
450
|
+
}
|
|
451
|
+
function runLimited(items, limit, action) {
|
|
452
|
+
let index = 0;
|
|
453
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
454
|
+
while (index < items.length) {
|
|
455
|
+
const current = items[index++];
|
|
456
|
+
await action(current);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
return Promise.all(workers);
|
|
460
|
+
}
|
|
461
|
+
function remoteBranchExistsSafe(repoDir, branch) {
|
|
462
|
+
try {
|
|
463
|
+
run("git", ["rev-parse", "--verify", `refs/remotes/origin/${branch}`], { cwd: repoDir, capture: true });
|
|
464
|
+
return true;
|
|
465
|
+
} catch {
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
return remoteBranchExists(repoDir, branch);
|
|
469
|
+
} catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function checkoutCommandFor(repoDir, branch) {
|
|
474
|
+
if (currentBranch(repoDir) === branch) return `git checkout ${branch} # already current`;
|
|
475
|
+
if (branchExists(repoDir, branch)) return `git checkout ${branch}`;
|
|
476
|
+
if (remoteBranchExistsSafe(repoDir, branch)) return `git checkout -b ${branch} origin/${branch}`;
|
|
477
|
+
return `git checkout -b ${branch}`;
|
|
478
|
+
}
|
|
479
|
+
function checkoutOrCreateBranch(node, options, branch) {
|
|
480
|
+
if (currentBranch(node.path) === branch) {
|
|
481
|
+
emitProgress(options, node, "branch", `Already on ${branch}.`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (branchExists(node.path, branch)) {
|
|
485
|
+
runCapturedCommand(node, options, "branch", "git", ["checkout", branch]);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (remoteBranchExistsSafe(node.path, branch)) {
|
|
489
|
+
runCapturedCommand(node, options, "branch", "git", ["checkout", "-b", branch, `origin/${branch}`]);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
runCapturedCommand(node, options, "branch", "git", ["checkout", "-b", branch]);
|
|
493
|
+
}
|
|
494
|
+
async function commitMessageFor(node, options, context) {
|
|
495
|
+
return generateRepositoryCommitMessage({
|
|
496
|
+
repoName: node.name,
|
|
497
|
+
repoPath: node.path,
|
|
498
|
+
branch: node.branch || options.branch,
|
|
499
|
+
kind: node.kind,
|
|
500
|
+
branchMode: node.branchMode,
|
|
501
|
+
userMessage: options.message?.trim() || void 0,
|
|
502
|
+
...context
|
|
503
|
+
}, {
|
|
504
|
+
mode: options.commitMessageMode ?? "auto",
|
|
505
|
+
provider: options.commitMessageProvider
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
function gitDiffSummary(repoDir) {
|
|
509
|
+
const changedFiles = run("git", ["status", "--porcelain"], { cwd: repoDir, capture: true });
|
|
510
|
+
const diff = run("git", ["diff", "--cached"], { cwd: repoDir, capture: true });
|
|
511
|
+
return { changedFiles, diff };
|
|
512
|
+
}
|
|
513
|
+
function hasStagedChanges(repoDir) {
|
|
514
|
+
try {
|
|
515
|
+
return run("git", ["diff", "--cached", "--name-only"], { cwd: repoDir, capture: true }).trim().length > 0;
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function updateDependencyReferences(node, finalizedReferences) {
|
|
521
|
+
if (!node.packageJson || !node.packageJsonPath) return [];
|
|
522
|
+
const changed = updateInternalDependencySpecs(node.packageJson, finalizedReferences);
|
|
523
|
+
if (changed.length > 0) {
|
|
524
|
+
writeJson(node.packageJsonPath, node.packageJson);
|
|
525
|
+
}
|
|
526
|
+
return changed;
|
|
527
|
+
}
|
|
528
|
+
function planPackageVersion(node, options) {
|
|
529
|
+
if (!node.packageJson || !node.packageJsonPath) return null;
|
|
530
|
+
const current = String(node.packageJson.version ?? "0.0.0");
|
|
531
|
+
return node.branchMode === "package-release-main" ? incrementVersion(current, options.bump ?? "patch") : nextDevVersion(current, options.branch);
|
|
532
|
+
}
|
|
533
|
+
function applyPackageVersion(node, version) {
|
|
534
|
+
if (!node.packageJson || !node.packageJsonPath) return false;
|
|
535
|
+
if (node.packageJson.version === version) return false;
|
|
536
|
+
node.packageJson.version = version;
|
|
537
|
+
writeJson(node.packageJsonPath, node.packageJson);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
function shouldSkipNetworkInstall() {
|
|
541
|
+
return getGitHubAutomationMode() === "stub" || process.env.TREESEED_SAVE_NPM_INSTALL_MODE === "skip";
|
|
542
|
+
}
|
|
543
|
+
function shouldSkipGitDependencySmoke() {
|
|
544
|
+
return shouldSkipNetworkInstall() || process.env.TREESEED_GIT_DEPENDENCY_SMOKE === "skip";
|
|
545
|
+
}
|
|
546
|
+
function hasNpmLockfile(repoDir) {
|
|
547
|
+
return existsSync(resolve(repoDir, "package-lock.json")) || existsSync(resolve(repoDir, "npm-shrinkwrap.json"));
|
|
548
|
+
}
|
|
549
|
+
async function runGitDependencySmoke(node, options, reference) {
|
|
550
|
+
if (reference.mode !== "dev-git-tag" || shouldSkipGitDependencySmoke()) return;
|
|
551
|
+
const installSpec = reference.installSpec ?? reference.spec;
|
|
552
|
+
const tempRoot = mkdtempSync(resolve(tmpdir(), "treeseed-git-dep-smoke-"));
|
|
553
|
+
const npmCacheRoot = resolve(tempRoot, ".npm-cache");
|
|
554
|
+
try {
|
|
555
|
+
emitProgress(options, node, "smoke", `Installing ${installSpec} in a temporary project.`);
|
|
556
|
+
writeFileSync(resolve(tempRoot, "package.json"), JSON.stringify({
|
|
557
|
+
name: "treeseed-git-dependency-smoke",
|
|
558
|
+
version: "0.0.0",
|
|
559
|
+
private: true,
|
|
560
|
+
type: "module",
|
|
561
|
+
dependencies: {
|
|
562
|
+
[reference.packageName]: installSpec
|
|
563
|
+
}
|
|
564
|
+
}, null, 2), "utf8");
|
|
565
|
+
let lastError = null;
|
|
566
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
567
|
+
emitProgress(options, node, "smoke", `npm install --cache ${npmCacheRoot} attempt ${attempt}/5.`);
|
|
568
|
+
try {
|
|
569
|
+
await runStreamingCommand(node, options, "smoke", "npm", ["install", "--cache", npmCacheRoot], { cwd: tempRoot });
|
|
570
|
+
return;
|
|
571
|
+
} catch (error) {
|
|
572
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
573
|
+
}
|
|
574
|
+
if (attempt < 5) {
|
|
575
|
+
emitProgress(options, node, "smoke", "npm install failed; retrying in 60 seconds.", "stderr");
|
|
576
|
+
spawnSync("sleep", ["60"], { stdio: "ignore" });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
throw new RepositorySaveError([
|
|
580
|
+
`Git dependency smoke install failed for ${reference.packageName} after 5 attempts.`,
|
|
581
|
+
`Spec: ${installSpec}`,
|
|
582
|
+
lastError ?? ""
|
|
583
|
+
].join("\n"));
|
|
584
|
+
} finally {
|
|
585
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function runNpmInstallWithRetry(node, options, gitDependencyRefreshSpecs = []) {
|
|
589
|
+
if (shouldSkipNetworkInstall()) {
|
|
590
|
+
emitProgress(options, node, "install", "Skipped npm install because network install mode is disabled.");
|
|
591
|
+
return { status: "skipped", attempts: 0, reason: "stubbed" };
|
|
592
|
+
}
|
|
593
|
+
let lastError = null;
|
|
594
|
+
const packageJson = node.packageJson ?? (existsSync(resolve(node.path, "package.json")) ? readJson(resolve(node.path, "package.json")) : null);
|
|
595
|
+
const rootWorkspaceInstall = node.path === options.root && Array.isArray(packageJson?.workspaces);
|
|
596
|
+
const args = rootWorkspaceInstall ? gitDependencyRefreshSpecs.length > 0 ? ["install", ...gitDependencyRefreshSpecs, "--force"] : ["install"] : gitDependencyRefreshSpecs.length > 0 ? ["install", ...gitDependencyRefreshSpecs, "--force", "--workspaces=false"] : ["install", "--workspaces=false"];
|
|
597
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
598
|
+
emitProgress(options, node, "install", `npm ${args.join(" ")} attempt ${attempt}/5.`);
|
|
599
|
+
try {
|
|
600
|
+
await runStreamingCommand(node, options, "install", "npm", args);
|
|
601
|
+
return { status: "completed", attempts: attempt, reason: null };
|
|
602
|
+
} catch (error) {
|
|
603
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
604
|
+
}
|
|
605
|
+
if (attempt < 5) {
|
|
606
|
+
emitProgress(options, node, "install", "npm install failed; retrying in 60 seconds.", "stderr");
|
|
607
|
+
spawnSync("sleep", ["60"], { stdio: "ignore" });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
throw new RepositorySaveError(`npm install failed after 5 attempts.
|
|
611
|
+
${lastError ?? ""}`);
|
|
612
|
+
}
|
|
613
|
+
function lockfileValidationCommand(node, options) {
|
|
614
|
+
const packageJson = node.packageJson ?? (existsSync(resolve(node.path, "package.json")) ? readJson(resolve(node.path, "package.json")) : null);
|
|
615
|
+
const rootWorkspaceInstall = node.path === options.root && Array.isArray(packageJson?.workspaces);
|
|
616
|
+
const args = rootWorkspaceInstall ? ["ci", "--ignore-scripts", "--dry-run"] : ["ci", "--ignore-scripts", "--dry-run", "--workspaces=false"];
|
|
617
|
+
return { command: "npm", args };
|
|
618
|
+
}
|
|
619
|
+
async function validateRepositoryLockfile(node, options) {
|
|
620
|
+
if (!hasNpmLockfile(node.path)) {
|
|
621
|
+
return { status: "skipped", command: null, issues: [], error: "no npm lockfile" };
|
|
622
|
+
}
|
|
623
|
+
const issues = collectDeploymentLockfileWorkspaceIssues(node.path).map((issue) => `${issue.filePath}: ${issue.packageName} ${issue.reason}`);
|
|
624
|
+
if (issues.length > 0) {
|
|
625
|
+
throw new RepositorySaveError([
|
|
626
|
+
`Lockfile validation failed for ${node.name}.`,
|
|
627
|
+
...issues
|
|
628
|
+
].join("\n"), {
|
|
629
|
+
details: {
|
|
630
|
+
failingRepo: node.name,
|
|
631
|
+
phase: "lockfile",
|
|
632
|
+
issues
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const { command, args } = lockfileValidationCommand(node, options);
|
|
637
|
+
const commandText = `${command} ${args.join(" ")}`;
|
|
638
|
+
if (shouldSkipNetworkInstall()) {
|
|
639
|
+
emitProgress(options, node, "lockfile", `Skipped ${commandText} because network install mode is disabled.`);
|
|
640
|
+
return { status: "skipped", command: commandText, issues: [], error: "stubbed" };
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
runCapturedCommand(node, options, "lockfile", command, args, { timeoutMs: 12e4 });
|
|
644
|
+
return { status: "passed", command: commandText, issues: [], error: null };
|
|
645
|
+
} catch (error) {
|
|
646
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
647
|
+
const result = { status: "failed", command: commandText, issues: [message], error: message };
|
|
648
|
+
throw new RepositorySaveError([
|
|
649
|
+
`Lockfile validation failed for ${node.name}.`,
|
|
650
|
+
`Command: ${commandText}`,
|
|
651
|
+
message
|
|
652
|
+
].join("\n"), {
|
|
653
|
+
details: {
|
|
654
|
+
failingRepo: node.name,
|
|
655
|
+
phase: "lockfile",
|
|
656
|
+
command: commandText,
|
|
657
|
+
issues: result.issues
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function hasScript(node, scriptName) {
|
|
663
|
+
return typeof node.scripts[scriptName] === "string" && node.scripts[scriptName].length > 0;
|
|
664
|
+
}
|
|
665
|
+
async function runScript(node, options, scriptName) {
|
|
666
|
+
await runStreamingCommand(node, options, "verify", "npm", ["run", scriptName]);
|
|
667
|
+
}
|
|
668
|
+
async function runRepoVerification(node, options, verifyMode) {
|
|
669
|
+
if (verifyMode === "skip" || getGitHubAutomationMode() === "stub") {
|
|
670
|
+
emitProgress(options, node, "verify", getGitHubAutomationMode() === "stub" ? "Skipped verification in stub automation mode." : "Skipped verification by request.");
|
|
671
|
+
return { mode: verifyMode, status: "skipped", primary: null, fallbackUsed: false, error: getGitHubAutomationMode() === "stub" ? "stubbed" : null };
|
|
672
|
+
}
|
|
673
|
+
if (node.kind !== "package") {
|
|
674
|
+
emitProgress(options, node, "verify", "Skipped package verification for project repository.");
|
|
675
|
+
return { mode: verifyMode, status: "skipped", primary: null, fallbackUsed: false, error: null };
|
|
676
|
+
}
|
|
677
|
+
if (verifyMode === "local-only") {
|
|
678
|
+
if (!hasScript(node, "verify:local")) {
|
|
679
|
+
throw new RepositorySaveError(`Package ${node.name} is missing required verify:local script.`);
|
|
680
|
+
}
|
|
681
|
+
await runScript(node, options, "verify:local");
|
|
682
|
+
return { mode: verifyMode, status: "passed", primary: "verify:local", fallbackUsed: false, error: null };
|
|
683
|
+
}
|
|
684
|
+
if (!hasScript(node, "verify:action") && !hasScript(node, "verify:local")) {
|
|
685
|
+
throw new RepositorySaveError(`Package ${node.name} is missing required verify:action or verify:local script.`);
|
|
686
|
+
}
|
|
687
|
+
if (hasScript(node, "verify:action")) {
|
|
688
|
+
try {
|
|
689
|
+
await runScript(node, options, "verify:action");
|
|
690
|
+
return { mode: verifyMode, status: "passed", primary: "verify:action", fallbackUsed: false, error: null };
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (!hasScript(node, "verify:local")) {
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
emitProgress(options, node, "verify", "verify:action failed; falling back to verify:local.", "stderr");
|
|
696
|
+
await runScript(node, options, "verify:local");
|
|
697
|
+
return {
|
|
698
|
+
mode: verifyMode,
|
|
699
|
+
status: "passed",
|
|
700
|
+
primary: "verify:action",
|
|
701
|
+
fallbackUsed: true,
|
|
702
|
+
error: error instanceof Error ? error.message : String(error)
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
await runScript(node, options, "verify:local");
|
|
707
|
+
return { mode: verifyMode, status: "passed", primary: "verify:local", fallbackUsed: true, error: null };
|
|
708
|
+
}
|
|
709
|
+
function pullRebaseFromOrigin(node, options, branch) {
|
|
710
|
+
if (!remoteBranchExistsSafe(node.path, branch)) {
|
|
711
|
+
emitProgress(options, node, "rebase", `Skipped pull --rebase because origin/${branch} does not exist.`);
|
|
712
|
+
return {
|
|
713
|
+
remoteBranchExisted: false,
|
|
714
|
+
pulledRebase: false
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
runCapturedCommand(node, options, "rebase", "git", ["pull", "--rebase", "--recurse-submodules=no", "origin", branch]);
|
|
719
|
+
return {
|
|
720
|
+
remoteBranchExisted: true,
|
|
721
|
+
pulledRebase: true
|
|
722
|
+
};
|
|
723
|
+
} catch (error) {
|
|
724
|
+
const report = collectMergeConflictReport(node.path);
|
|
725
|
+
throw new RepositorySaveError(formatMergeConflictReport(report, node.path, branch), {
|
|
726
|
+
exitCode: 12,
|
|
727
|
+
details: { branch, report, originalError: error instanceof Error ? error.message : String(error) }
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function pushCurrentBranch(node, options, branch, tagName) {
|
|
732
|
+
ensureWritableRemote(node, options);
|
|
733
|
+
const remoteBranchExists2 = remoteBranchExistsSafe(node.path, branch);
|
|
734
|
+
let pushedTag = false;
|
|
735
|
+
const args = remoteBranchExists2 ? ["push", "origin", branch] : ["push", "-u", "origin", branch];
|
|
736
|
+
if (tagName) {
|
|
737
|
+
const state = tagState(node.path, tagName);
|
|
738
|
+
assertTagStateMatchesHead(node, tagName, state, headCommit(node.path));
|
|
739
|
+
if (!state.remoteExists) {
|
|
740
|
+
args.push(tagName);
|
|
741
|
+
pushedTag = true;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
runCapturedCommand(node, options, "push", "git", args);
|
|
745
|
+
return {
|
|
746
|
+
createdRemoteBranch: !remoteBranchExists2,
|
|
747
|
+
pushed: true,
|
|
748
|
+
pushedTag,
|
|
749
|
+
combinedBranchAndTagPush: pushedTag
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function tagExists(repoDir, tagName) {
|
|
753
|
+
try {
|
|
754
|
+
run("git", ["rev-parse", "--verify", `refs/tags/${tagName}`], { cwd: repoDir, capture: true });
|
|
755
|
+
return true;
|
|
756
|
+
} catch {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function localTagCommit(repoDir, tagName) {
|
|
761
|
+
try {
|
|
762
|
+
return run("git", ["rev-list", "-n", "1", tagName], { cwd: repoDir, capture: true }).trim();
|
|
763
|
+
} catch {
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
function remoteTagCommit(repoDir, tagName) {
|
|
768
|
+
try {
|
|
769
|
+
const output = run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tagName}*`], { cwd: repoDir, capture: true });
|
|
770
|
+
const lines = output.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
771
|
+
const dereferenced = lines.find((line) => line.endsWith(`refs/tags/${tagName}^{}`));
|
|
772
|
+
const exact = lines.find((line) => line.endsWith(`refs/tags/${tagName}`));
|
|
773
|
+
const selected = dereferenced ?? exact;
|
|
774
|
+
return selected ? selected.split(/\s+/u)[0] ?? null : null;
|
|
775
|
+
} catch {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function localTagMessage(repoDir, tagName) {
|
|
780
|
+
try {
|
|
781
|
+
return run("git", ["tag", "-l", tagName, "--format=%(contents)"], { cwd: repoDir, capture: true }).trim();
|
|
782
|
+
} catch {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function tagState(repoDir, tagName) {
|
|
787
|
+
const localCommit = localTagCommit(repoDir, tagName);
|
|
788
|
+
const remoteCommit = remoteTagCommit(repoDir, tagName);
|
|
789
|
+
return {
|
|
790
|
+
tagName,
|
|
791
|
+
localExists: localCommit != null,
|
|
792
|
+
localCommit,
|
|
793
|
+
remoteExists: remoteCommit != null,
|
|
794
|
+
remoteCommit
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function assertTagStateMatchesHead(node, tagName, state, head) {
|
|
798
|
+
if (state.localCommit && state.localCommit !== head) {
|
|
799
|
+
throw new RepositorySaveError(`Package ${node.name} tag ${tagName} points to ${state.localCommit.slice(0, 12)}, but ${node.name} HEAD is ${head.slice(0, 12)}. Refusing to move an existing tag.`, {
|
|
800
|
+
details: {
|
|
801
|
+
failingRepo: node.name,
|
|
802
|
+
phase: "tag",
|
|
803
|
+
currentVersion: tagName,
|
|
804
|
+
expectedTag: tagName,
|
|
805
|
+
tagState: state
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
if (state.remoteCommit && state.remoteCommit !== head) {
|
|
810
|
+
throw new RepositorySaveError(`Remote tag ${tagName} for ${node.name} points to ${state.remoteCommit.slice(0, 12)}, but ${node.name} HEAD is ${head.slice(0, 12)}. Refusing to move an existing tag.`, {
|
|
811
|
+
details: {
|
|
812
|
+
failingRepo: node.name,
|
|
813
|
+
phase: "tag",
|
|
814
|
+
currentVersion: tagName,
|
|
815
|
+
expectedTag: tagName,
|
|
816
|
+
tagState: state
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function createPackageTagMessage(node, tagName, branch, workflowRunId) {
|
|
822
|
+
return tagName.includes("-dev.") ? createDevTagMessage({
|
|
823
|
+
packageName: node.name,
|
|
824
|
+
version: tagName,
|
|
825
|
+
branch,
|
|
826
|
+
commitSha: headCommit(node.path),
|
|
827
|
+
workflowRunId
|
|
828
|
+
}) : `release: ${tagName}`;
|
|
829
|
+
}
|
|
830
|
+
function ensureRemoteAccessBeforeVerification(node, options, state) {
|
|
831
|
+
if (shouldSkipRemoteAccessPreflight()) return;
|
|
832
|
+
if (state.remoteAccessChecked.has(node.path)) return;
|
|
833
|
+
ensureWritableRemote(node, options);
|
|
834
|
+
const writeUrl = remoteWriteUrl(node.path) ?? "origin";
|
|
835
|
+
emitProgress(options, node, "preflight", `Checking write remote access before verification (${writeUrl}).`);
|
|
836
|
+
try {
|
|
837
|
+
runQuietCommand(node, "preflight", "git", ["ls-remote", "--heads", writeUrl], { timeoutMs: 3e4 });
|
|
838
|
+
state.remoteAccessChecked.add(node.path);
|
|
839
|
+
} catch (error) {
|
|
840
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
841
|
+
throw new RepositorySaveError([
|
|
842
|
+
`Cannot access origin remote for ${node.name}; save would fail after verification when pushing branch or tags.`,
|
|
843
|
+
"Fix Git authentication, then rerun `npx trsd save` to resume.",
|
|
844
|
+
detail
|
|
845
|
+
].join("\n"), {
|
|
846
|
+
exitCode: 13,
|
|
847
|
+
details: {
|
|
848
|
+
failingRepo: node.name,
|
|
849
|
+
phase: "preflight",
|
|
850
|
+
originalError: detail
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function shouldSkipRemoteAccessPreflight() {
|
|
856
|
+
return getGitHubAutomationMode() === "stub" || process.env.TREESEED_SAVE_REMOTE_PREFLIGHT === "skip";
|
|
857
|
+
}
|
|
858
|
+
function localTreeseedTagWasCreatedByThisRun(node, tagName, workflowRunId) {
|
|
859
|
+
const message = localTagMessage(node.path, tagName);
|
|
860
|
+
if (!message?.includes("treeseed-dev-tag: true")) return false;
|
|
861
|
+
if (!workflowRunId) return true;
|
|
862
|
+
return message.includes(`workflowRunId: ${workflowRunId}`);
|
|
863
|
+
}
|
|
864
|
+
function ensurePackageTagReady(node, options, tagName, branch, workflowRunId) {
|
|
865
|
+
let message = null;
|
|
866
|
+
ensureWritableRemote(node, options);
|
|
867
|
+
const head = headCommit(node.path);
|
|
868
|
+
let state = tagState(node.path, tagName);
|
|
869
|
+
assertTagStateMatchesHead(node, tagName, state, head);
|
|
870
|
+
if (!state.localExists && state.remoteExists && state.remoteCommit === head) {
|
|
871
|
+
runCapturedCommand(node, options, "tag", "git", ["fetch", "origin", `refs/tags/${tagName}:refs/tags/${tagName}`]);
|
|
872
|
+
state = tagState(node.path, tagName);
|
|
873
|
+
assertTagStateMatchesHead(node, tagName, state, head);
|
|
874
|
+
}
|
|
875
|
+
if (!state.localExists) {
|
|
876
|
+
message = createPackageTagMessage(node, tagName, branch, workflowRunId);
|
|
877
|
+
runCapturedCommand(node, options, "tag", "git", ["tag", "-a", tagName, "-m", message]);
|
|
878
|
+
state = tagState(node.path, tagName);
|
|
879
|
+
assertTagStateMatchesHead(node, tagName, state, head);
|
|
880
|
+
}
|
|
881
|
+
if (state.remoteExists) {
|
|
882
|
+
emitProgress(options, node, "tag", `Remote tag ${tagName} already points at HEAD.`);
|
|
883
|
+
}
|
|
884
|
+
return message;
|
|
885
|
+
}
|
|
886
|
+
function refreshSubmodulePointers(node, finalizedCommits) {
|
|
887
|
+
let changed = false;
|
|
888
|
+
for (const [repoName] of finalizedCommits.entries()) {
|
|
889
|
+
const childRelativePath = node.id === "." ? repoName : repoName.startsWith(`${node.id}/`) ? repoName.slice(node.id.length + 1) : null;
|
|
890
|
+
if (!childRelativePath) continue;
|
|
891
|
+
const childPath = resolve(node.path, childRelativePath);
|
|
892
|
+
if (!existsSync(childPath) || !isGitRepo(childPath)) continue;
|
|
893
|
+
const status = run("git", ["status", "--porcelain", "--", childRelativePath], { cwd: node.path, capture: true });
|
|
894
|
+
if (status.trim().length > 0) {
|
|
895
|
+
changed = true;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return changed;
|
|
899
|
+
}
|
|
900
|
+
function syncBranchBeforeSave(node, options, branch) {
|
|
901
|
+
checkoutOrCreateBranch(node, options, branch);
|
|
902
|
+
}
|
|
903
|
+
function refreshRepositoryNodePackageMetadata(node) {
|
|
904
|
+
const packageJsonPath = resolve(node.path, "package.json");
|
|
905
|
+
const packageJson = existsSync(packageJsonPath) ? readJson(packageJsonPath) : null;
|
|
906
|
+
node.packageJsonPath = packageJson ? packageJsonPath : null;
|
|
907
|
+
node.packageJson = packageJson;
|
|
908
|
+
node.scripts = packageScripts(packageJson);
|
|
909
|
+
node.remoteUrl = originRemoteUrlSafe(node.path);
|
|
910
|
+
if (node.kind === "package") {
|
|
911
|
+
node.name = repoDisplayName(node.path, packageJson);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
function finalizePackageReference(node, version, options) {
|
|
915
|
+
const reference = createPackageDependencyReference({
|
|
916
|
+
packageName: node.name,
|
|
917
|
+
version,
|
|
918
|
+
branchMode: node.branchMode === "package-release-main" ? "package-release-main" : "package-dev-save",
|
|
919
|
+
remoteUrl: node.remoteUrl,
|
|
920
|
+
devDependencyReferenceMode: options.devDependencyReferenceMode ?? "git-tag",
|
|
921
|
+
gitDependencyProtocol: options.gitDependencyProtocol ?? "preserve-origin"
|
|
922
|
+
});
|
|
923
|
+
node.plannedDependencySpec = reference.spec;
|
|
924
|
+
return reference;
|
|
925
|
+
}
|
|
926
|
+
async function finalizeCleanPackageVersion(node, options, state, report, branch) {
|
|
927
|
+
const version = typeof node.packageJson?.version === "string" ? node.packageJson.version : null;
|
|
928
|
+
if (!version || !packageVersionEligibleForBranch(node, version, options)) {
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
const head = headCommit(node.path);
|
|
932
|
+
const currentTagState = tagState(node.path, version);
|
|
933
|
+
assertTagStateMatchesHead(node, version, currentTagState, head);
|
|
934
|
+
const remoteBranchExists2 = remoteBranchExistsSafe(node.path, branch);
|
|
935
|
+
const finalizedRemotely = currentTagState.localCommit === head && currentTagState.remoteCommit === head && remoteBranchExists2;
|
|
936
|
+
report.version = version;
|
|
937
|
+
report.tagName = version;
|
|
938
|
+
report.commitSha = head;
|
|
939
|
+
report.dependencySpec = finalizePackageReference(node, version, options).spec;
|
|
940
|
+
state.finalizedVersions.set(node.name, version);
|
|
941
|
+
state.finalizedReferences.set(node.name, finalizePackageReference(node, version, options));
|
|
942
|
+
state.finalizedCommits.set(node.relativePath, head);
|
|
943
|
+
if (finalizedRemotely) {
|
|
944
|
+
report.pushed = true;
|
|
945
|
+
report.skippedReason = "already-finalized";
|
|
946
|
+
report.publishWait = { recoveredPartialSave: true, remoteBranchExisted: true, tagAlreadyPushed: true };
|
|
947
|
+
emitProgress(options, node, "finalize", `Using existing finalized package version ${version}.`);
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
emitProgress(options, node, "finalize", `Finalizing interrupted package version ${version}.`);
|
|
951
|
+
if (hasNpmLockfile(node.path)) {
|
|
952
|
+
report.install = await runNpmInstallWithRetry(node, options);
|
|
953
|
+
report.lockfileValidation = await validateRepositoryLockfile(node, options);
|
|
954
|
+
}
|
|
955
|
+
const rebase = pullRebaseFromOrigin(node, options, branch);
|
|
956
|
+
if (currentTagState.localCommit === head && localTreeseedTagWasCreatedByThisRun(node, version, options.workflowRunId)) {
|
|
957
|
+
emitProgress(options, node, "verify", `Reusing verification from interrupted tag ${version}.`);
|
|
958
|
+
report.verification = {
|
|
959
|
+
mode: options.verifyMode ?? "action-first",
|
|
960
|
+
status: "skipped",
|
|
961
|
+
primary: null,
|
|
962
|
+
fallbackUsed: false,
|
|
963
|
+
error: "verified-before-interruption"
|
|
964
|
+
};
|
|
965
|
+
report.verified = true;
|
|
966
|
+
} else {
|
|
967
|
+
ensureRemoteAccessBeforeVerification(node, options, state);
|
|
968
|
+
report.verification = await runRepoVerification(node, options, options.verifyMode ?? "action-first");
|
|
969
|
+
report.verified = report.verification.status === "passed";
|
|
970
|
+
}
|
|
971
|
+
const tagMessage = ensurePackageTagReady(node, options, version, branch, options.workflowRunId);
|
|
972
|
+
report.devTagMetadata = tagMessage?.includes("treeseed-dev-tag: true") ? tagMessage : null;
|
|
973
|
+
const reference = finalizePackageReference(node, version, options);
|
|
974
|
+
const push = pushCurrentBranch(node, options, branch, version);
|
|
975
|
+
await runGitDependencySmoke(node, options, reference);
|
|
976
|
+
report.dependencySpec = reference.spec;
|
|
977
|
+
report.pushed = push.pushed;
|
|
978
|
+
report.skippedReason = "finalized-partial-save";
|
|
979
|
+
report.commitSha = headCommit(node.path);
|
|
980
|
+
state.finalizedCommits.set(node.relativePath, report.commitSha);
|
|
981
|
+
report.publishWait = {
|
|
982
|
+
...rebase,
|
|
983
|
+
...push,
|
|
984
|
+
recoveredPartialSave: true
|
|
985
|
+
};
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
function branchModeLabel(branchMode) {
|
|
989
|
+
switch (branchMode) {
|
|
990
|
+
case "package-release-main":
|
|
991
|
+
return "stable package release";
|
|
992
|
+
case "package-dev-save":
|
|
993
|
+
return "dev package save";
|
|
994
|
+
case "project-save":
|
|
995
|
+
return "project save";
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function repoPlanCommands(node, options, plannedVersion, plannedDependencySpec, dependencyUpdates) {
|
|
999
|
+
const branch = node.branch || options.branch;
|
|
1000
|
+
const remoteExists = remoteBranchExistsSafe(node.path, branch);
|
|
1001
|
+
const commands = [
|
|
1002
|
+
checkoutCommandFor(node.path, branch)
|
|
1003
|
+
];
|
|
1004
|
+
const sshPushUrl = (options.gitRemoteWriteMode ?? "ssh-pushurl") === "off" ? null : sshPushUrlForRemote(node.remoteUrl);
|
|
1005
|
+
if (sshPushUrl) {
|
|
1006
|
+
commands.push(`git remote set-url --push origin ${sshPushUrl} # keep ${node.remoteUrl} for reads`);
|
|
1007
|
+
}
|
|
1008
|
+
if (dependencyUpdates.length > 0) {
|
|
1009
|
+
commands.push(`update package.json internal dependencies: ${dependencyUpdates.join(", ")}`);
|
|
1010
|
+
}
|
|
1011
|
+
if (node.submoduleDependencies.length > 0) {
|
|
1012
|
+
commands.push(`refresh submodule pointers: ${node.submoduleDependencies.join(", ")}`);
|
|
1013
|
+
}
|
|
1014
|
+
const packageJson = node.packageJson ?? (existsSync(resolve(node.path, "package.json")) ? readJson(resolve(node.path, "package.json")) : null);
|
|
1015
|
+
const rootWorkspaceInstall = node.path === options.root && Array.isArray(packageJson?.workspaces);
|
|
1016
|
+
if (node.kind === "package" && plannedVersion) {
|
|
1017
|
+
commands.push(`update package.json version to ${plannedVersion}`);
|
|
1018
|
+
commands.push("npm install --workspaces=false # explicitly refresh changed git-tag dependencies with --force; retry up to 5 times with 60s delay");
|
|
1019
|
+
} else if (node.kind === "project" && dependencyUpdates.length > 0 && hasNpmLockfile(node.path)) {
|
|
1020
|
+
commands.push(rootWorkspaceInstall ? "npm install # refresh root workspace lockfile against the real checked-in manifest" : "npm install --workspaces=false # refresh project lockfile after internal dependency updates");
|
|
1021
|
+
}
|
|
1022
|
+
if (hasNpmLockfile(node.path) && (node.kind === "project" || plannedVersion || dependencyUpdates.length > 0 || node.submoduleDependencies.length > 0)) {
|
|
1023
|
+
commands.push(rootWorkspaceInstall ? "npm ci --ignore-scripts --dry-run # validate root manifest, workspaces, and lockfile before commit" : "npm ci --ignore-scripts --dry-run --workspaces=false # validate deployment lockfile before commit");
|
|
1024
|
+
}
|
|
1025
|
+
commands.push("git add -A");
|
|
1026
|
+
commands.push("generate commit message # Cloudflare AI when configured, fallback otherwise");
|
|
1027
|
+
commands.push("git commit -m <generated-message>");
|
|
1028
|
+
commands.push(
|
|
1029
|
+
remoteExists ? `git pull --rebase --recurse-submodules=no origin ${branch}` : `skip pull --rebase # origin/${branch} does not exist yet`
|
|
1030
|
+
);
|
|
1031
|
+
if (node.kind === "package") {
|
|
1032
|
+
const verifyMode = options.verifyMode ?? "action-first";
|
|
1033
|
+
if (verifyMode === "skip") {
|
|
1034
|
+
commands.push("skip package verification");
|
|
1035
|
+
} else if (verifyMode === "local-only") {
|
|
1036
|
+
commands.push("npm run verify:local");
|
|
1037
|
+
} else {
|
|
1038
|
+
commands.push("npm run verify:action # fallback to npm run verify:local on failure");
|
|
1039
|
+
}
|
|
1040
|
+
if (plannedVersion) {
|
|
1041
|
+
commands.push(`git tag -a ${plannedVersion} -m <${plannedVersion.includes("-dev.") ? "dev metadata" : "release"}>`);
|
|
1042
|
+
commands.push(remoteExists ? `git push origin ${branch} ${plannedVersion}` : `git push -u origin ${branch} ${plannedVersion}`);
|
|
1043
|
+
if (plannedDependencySpec && node.branchMode === "package-dev-save") {
|
|
1044
|
+
commands.push(`smoke install ${plannedDependencySpec}`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
} else {
|
|
1048
|
+
commands.push("skip package verification # project repository");
|
|
1049
|
+
commands.push(remoteExists ? `git push origin ${branch}` : `git push -u origin ${branch}`);
|
|
1050
|
+
}
|
|
1051
|
+
return commands;
|
|
1052
|
+
}
|
|
1053
|
+
function planRepositorySave(options) {
|
|
1054
|
+
const scope = options.branch === STAGING_BRANCH ? "staging" : options.branch === PRODUCTION_BRANCH ? "prod" : "local";
|
|
1055
|
+
const allNodes = discoverRepositorySaveNodes(options.root, options.gitRoot, options.branch, {
|
|
1056
|
+
stablePackageRelease: options.stablePackageRelease === true
|
|
1057
|
+
});
|
|
1058
|
+
const nodes = options.includeRoot === false ? allNodes.filter((node) => node.id !== ".") : allNodes;
|
|
1059
|
+
const mode = nodes.some((node) => node.id !== ".") ? "recursive-workspace" : "root-only";
|
|
1060
|
+
const waves = repositorySaveWaves(nodes);
|
|
1061
|
+
const plannedVersions = /* @__PURE__ */ new Map();
|
|
1062
|
+
const plannedReferences = /* @__PURE__ */ new Map();
|
|
1063
|
+
const plans = /* @__PURE__ */ new Map();
|
|
1064
|
+
for (const wave of waves) {
|
|
1065
|
+
for (const node of wave) {
|
|
1066
|
+
const dependencyUpdates = node.dependencies.map((id) => nodes.find((candidate) => candidate.id === id)).filter((candidate) => Boolean(candidate)).map((dependency) => {
|
|
1067
|
+
const reference = plannedReferences.get(dependency.name);
|
|
1068
|
+
return reference ? `${dependency.name} -> ${reference.spec}` : null;
|
|
1069
|
+
}).filter((value) => Boolean(value));
|
|
1070
|
+
const dependencyChanged = dependencyUpdates.length > 0;
|
|
1071
|
+
const submoduleChanged = node.submoduleDependencies.length > 0 && node.submoduleDependencies.some((id) => {
|
|
1072
|
+
const dependency = plans.get(id);
|
|
1073
|
+
return dependency?.dirty || Boolean(dependency?.plannedVersion);
|
|
1074
|
+
});
|
|
1075
|
+
const dirty = hasMeaningfulChanges(node.path);
|
|
1076
|
+
const packageNeedsVersion = node.kind === "package" && (dirty || dependencyChanged || submoduleChanged);
|
|
1077
|
+
const currentVersion = typeof node.packageJson?.version === "string" ? node.packageJson.version : null;
|
|
1078
|
+
const plannedVersion = packageNeedsVersion ? selectPackageVersion(node, options).version : null;
|
|
1079
|
+
let plannedDependencySpec = null;
|
|
1080
|
+
if (node.kind === "package" && plannedVersion) {
|
|
1081
|
+
const reference = createPackageDependencyReference({
|
|
1082
|
+
packageName: node.name,
|
|
1083
|
+
version: plannedVersion,
|
|
1084
|
+
branchMode: node.branchMode === "package-release-main" ? "package-release-main" : "package-dev-save",
|
|
1085
|
+
remoteUrl: node.remoteUrl,
|
|
1086
|
+
devDependencyReferenceMode: options.devDependencyReferenceMode ?? "git-tag",
|
|
1087
|
+
gitDependencyProtocol: options.gitDependencyProtocol ?? "preserve-origin"
|
|
1088
|
+
});
|
|
1089
|
+
plannedDependencySpec = reference.spec;
|
|
1090
|
+
plannedVersions.set(node.name, plannedVersion);
|
|
1091
|
+
plannedReferences.set(node.name, reference);
|
|
1092
|
+
}
|
|
1093
|
+
const current = currentBranch(node.path) || null;
|
|
1094
|
+
const branch = node.branch || options.branch;
|
|
1095
|
+
const notes = [
|
|
1096
|
+
`${branchModeLabel(node.branchMode)} on top-level ${options.branch}`,
|
|
1097
|
+
...current && current !== branch ? [`current branch ${current} will be switched to ${branch}`] : [],
|
|
1098
|
+
...node.kind === "package" && plannedVersion?.includes("-dev.") ? ["dev Git tag only; publish workflows reject prerelease/dev tags"] : []
|
|
1099
|
+
];
|
|
1100
|
+
const repoPlan = {
|
|
1101
|
+
id: node.id,
|
|
1102
|
+
name: node.name,
|
|
1103
|
+
path: node.path,
|
|
1104
|
+
relativePath: node.relativePath,
|
|
1105
|
+
kind: node.kind,
|
|
1106
|
+
currentBranch: current,
|
|
1107
|
+
targetBranch: branch,
|
|
1108
|
+
branchMode: node.branchMode,
|
|
1109
|
+
dirty,
|
|
1110
|
+
dependencies: node.dependencies,
|
|
1111
|
+
dependents: node.dependents,
|
|
1112
|
+
submoduleDependencies: node.submoduleDependencies,
|
|
1113
|
+
currentVersion,
|
|
1114
|
+
plannedVersion,
|
|
1115
|
+
plannedTag: plannedVersion,
|
|
1116
|
+
plannedDependencySpec,
|
|
1117
|
+
remoteUrl: node.remoteUrl,
|
|
1118
|
+
commands: repoPlanCommands(node, options, plannedVersion, plannedDependencySpec, dependencyUpdates),
|
|
1119
|
+
notes
|
|
1120
|
+
};
|
|
1121
|
+
plans.set(node.id, repoPlan);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const rootNode = nodes.find((node) => node.id === ".") ?? allNodes.find((node) => node.id === ".");
|
|
1125
|
+
const rootRepo = rootNode ? plans.get(rootNode.id) : null;
|
|
1126
|
+
if (!rootRepo) {
|
|
1127
|
+
throw new RepositorySaveError("Unable to build repository save plan for root repository.");
|
|
1128
|
+
}
|
|
1129
|
+
const repoPlans = nodes.filter((node) => node.id !== ".").sort(compareNodes).map((node) => plans.get(node.id)).filter((plan) => Boolean(plan));
|
|
1130
|
+
const wavePlans = waves.map((wave, index) => ({
|
|
1131
|
+
index: index + 1,
|
|
1132
|
+
parallel: wave.length > 1,
|
|
1133
|
+
repos: wave.map((node) => node.name),
|
|
1134
|
+
commands: wave.map((node) => ({
|
|
1135
|
+
repo: node.name,
|
|
1136
|
+
commands: plans.get(node.id)?.commands ?? []
|
|
1137
|
+
}))
|
|
1138
|
+
}));
|
|
1139
|
+
return {
|
|
1140
|
+
mode,
|
|
1141
|
+
branch: options.branch,
|
|
1142
|
+
scope,
|
|
1143
|
+
devDependencyReferenceMode: options.devDependencyReferenceMode ?? "git-tag",
|
|
1144
|
+
gitDependencyProtocol: options.gitDependencyProtocol ?? "preserve-origin",
|
|
1145
|
+
verifyMode: options.verifyMode ?? "action-first",
|
|
1146
|
+
commitMessageMode: options.commitMessageMode ?? "auto",
|
|
1147
|
+
repos: repoPlans,
|
|
1148
|
+
rootRepo,
|
|
1149
|
+
waves: wavePlans,
|
|
1150
|
+
plannedVersions: Object.fromEntries(plannedVersions.entries()),
|
|
1151
|
+
plannedSteps: wavePlans.flatMap((wave) => wave.commands.map((entry) => ({
|
|
1152
|
+
id: `wave-${wave.index}-${entry.repo}`,
|
|
1153
|
+
description: `Wave ${wave.index}${wave.parallel ? " parallel" : ""}: ${entry.repo}`
|
|
1154
|
+
})))
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
async function refreshAndValidateRootWorkspaceLockfileForSave(options) {
|
|
1158
|
+
const repoDir = options.gitRoot ?? options.root;
|
|
1159
|
+
const packageJsonPath = resolve(repoDir, "package.json");
|
|
1160
|
+
const packageJson = existsSync(packageJsonPath) ? readJson(packageJsonPath) : null;
|
|
1161
|
+
const node = {
|
|
1162
|
+
id: ".",
|
|
1163
|
+
name: repoDisplayName(repoDir, packageJson),
|
|
1164
|
+
path: repoDir,
|
|
1165
|
+
relativePath: ".",
|
|
1166
|
+
kind: "project",
|
|
1167
|
+
branch: options.branch ?? currentBranch(repoDir) ?? null,
|
|
1168
|
+
branchMode: "project-save",
|
|
1169
|
+
packageJsonPath: packageJson ? packageJsonPath : null,
|
|
1170
|
+
packageJson,
|
|
1171
|
+
scripts: packageScripts(packageJson),
|
|
1172
|
+
remoteUrl: originRemoteUrlSafe(repoDir),
|
|
1173
|
+
dependencies: [],
|
|
1174
|
+
dependents: [],
|
|
1175
|
+
submoduleDependencies: [],
|
|
1176
|
+
plannedVersion: null,
|
|
1177
|
+
plannedTag: null,
|
|
1178
|
+
plannedDependencySpec: null
|
|
1179
|
+
};
|
|
1180
|
+
if (!hasNpmLockfile(repoDir)) {
|
|
1181
|
+
return {
|
|
1182
|
+
install: null,
|
|
1183
|
+
lockfileValidation: { status: "skipped", command: null, issues: [], error: "no npm lockfile" }
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
const install = await runNpmInstallWithRetry(node, { root: options.root, onProgress: options.onProgress });
|
|
1187
|
+
const lockfileValidation = await validateRepositoryLockfile(node, { root: options.root, onProgress: options.onProgress });
|
|
1188
|
+
return { install, lockfileValidation };
|
|
1189
|
+
}
|
|
1190
|
+
async function saveOneRepository(node, options, state) {
|
|
1191
|
+
const report = state.reports.get(node.id) ?? createReport(node);
|
|
1192
|
+
state.reports.set(node.id, report);
|
|
1193
|
+
const branch = node.branch || options.branch;
|
|
1194
|
+
emitProgress(options, node, "start", `Starting ${node.branchMode} on ${branch}.`);
|
|
1195
|
+
syncBranchBeforeSave(node, options, branch);
|
|
1196
|
+
node.branch = currentBranch(node.path) || branch;
|
|
1197
|
+
report.branch = node.branch;
|
|
1198
|
+
refreshRepositoryNodePackageMetadata(node);
|
|
1199
|
+
ensureWritableRemote(node, options);
|
|
1200
|
+
const dependencyUpdates = updateDependencyReferences(node, state.finalizedReferences);
|
|
1201
|
+
const dependencyChanged = dependencyUpdates.length > 0;
|
|
1202
|
+
const gitDependencyRefreshSpecs = dependencyUpdates.map((update) => state.finalizedReferences.get(update.packageName)).filter((reference) => Boolean(reference) && reference.mode === "dev-git-tag").map((reference) => `${reference.packageName}@${reference.installSpec ?? reference.spec}`);
|
|
1203
|
+
const submodulesChanged = refreshSubmodulePointers(node, state.finalizedCommits);
|
|
1204
|
+
const packageNeedsVersion = node.kind === "package" && (hasMeaningfulChanges(node.path) || dependencyChanged || submodulesChanged);
|
|
1205
|
+
let plannedVersion = null;
|
|
1206
|
+
if (packageNeedsVersion) {
|
|
1207
|
+
const selection = selectPackageVersion(node, options);
|
|
1208
|
+
plannedVersion = selection.version;
|
|
1209
|
+
if (!plannedVersion) {
|
|
1210
|
+
throw new RepositorySaveError(`Unable to plan package version for ${node.name}.`);
|
|
1211
|
+
}
|
|
1212
|
+
if (selection.reused) {
|
|
1213
|
+
emitProgress(options, node, "version", `Reusing existing interrupted save version ${plannedVersion}.`);
|
|
1214
|
+
} else {
|
|
1215
|
+
applyPackageVersion(node, plannedVersion);
|
|
1216
|
+
}
|
|
1217
|
+
node.plannedVersion = plannedVersion;
|
|
1218
|
+
node.plannedTag = plannedVersion;
|
|
1219
|
+
report.version = plannedVersion;
|
|
1220
|
+
report.tagName = plannedVersion;
|
|
1221
|
+
if (!selection.reused) {
|
|
1222
|
+
emitProgress(options, node, "version", `Planned ${plannedVersion}.`);
|
|
1223
|
+
}
|
|
1224
|
+
const reference = finalizePackageReference(node, plannedVersion, options);
|
|
1225
|
+
report.dependencySpec = reference.spec;
|
|
1226
|
+
report.install = await runNpmInstallWithRetry(node, options, gitDependencyRefreshSpecs);
|
|
1227
|
+
} else if (node.kind === "package") {
|
|
1228
|
+
report.version = String(node.packageJson?.version ?? report.version ?? "");
|
|
1229
|
+
} else if (node.kind === "project" && dependencyChanged && hasNpmLockfile(node.path)) {
|
|
1230
|
+
report.install = await runNpmInstallWithRetry(node, options, gitDependencyRefreshSpecs);
|
|
1231
|
+
}
|
|
1232
|
+
if (hasNpmLockfile(node.path) && (node.kind === "project" || packageNeedsVersion || dependencyChanged || submodulesChanged)) {
|
|
1233
|
+
report.lockfileValidation = await validateRepositoryLockfile(node, options);
|
|
1234
|
+
}
|
|
1235
|
+
const dirty = hasMeaningfulChanges(node.path);
|
|
1236
|
+
report.dirty = dirty;
|
|
1237
|
+
if (!dirty) {
|
|
1238
|
+
report.skippedReason = "clean";
|
|
1239
|
+
report.commitSha = headCommit(node.path);
|
|
1240
|
+
emitProgress(options, node, "clean", "No meaningful changes to commit.");
|
|
1241
|
+
if (node.kind === "package") {
|
|
1242
|
+
const finalized = await finalizeCleanPackageVersion(node, options, state, report, branch);
|
|
1243
|
+
if (finalized) {
|
|
1244
|
+
return report;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
if (node.id === ".") {
|
|
1248
|
+
const rebase2 = pullRebaseFromOrigin(node, options, branch);
|
|
1249
|
+
const push = pushCurrentBranch(node, options, branch);
|
|
1250
|
+
report.pushed = push.pushed;
|
|
1251
|
+
report.publishWait = {
|
|
1252
|
+
...rebase2,
|
|
1253
|
+
...push
|
|
1254
|
+
};
|
|
1255
|
+
report.commitSha = headCommit(node.path);
|
|
1256
|
+
}
|
|
1257
|
+
state.finalizedCommits.set(node.relativePath, report.commitSha);
|
|
1258
|
+
return report;
|
|
1259
|
+
}
|
|
1260
|
+
runCapturedCommand(node, options, "commit", "git", ["add", "-A"]);
|
|
1261
|
+
if (!hasStagedChanges(node.path)) {
|
|
1262
|
+
report.dirty = false;
|
|
1263
|
+
report.skippedReason = "clean-after-add";
|
|
1264
|
+
report.commitSha = headCommit(node.path);
|
|
1265
|
+
emitProgress(options, node, "clean", "No staged changes to commit after refreshing the index.");
|
|
1266
|
+
if (node.kind === "package") {
|
|
1267
|
+
const finalized = await finalizeCleanPackageVersion(node, options, state, report, branch);
|
|
1268
|
+
if (finalized) {
|
|
1269
|
+
return report;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
if (node.id === ".") {
|
|
1273
|
+
const rebase2 = pullRebaseFromOrigin(node, options, branch);
|
|
1274
|
+
const push = pushCurrentBranch(node, options, branch);
|
|
1275
|
+
report.pushed = push.pushed;
|
|
1276
|
+
report.publishWait = {
|
|
1277
|
+
...rebase2,
|
|
1278
|
+
...push
|
|
1279
|
+
};
|
|
1280
|
+
report.commitSha = headCommit(node.path);
|
|
1281
|
+
}
|
|
1282
|
+
state.finalizedCommits.set(node.relativePath, report.commitSha);
|
|
1283
|
+
return report;
|
|
1284
|
+
}
|
|
1285
|
+
const { changedFiles, diff } = gitDiffSummary(node.path);
|
|
1286
|
+
emitProgress(options, node, "message", "Generating commit message.");
|
|
1287
|
+
const messageResult = await commitMessageFor(node, options, {
|
|
1288
|
+
changedFiles,
|
|
1289
|
+
diff,
|
|
1290
|
+
plannedVersion: plannedVersion ?? report.version,
|
|
1291
|
+
plannedTag: node.plannedTag ?? report.tagName
|
|
1292
|
+
});
|
|
1293
|
+
report.commitMessage = messageResult.message;
|
|
1294
|
+
report.commitMessageProvider = messageResult.provider;
|
|
1295
|
+
report.commitMessageFallbackUsed = messageResult.fallbackUsed;
|
|
1296
|
+
report.commitMessageError = messageResult.error;
|
|
1297
|
+
emitProgress(options, node, "message", `${messageResult.provider}${messageResult.fallbackUsed ? " fallback" : ""}: ${messageResult.message.split(/\r?\n/u)[0]}`);
|
|
1298
|
+
try {
|
|
1299
|
+
runCapturedCommand(node, options, "commit", "git", ["commit", "-m", messageResult.message]);
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
if (!isNoOpGitCommitError(error) || hasMeaningfulChanges(node.path) || hasStagedChanges(node.path)) {
|
|
1302
|
+
throw error;
|
|
1303
|
+
}
|
|
1304
|
+
report.dirty = false;
|
|
1305
|
+
report.skippedReason = "clean-at-commit";
|
|
1306
|
+
report.commitSha = headCommit(node.path);
|
|
1307
|
+
emitProgress(options, node, "clean", "No changes remained to commit after Git refreshed the index.");
|
|
1308
|
+
if (node.kind === "package") {
|
|
1309
|
+
const finalized = await finalizeCleanPackageVersion(node, options, state, report, branch);
|
|
1310
|
+
if (finalized) {
|
|
1311
|
+
return report;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (node.id === ".") {
|
|
1315
|
+
const rebase2 = pullRebaseFromOrigin(node, options, branch);
|
|
1316
|
+
const push = pushCurrentBranch(node, options, branch);
|
|
1317
|
+
report.pushed = push.pushed;
|
|
1318
|
+
report.publishWait = {
|
|
1319
|
+
...rebase2,
|
|
1320
|
+
...push
|
|
1321
|
+
};
|
|
1322
|
+
report.commitSha = headCommit(node.path);
|
|
1323
|
+
}
|
|
1324
|
+
state.finalizedCommits.set(node.relativePath, report.commitSha);
|
|
1325
|
+
return report;
|
|
1326
|
+
}
|
|
1327
|
+
report.committed = true;
|
|
1328
|
+
const rebase = pullRebaseFromOrigin(node, options, branch);
|
|
1329
|
+
const verifyMode = options.verifyMode ?? "action-first";
|
|
1330
|
+
if (node.kind === "package") {
|
|
1331
|
+
ensureRemoteAccessBeforeVerification(node, options, state);
|
|
1332
|
+
}
|
|
1333
|
+
report.verification = await runRepoVerification(node, options, verifyMode);
|
|
1334
|
+
report.verified = report.verification.status === "passed";
|
|
1335
|
+
if (node.kind === "package") {
|
|
1336
|
+
const version = plannedVersion ?? String(readJson(resolve(node.path, "package.json")).version ?? report.version ?? "");
|
|
1337
|
+
const tagMessage = ensurePackageTagReady(node, options, version, branch, options.workflowRunId);
|
|
1338
|
+
report.tagName = version;
|
|
1339
|
+
report.version = version;
|
|
1340
|
+
report.devTagMetadata = tagMessage?.includes("treeseed-dev-tag: true") ? tagMessage : null;
|
|
1341
|
+
const reference = finalizePackageReference(node, version, options);
|
|
1342
|
+
const push = pushCurrentBranch(node, options, branch, version);
|
|
1343
|
+
await runGitDependencySmoke(node, options, reference);
|
|
1344
|
+
report.dependencySpec = reference.spec;
|
|
1345
|
+
state.finalizedVersions.set(node.name, version);
|
|
1346
|
+
state.finalizedReferences.set(node.name, reference);
|
|
1347
|
+
report.pushed = push.pushed;
|
|
1348
|
+
report.publishWait = {
|
|
1349
|
+
...rebase,
|
|
1350
|
+
...push
|
|
1351
|
+
};
|
|
1352
|
+
} else {
|
|
1353
|
+
const push = pushCurrentBranch(node, options, branch);
|
|
1354
|
+
report.pushed = push.pushed;
|
|
1355
|
+
report.publishWait = {
|
|
1356
|
+
...rebase,
|
|
1357
|
+
...push
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
report.commitSha = headCommit(node.path);
|
|
1361
|
+
report.skippedReason = null;
|
|
1362
|
+
state.finalizedCommits.set(node.relativePath, report.commitSha);
|
|
1363
|
+
emitProgress(options, node, "done", `Saved ${report.commitSha?.slice(0, 12) ?? "current HEAD"}.`);
|
|
1364
|
+
return report;
|
|
1365
|
+
}
|
|
1366
|
+
async function runRepositorySaveOrchestrator(options) {
|
|
1367
|
+
const root = options.root;
|
|
1368
|
+
const gitRoot = options.gitRoot;
|
|
1369
|
+
const branch = options.branch;
|
|
1370
|
+
const scope = branch === STAGING_BRANCH ? "staging" : branch === PRODUCTION_BRANCH ? "prod" : "local";
|
|
1371
|
+
const allNodes = discoverRepositorySaveNodes(root, gitRoot, branch, {
|
|
1372
|
+
stablePackageRelease: options.stablePackageRelease === true
|
|
1373
|
+
});
|
|
1374
|
+
const nodes = options.includeRoot === false ? allNodes.filter((node) => node.id !== ".") : allNodes;
|
|
1375
|
+
const mode = nodes.some((node) => node.id !== ".") ? "recursive-workspace" : "root-only";
|
|
1376
|
+
const waves = repositorySaveWaves(nodes);
|
|
1377
|
+
const state = {
|
|
1378
|
+
finalizedVersions: /* @__PURE__ */ new Map(),
|
|
1379
|
+
finalizedReferences: /* @__PURE__ */ new Map(),
|
|
1380
|
+
finalizedCommits: /* @__PURE__ */ new Map(),
|
|
1381
|
+
reports: new Map(nodes.map((node) => [node.id, createReport(node)])),
|
|
1382
|
+
remoteAccessChecked: /* @__PURE__ */ new Set()
|
|
1383
|
+
};
|
|
1384
|
+
for (const wave of waves) {
|
|
1385
|
+
await runLimited(wave, 3, async (node) => {
|
|
1386
|
+
try {
|
|
1387
|
+
await saveOneRepository(node, options, state);
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
const existing = repositorySaveErrorDetails(error);
|
|
1390
|
+
throw new RepositorySaveError(error instanceof Error ? error.message : String(error), {
|
|
1391
|
+
exitCode: existing.exitCode,
|
|
1392
|
+
details: {
|
|
1393
|
+
...existing.details ?? {},
|
|
1394
|
+
partialFailure: {
|
|
1395
|
+
message: `Treeseed save stopped while saving ${node.name}.`,
|
|
1396
|
+
failingRepo: node.name,
|
|
1397
|
+
phase: typeof existing.details?.phase === "string" ? existing.details.phase : null,
|
|
1398
|
+
currentVersion: typeof node.packageJson?.version === "string" ? node.packageJson.version : null,
|
|
1399
|
+
expectedTag: node.plannedTag,
|
|
1400
|
+
tagState: node.plannedTag ? tagState(node.path, node.plannedTag) : null,
|
|
1401
|
+
nextCommand: `treeseed resume ${options.workflowRunId ?? "<run-id>"}`,
|
|
1402
|
+
repos: [...state.reports.entries()].filter(([id]) => id !== ".").map(([, report]) => report),
|
|
1403
|
+
rootRepo: state.reports.get(".") ?? null,
|
|
1404
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
const rootNode = nodes.find((node) => node.id === ".") ?? allNodes.find((node) => node.id === ".");
|
|
1412
|
+
const rootReport = rootNode ? state.reports.get(rootNode.id) ?? createReport(rootNode) : createReport({
|
|
1413
|
+
id: ".",
|
|
1414
|
+
name: "@treeseed/market",
|
|
1415
|
+
path: gitRoot,
|
|
1416
|
+
relativePath: ".",
|
|
1417
|
+
kind: "project",
|
|
1418
|
+
branch,
|
|
1419
|
+
branchMode: "project-save",
|
|
1420
|
+
packageJsonPath: null,
|
|
1421
|
+
packageJson: null,
|
|
1422
|
+
scripts: {},
|
|
1423
|
+
remoteUrl: null,
|
|
1424
|
+
dependencies: [],
|
|
1425
|
+
dependents: [],
|
|
1426
|
+
submoduleDependencies: [],
|
|
1427
|
+
plannedVersion: null,
|
|
1428
|
+
plannedTag: null,
|
|
1429
|
+
plannedDependencySpec: null
|
|
1430
|
+
});
|
|
1431
|
+
const packageReports = nodes.filter((node) => node.id !== ".").sort(compareNodes).map((node) => state.reports.get(node.id) ?? createReport(node));
|
|
1432
|
+
return {
|
|
1433
|
+
mode,
|
|
1434
|
+
branch,
|
|
1435
|
+
scope,
|
|
1436
|
+
repos: packageReports,
|
|
1437
|
+
rootRepo: rootReport,
|
|
1438
|
+
waves: waves.map((wave) => wave.map((node) => node.name)),
|
|
1439
|
+
plannedVersions: Object.fromEntries(state.finalizedVersions.entries())
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
function repositorySaveErrorDetails(error) {
|
|
1443
|
+
if (error instanceof RepositorySaveError) {
|
|
1444
|
+
return {
|
|
1445
|
+
exitCode: error.exitCode,
|
|
1446
|
+
details: error.details
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
exitCode: void 0,
|
|
1451
|
+
details: void 0
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
export {
|
|
1455
|
+
discoverRepositorySaveNodes,
|
|
1456
|
+
nextDevVersion,
|
|
1457
|
+
planRepositorySave,
|
|
1458
|
+
refreshAndValidateRootWorkspaceLockfileForSave,
|
|
1459
|
+
repositorySaveErrorDetails,
|
|
1460
|
+
repositorySaveWaves,
|
|
1461
|
+
runRepositorySaveOrchestrator
|
|
1462
|
+
};
|