@brainervirus/workit-core 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +31 -8
- package/src/core/auto-approval.ts +44 -32
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +1102 -209
- package/src/core/external-action.ts +230 -103
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +16 -3
- package/src/core/task-engine.ts +136 -31
- package/src/core/task-evaluation.ts +3 -5
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workers.ts +17 -0
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
package/src/core/branch.ts
CHANGED
|
@@ -1,18 +1,229 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { matchCommitFlavor } from "./commit-flavors";
|
|
4
6
|
import { gitContext } from "./git";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import {
|
|
8
|
+
readConfig,
|
|
9
|
+
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
10
|
+
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
11
|
+
} from "./config";
|
|
12
|
+
import {
|
|
13
|
+
resolveRuntimeWorkspacePolicy,
|
|
14
|
+
type WorkspaceBranchPolicy,
|
|
15
|
+
type WorkspaceCommitPolicy,
|
|
16
|
+
} from "./workspaces";
|
|
17
|
+
import { vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
function effectiveWorkspacePolicy(
|
|
20
|
+
workspaceRoot: string,
|
|
21
|
+
kind: "branch",
|
|
22
|
+
profileName?: string,
|
|
23
|
+
): {
|
|
24
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
25
|
+
provenance: { branchPolicy: string };
|
|
26
|
+
};
|
|
27
|
+
function effectiveWorkspacePolicy(
|
|
28
|
+
workspaceRoot: string,
|
|
29
|
+
kind: "commit",
|
|
30
|
+
profileName?: string,
|
|
31
|
+
): {
|
|
32
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
33
|
+
provenance: { commitPolicy: string };
|
|
34
|
+
};
|
|
35
|
+
function effectiveWorkspacePolicy(
|
|
36
|
+
workspaceRoot: string,
|
|
37
|
+
kind: "branch" | "commit",
|
|
38
|
+
profileName?: string,
|
|
39
|
+
):
|
|
40
|
+
| {
|
|
41
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
42
|
+
provenance: { branchPolicy: string };
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
46
|
+
provenance: { commitPolicy: string };
|
|
47
|
+
} {
|
|
48
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
49
|
+
workspaceRoot,
|
|
50
|
+
kind,
|
|
51
|
+
profileName?.trim() || process.env.WORKFLOW_PROFILE?.trim() || undefined,
|
|
52
|
+
);
|
|
53
|
+
const config = readConfig();
|
|
54
|
+
return kind === "branch"
|
|
55
|
+
? {
|
|
56
|
+
branchPolicy: resolveConfiguredBranchPolicy(
|
|
57
|
+
config,
|
|
58
|
+
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
59
|
+
),
|
|
60
|
+
provenance: { branchPolicy: selected.source },
|
|
61
|
+
}
|
|
62
|
+
: {
|
|
63
|
+
commitPolicy: resolveConfiguredCommitPolicy(
|
|
64
|
+
config,
|
|
65
|
+
selected.policy ? { commitPolicy: selected.policy as WorkspaceCommitPolicy } : null,
|
|
66
|
+
),
|
|
67
|
+
provenance: { commitPolicy: selected.source },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type BranchNamePolicy = {
|
|
72
|
+
allowed: readonly RegExp[];
|
|
73
|
+
protected: ReadonlySet<string>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type BranchNamePolicyCheck =
|
|
77
|
+
| { ok: true }
|
|
78
|
+
| {
|
|
79
|
+
ok: false;
|
|
80
|
+
rule: "protected_ref" | "allowed_pattern" | "policy_unavailable";
|
|
81
|
+
source: string;
|
|
82
|
+
attempted: string;
|
|
83
|
+
correction: string;
|
|
84
|
+
error: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type CommitMessagePolicyCheck =
|
|
88
|
+
| { ok: true }
|
|
89
|
+
| {
|
|
90
|
+
ok: false;
|
|
91
|
+
rule: "commit_style" | "policy_unavailable";
|
|
92
|
+
source: string;
|
|
93
|
+
attempted: string;
|
|
94
|
+
correction: string;
|
|
95
|
+
error: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const validateBranchNamePolicy = (
|
|
99
|
+
name: string,
|
|
100
|
+
policy: BranchNamePolicy,
|
|
101
|
+
source: string,
|
|
102
|
+
): BranchNamePolicyCheck => {
|
|
103
|
+
const allowed = policy.allowed.map((pattern) => `/${pattern.source}/${pattern.flags}`);
|
|
104
|
+
if (policy.protected.has(name.toLowerCase())) {
|
|
105
|
+
const correction = `choose a non-protected branch matching one of: ${allowed.join(", ")}`;
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
rule: "protected_ref",
|
|
109
|
+
source,
|
|
110
|
+
attempted: name,
|
|
111
|
+
correction,
|
|
112
|
+
error: `protected_ref: branch ${JSON.stringify(name)} is protected by ${source}; ${correction}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (!policy.allowed.some((pattern) => pattern.test(name))) {
|
|
116
|
+
const correction = `choose a branch matching one of: ${allowed.join(", ")}`;
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
rule: "allowed_pattern",
|
|
120
|
+
source,
|
|
121
|
+
attempted: name,
|
|
122
|
+
correction,
|
|
123
|
+
error: `allowed_pattern: branch ${JSON.stringify(name)} violates ${source} policy; ${correction}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ok: true };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const branchPolicySnapshotFor = (workspaceRoot: string) => {
|
|
130
|
+
const resolved = effectiveWorkspacePolicy(workspaceRoot, "branch");
|
|
131
|
+
const branchPolicy = resolved.branchPolicy;
|
|
132
|
+
const source = resolved.provenance.branchPolicy;
|
|
133
|
+
const allowed = branchPolicy.allowed
|
|
134
|
+
.map((pattern) => `${pattern.source}/${pattern.flags}`)
|
|
135
|
+
.sort();
|
|
136
|
+
const protectedRefs = [...branchPolicy.protected].map((name) => name.toLowerCase()).sort();
|
|
137
|
+
const fingerprint = createHash("sha256")
|
|
138
|
+
.update(JSON.stringify({ allowed, protectedRefs, source }))
|
|
139
|
+
.digest("hex");
|
|
140
|
+
return {
|
|
141
|
+
branchPolicy,
|
|
142
|
+
namePolicy: { allowed: branchPolicy.allowed, protected: branchPolicy.protected },
|
|
143
|
+
source,
|
|
144
|
+
fingerprint,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const commitPolicySnapshotFor = (workspaceRoot: string) => {
|
|
149
|
+
const resolved = effectiveWorkspacePolicy(workspaceRoot, "commit");
|
|
150
|
+
const policy = resolved.commitPolicy;
|
|
151
|
+
const source = resolved.provenance.commitPolicy;
|
|
152
|
+
const fingerprint = createHash("sha256")
|
|
153
|
+
.update(JSON.stringify({ preset: policy.preset, pattern: policy.pattern ?? null, source }))
|
|
154
|
+
.digest("hex");
|
|
155
|
+
return { policy, source, fingerprint };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const validateCommitMessagePolicy = (
|
|
159
|
+
message: string,
|
|
160
|
+
policy: { preset: Parameters<typeof matchCommitFlavor>[1]; pattern?: string },
|
|
161
|
+
source: string,
|
|
162
|
+
): CommitMessagePolicyCheck => {
|
|
163
|
+
const attempted = message.split("\n", 1)[0] ?? "";
|
|
164
|
+
if (matchCommitFlavor(message, policy.preset, policy.pattern)) return { ok: true };
|
|
165
|
+
const correction =
|
|
166
|
+
policy.preset === "custom" && policy.pattern
|
|
167
|
+
? `use a commit subject matching ${JSON.stringify(policy.pattern)}`
|
|
168
|
+
: `use a ${policy.preset} commit subject`;
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
rule: "commit_style",
|
|
172
|
+
source,
|
|
173
|
+
attempted,
|
|
174
|
+
correction,
|
|
175
|
+
error: `commit_style: commit subject ${JSON.stringify(attempted)} violates ${source} ${policy.preset} policy; ${correction}`,
|
|
176
|
+
};
|
|
177
|
+
};
|
|
12
178
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
179
|
+
export const validateBranchNameFor = (
|
|
180
|
+
workspaceRoot: string,
|
|
181
|
+
name: string,
|
|
182
|
+
): BranchNamePolicyCheck => {
|
|
183
|
+
try {
|
|
184
|
+
const resolved = branchPolicySnapshotFor(workspaceRoot);
|
|
185
|
+
return validateBranchNamePolicy(name, resolved.namePolicy, resolved.source);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const correction = "repair or select a valid workspace policy before creating a branch";
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
rule: "policy_unavailable",
|
|
191
|
+
source: "workspaces.json",
|
|
192
|
+
attempted: name,
|
|
193
|
+
correction,
|
|
194
|
+
error: `policy_unavailable: cannot validate branch ${JSON.stringify(name)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const validateCommitMessageFor = (
|
|
200
|
+
workspaceRoot: string,
|
|
201
|
+
message: string,
|
|
202
|
+
): CommitMessagePolicyCheck => {
|
|
203
|
+
const attempted = message.split("\n", 1)[0] ?? "";
|
|
204
|
+
try {
|
|
205
|
+
const resolved = commitPolicySnapshotFor(workspaceRoot);
|
|
206
|
+
return validateCommitMessagePolicy(message, resolved.policy, resolved.source);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
const correction = "repair or select a valid workspace policy before committing";
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
rule: "policy_unavailable",
|
|
212
|
+
source: "workspaces.json",
|
|
213
|
+
attempted,
|
|
214
|
+
correction,
|
|
215
|
+
error: `policy_unavailable: cannot validate commit subject ${JSON.stringify(attempted)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** CA-09: one policy resolver; profiles layer above the matched workspace. */
|
|
221
|
+
export const resolveBranchPolicyFor = (workspaceRoot: string, profileName?: string) =>
|
|
222
|
+
effectiveWorkspacePolicy(workspaceRoot, "branch", profileName).branchPolicy;
|
|
223
|
+
|
|
224
|
+
/** Commit-flavor equivalent using the same profile and provenance chain. */
|
|
225
|
+
export const resolveCommitPolicyFor = (workspaceRoot: string, profileName?: string) =>
|
|
226
|
+
effectiveWorkspacePolicy(workspaceRoot, "commit", profileName).commitPolicy;
|
|
16
227
|
|
|
17
228
|
const policy = (root: string) => resolveBranchPolicyFor(root);
|
|
18
229
|
const allowedBranch = (root: string, name: string) =>
|
|
@@ -40,25 +251,13 @@ export const verifyPushIdentity = (
|
|
|
40
251
|
): { ok: true } | { ok: false; error: string } => {
|
|
41
252
|
if (!account || typeof account !== "string")
|
|
42
253
|
return { ok: false, error: "push identity requires a configured area account" };
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const bin = provider === "gitlab" ? "glab" : "gh";
|
|
48
|
-
let login: string;
|
|
49
|
-
try {
|
|
50
|
-
login = execFileSync(bin, [...probe], {
|
|
51
|
-
cwd: root,
|
|
52
|
-
encoding: "utf8",
|
|
53
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
54
|
-
}).trim();
|
|
55
|
-
} catch {
|
|
56
|
-
return { ok: false, error: `${bin} identity could not be resolved` };
|
|
57
|
-
}
|
|
58
|
-
if (!login || login.toLowerCase() !== account.toLowerCase())
|
|
254
|
+
const identity = vcsCliIdentity(root);
|
|
255
|
+
if (!identity.ok || identity.provider !== provider)
|
|
256
|
+
return { ok: false, error: identity.error ?? "hosting CLI identity could not be resolved" };
|
|
257
|
+
if (identity.username?.toLowerCase() !== account.toLowerCase())
|
|
59
258
|
return {
|
|
60
259
|
ok: false,
|
|
61
|
-
error: `push identity ${
|
|
260
|
+
error: `push identity ${identity.username || "(unknown)"} does not match area account ${account}`,
|
|
62
261
|
};
|
|
63
262
|
return { ok: true };
|
|
64
263
|
};
|
|
@@ -83,8 +282,7 @@ const readSafe = (p: string): string | null => {
|
|
|
83
282
|
|
|
84
283
|
const normalizeBranch = (root: string, name: string): string | null => {
|
|
85
284
|
const n = name.trim().replace(/`/g, "").replace(/\.+$/, "");
|
|
86
|
-
if (
|
|
87
|
-
if (!allowedBranch(root, n)) return null;
|
|
285
|
+
if (!validateBranchNameFor(root, n).ok) return null;
|
|
88
286
|
const parts = n
|
|
89
287
|
.toLowerCase()
|
|
90
288
|
.split("/")
|
|
@@ -155,9 +353,10 @@ export const resolveBranch = ({
|
|
|
155
353
|
const text = readSafe(file);
|
|
156
354
|
if (!text) continue;
|
|
157
355
|
if (USE_CURRENT_RE.test(text)) {
|
|
158
|
-
if (!current
|
|
159
|
-
|
|
160
|
-
|
|
356
|
+
if (!current) return { error: `use-current but HEAD ${current} is not an allowed branch` };
|
|
357
|
+
const policyCheck = validateBranchNameFor(cwd, current);
|
|
358
|
+
if (!policyCheck.ok)
|
|
359
|
+
return { error: `use-current but HEAD ${current} is not allowed: ${policyCheck.error}` };
|
|
161
360
|
return finish(current, "use-current");
|
|
162
361
|
}
|
|
163
362
|
}
|
|
@@ -176,8 +375,11 @@ export const resolveBranch = ({
|
|
|
176
375
|
}
|
|
177
376
|
}
|
|
178
377
|
if (declaredButInvalid) {
|
|
378
|
+
const policyCheck = validateBranchNameFor(cwd, declaredButInvalid);
|
|
179
379
|
return {
|
|
180
|
-
error:
|
|
380
|
+
error: policyCheck.ok
|
|
381
|
+
? `declared branch ${JSON.stringify(declaredButInvalid)} is not a valid branch name`
|
|
382
|
+
: policyCheck.error,
|
|
181
383
|
};
|
|
182
384
|
}
|
|
183
385
|
|
|
@@ -271,7 +473,12 @@ const originBaseReady = (cwd: string, base: string): { ok: boolean; error?: stri
|
|
|
271
473
|
|
|
272
474
|
// Mutating half of ensureBaseBranch: fast-forwards the local base (creating
|
|
273
475
|
// it from origin/<base> if needed). Only safe on a clean tree.
|
|
274
|
-
const fastForwardBase = (
|
|
476
|
+
const fastForwardBase = (
|
|
477
|
+
cwd: string,
|
|
478
|
+
base: string,
|
|
479
|
+
expectedRemoteBase?: string,
|
|
480
|
+
expectedLocalBase?: string | null,
|
|
481
|
+
): { ok: boolean; error?: string } => {
|
|
275
482
|
const run = (args: string[]) =>
|
|
276
483
|
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
277
484
|
try {
|
|
@@ -284,13 +491,26 @@ const fastForwardBase = (cwd: string, base: string): { ok: boolean; error?: stri
|
|
|
284
491
|
} catch {
|
|
285
492
|
hasLocalBase = false;
|
|
286
493
|
}
|
|
494
|
+
if (expectedLocalBase !== undefined) {
|
|
495
|
+
let localBase: string | null = null;
|
|
496
|
+
try {
|
|
497
|
+
localBase = run(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
|
|
498
|
+
} catch {}
|
|
499
|
+
if (localBase !== expectedLocalBase)
|
|
500
|
+
return { ok: false, error: "approved local base changed before branch setup" };
|
|
501
|
+
}
|
|
287
502
|
if (hasLocalBase) {
|
|
288
503
|
run(["checkout", base]);
|
|
504
|
+
if (expectedLocalBase && run(["rev-parse", "HEAD"]).trim() !== expectedLocalBase)
|
|
505
|
+
return { ok: false, error: "approved local base changed before branch setup" };
|
|
289
506
|
try {
|
|
290
|
-
run(["merge", "--ff-only", `origin/${base}`]);
|
|
507
|
+
run(["merge", "--ff-only", expectedRemoteBase ?? `origin/${base}`]);
|
|
291
508
|
} catch {
|
|
292
509
|
/* non-fast-forward: keep local */
|
|
293
510
|
}
|
|
511
|
+
} else if (expectedRemoteBase) {
|
|
512
|
+
run(["checkout", "-b", base, expectedRemoteBase]);
|
|
513
|
+
run(["branch", "--set-upstream-to", `origin/${base}`, base]);
|
|
294
514
|
} else {
|
|
295
515
|
run(["checkout", "-b", base, "--track", `origin/${base}`]);
|
|
296
516
|
}
|
|
@@ -369,6 +589,8 @@ export const branchSetup = ({
|
|
|
369
589
|
stash,
|
|
370
590
|
workspace_root,
|
|
371
591
|
log,
|
|
592
|
+
expected_remote_base,
|
|
593
|
+
expected_local_base,
|
|
372
594
|
}: {
|
|
373
595
|
action?: string;
|
|
374
596
|
sdd_dir?: string;
|
|
@@ -376,6 +598,8 @@ export const branchSetup = ({
|
|
|
376
598
|
stash?: string;
|
|
377
599
|
workspace_root: string;
|
|
378
600
|
log?: (message: string) => void;
|
|
601
|
+
expected_remote_base?: string;
|
|
602
|
+
expected_local_base?: string | null;
|
|
379
603
|
}): BranchSetupResult => {
|
|
380
604
|
const cwd = path.resolve(workspace_root);
|
|
381
605
|
const exec = (args: string[]): string =>
|
|
@@ -432,12 +656,13 @@ export const branchSetup = ({
|
|
|
432
656
|
error: "target_branch (the working branch to create or switch to) is required for setup",
|
|
433
657
|
phase: "preflight",
|
|
434
658
|
};
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
};
|
|
659
|
+
if (expected_remote_base && !/^[a-f0-9]{40,64}$/i.test(expected_remote_base))
|
|
660
|
+
return { error: "approved remote base commit is invalid", phase: "preflight" };
|
|
661
|
+
try {
|
|
662
|
+
exec(["check-ref-format", "--branch", target]);
|
|
663
|
+
} catch {
|
|
664
|
+
return { error: `invalid Git branch name ${target}`, phase: "preflight" };
|
|
665
|
+
}
|
|
441
666
|
|
|
442
667
|
// CA-02: resolve the base up front so an unresolvable base fails before
|
|
443
668
|
// any mutation. The origin/<base> validation runs after the stash gate
|
|
@@ -449,6 +674,10 @@ export const branchSetup = ({
|
|
|
449
674
|
} catch {
|
|
450
675
|
targetExists = false;
|
|
451
676
|
}
|
|
677
|
+
if (!targetExists) {
|
|
678
|
+
const policy = validateBranchNameFor(cwd, target);
|
|
679
|
+
if (!policy.ok) return { error: policy.error, phase: "preflight" };
|
|
680
|
+
}
|
|
452
681
|
if (!targetExists) {
|
|
453
682
|
const baseResolved = baseBranch(cwd);
|
|
454
683
|
if ("error" in baseResolved) return { error: baseResolved.error, phase: "preflight" };
|
|
@@ -491,10 +720,25 @@ export const branchSetup = ({
|
|
|
491
720
|
// tree untouched. The mutating fast-forward stays below: it checks out
|
|
492
721
|
// the base branch — unsafe on a dirty tree.
|
|
493
722
|
let validatedBase: string | undefined;
|
|
723
|
+
let validatedRemoteBase: string | undefined;
|
|
724
|
+
let validatedLocalBase: string | null | undefined;
|
|
494
725
|
if (!targetExists && base !== undefined) {
|
|
495
726
|
const ready = originBaseReady(cwd, base);
|
|
496
727
|
if (!ready.ok)
|
|
497
728
|
return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
|
|
729
|
+
const fetchedBase = exec(["rev-parse", "--verify", `refs/remotes/origin/${base}`]).trim();
|
|
730
|
+
if (expected_remote_base && fetchedBase !== expected_remote_base)
|
|
731
|
+
return { error: "approved remote base changed before branch setup", phase: "preflight" };
|
|
732
|
+
if (expected_local_base !== undefined) {
|
|
733
|
+
let localBase: string | null = null;
|
|
734
|
+
try {
|
|
735
|
+
localBase = exec(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
|
|
736
|
+
} catch {}
|
|
737
|
+
if (localBase !== expected_local_base)
|
|
738
|
+
return { error: "approved local base changed before branch setup", phase: "preflight" };
|
|
739
|
+
validatedLocalBase = expected_local_base;
|
|
740
|
+
}
|
|
741
|
+
if (expected_remote_base) validatedRemoteBase = expected_remote_base;
|
|
498
742
|
validatedBase = base;
|
|
499
743
|
}
|
|
500
744
|
if (dirty && stash === "yes") {
|
|
@@ -531,10 +775,17 @@ export const branchSetup = ({
|
|
|
531
775
|
// fast-forward mutation remains post-stash.
|
|
532
776
|
const baseResult =
|
|
533
777
|
validatedBase !== undefined
|
|
534
|
-
? fastForwardBase(cwd, effectiveBase)
|
|
778
|
+
? fastForwardBase(cwd, effectiveBase, validatedRemoteBase, validatedLocalBase)
|
|
535
779
|
: ensureBaseBranch(cwd, effectiveBase);
|
|
536
780
|
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
537
|
-
exec(["
|
|
781
|
+
const targetBase = exec(["rev-parse", "HEAD"]).trim();
|
|
782
|
+
if (
|
|
783
|
+
validatedRemoteBase &&
|
|
784
|
+
validatedLocalBase !== undefined &&
|
|
785
|
+
![validatedRemoteBase, validatedLocalBase].includes(targetBase)
|
|
786
|
+
)
|
|
787
|
+
return failAfterStash("approved branch base changed before branch creation", "preflight");
|
|
788
|
+
exec(["checkout", "-b", target, targetBase]);
|
|
538
789
|
journal("post-create");
|
|
539
790
|
} catch (createError) {
|
|
540
791
|
return failAfterStash(
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { isConfigObject, mergePreset, type BranchPreset, type ToolkitConfig } from "./config";
|
|
5
|
+
import { writeFileAtomic } from "./safe-write";
|
|
5
6
|
|
|
6
7
|
export type ConversionInput = {
|
|
7
8
|
configDir: string;
|
|
@@ -170,11 +171,10 @@ export function applyConversionConfig(
|
|
|
170
171
|
delete next.workflowMode;
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
|
|
174
|
-
writeFileSync(configFile, JSON.stringify(next, null, 2) + "\n");
|
|
174
|
+
writeFileAtomic(configFile, JSON.stringify(next, null, 2) + "\n");
|
|
175
175
|
|
|
176
176
|
const choicesPath = path.join(configDir, "cutover-choices.json");
|
|
177
|
-
|
|
177
|
+
writeFileAtomic(
|
|
178
178
|
choicesPath,
|
|
179
179
|
JSON.stringify({ resolutions, applied_at: new Date().toISOString() }, null, 2) + "\n",
|
|
180
180
|
);
|
package/src/core/config-guard.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { initStatus } from "./init";
|
|
2
2
|
|
|
3
|
-
export const ALL_ITEM_IDS = [
|
|
4
|
-
"youtrack_json",
|
|
5
|
-
"youtrack_token",
|
|
6
|
-
"vcs_json",
|
|
7
|
-
"gitlab_token",
|
|
8
|
-
"github_token",
|
|
9
|
-
];
|
|
3
|
+
export const ALL_ITEM_IDS = ["youtrack_json", "youtrack_token", "vcs_json"];
|
|
10
4
|
export const CONFIG_GAP_MARKER = "workflow config missing";
|
|
11
5
|
|
|
12
6
|
export function describeConfigGaps(scope?: string[]): { missing: string[]; ok: boolean } {
|