@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { branchNames, productionBranchOf, repoBranchPreset } from "./branch-policy";
|
|
6
|
+
import { matchCommitFlavor } from "./commit-flavors";
|
|
7
|
+
import { gitContext } from "./git";
|
|
8
|
+
import {
|
|
9
|
+
branchGlobPattern,
|
|
10
|
+
readConfig,
|
|
11
|
+
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
12
|
+
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
13
|
+
} from "./config";
|
|
14
|
+
import {
|
|
15
|
+
resolveRuntimeWorkspacePolicy,
|
|
16
|
+
type WorkspaceBranchPolicy,
|
|
17
|
+
type WorkspaceCommitPolicy,
|
|
18
|
+
} from "./workspaces";
|
|
19
|
+
import { vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
20
|
+
import {
|
|
21
|
+
ReleaseTrackError,
|
|
22
|
+
namingGlob,
|
|
23
|
+
trackBranches,
|
|
24
|
+
workspaceReleaseTracks,
|
|
25
|
+
} from "./release-tracks";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Release tracks extend the branch policy: every track's long-lived branches
|
|
29
|
+
* are protected (the union across tracks plus the explicit protected list),
|
|
30
|
+
* and the tracks' naming templates are allowed branch patterns. A track read
|
|
31
|
+
* that must fail closed (a `critical` field) throws, so callers block.
|
|
32
|
+
*/
|
|
33
|
+
const withReleaseTracks = (
|
|
34
|
+
workspaceRoot: string,
|
|
35
|
+
policy: ReturnType<typeof resolveConfiguredBranchPolicy>,
|
|
36
|
+
): ReturnType<typeof resolveConfiguredBranchPolicy> => {
|
|
37
|
+
const read = workspaceReleaseTracks(workspaceRoot);
|
|
38
|
+
if (read.error) throw new ReleaseTrackError(read.error);
|
|
39
|
+
if (read.tracks.length === 0 && read.protectedBranches.length === 0) return policy;
|
|
40
|
+
const known = new Set(policy.allowed.map((pattern) => pattern.source));
|
|
41
|
+
const allowed = [...policy.allowed];
|
|
42
|
+
for (const glob of read.tracks.flatMap((track) => Object.values(track.naming).map(namingGlob))) {
|
|
43
|
+
const pattern = branchGlobPattern(glob);
|
|
44
|
+
if (!known.has(pattern.source)) {
|
|
45
|
+
known.add(pattern.source);
|
|
46
|
+
allowed.push(pattern);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...policy,
|
|
51
|
+
allowed,
|
|
52
|
+
protected: new Set([
|
|
53
|
+
...policy.protected,
|
|
54
|
+
// Fail-safe: every branch any track entry names, including dropped ones.
|
|
55
|
+
...[...trackBranches(read.tracks), ...read.protectedBranches].map((name) =>
|
|
56
|
+
name.toLowerCase(),
|
|
57
|
+
),
|
|
58
|
+
]),
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function effectiveWorkspacePolicy(
|
|
63
|
+
workspaceRoot: string,
|
|
64
|
+
kind: "branch",
|
|
65
|
+
profileName?: string,
|
|
66
|
+
): {
|
|
67
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
68
|
+
provenance: { branchPolicy: string };
|
|
69
|
+
};
|
|
70
|
+
function effectiveWorkspacePolicy(
|
|
71
|
+
workspaceRoot: string,
|
|
72
|
+
kind: "commit",
|
|
73
|
+
profileName?: string,
|
|
74
|
+
): {
|
|
75
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
76
|
+
provenance: { commitPolicy: string };
|
|
77
|
+
};
|
|
78
|
+
function effectiveWorkspacePolicy(
|
|
79
|
+
workspaceRoot: string,
|
|
80
|
+
kind: "branch" | "commit",
|
|
81
|
+
profileName?: string,
|
|
82
|
+
):
|
|
83
|
+
| {
|
|
84
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
85
|
+
provenance: { branchPolicy: string };
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
89
|
+
provenance: { commitPolicy: string };
|
|
90
|
+
} {
|
|
91
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
92
|
+
workspaceRoot,
|
|
93
|
+
kind,
|
|
94
|
+
profileName?.trim() || process.env.WORKIT_PROFILE?.trim() || undefined,
|
|
95
|
+
);
|
|
96
|
+
const config = readConfig();
|
|
97
|
+
return kind === "branch"
|
|
98
|
+
? {
|
|
99
|
+
branchPolicy: withReleaseTracks(
|
|
100
|
+
workspaceRoot,
|
|
101
|
+
resolveConfiguredBranchPolicy(
|
|
102
|
+
config,
|
|
103
|
+
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
104
|
+
() => repoBranchPreset(workspaceRoot),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
provenance: { branchPolicy: selected.source },
|
|
108
|
+
}
|
|
109
|
+
: {
|
|
110
|
+
commitPolicy: resolveConfiguredCommitPolicy(
|
|
111
|
+
config,
|
|
112
|
+
selected.policy ? { commitPolicy: selected.policy as WorkspaceCommitPolicy } : null,
|
|
113
|
+
),
|
|
114
|
+
provenance: { commitPolicy: selected.source },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type BranchNamePolicy = {
|
|
119
|
+
allowed: readonly RegExp[];
|
|
120
|
+
protected: ReadonlySet<string>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type BranchNamePolicyCheck =
|
|
124
|
+
| { ok: true }
|
|
125
|
+
| {
|
|
126
|
+
ok: false;
|
|
127
|
+
rule: "protected_ref" | "allowed_pattern" | "policy_unavailable";
|
|
128
|
+
source: string;
|
|
129
|
+
attempted: string;
|
|
130
|
+
correction: string;
|
|
131
|
+
error: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type CommitMessagePolicyCheck =
|
|
135
|
+
| { ok: true }
|
|
136
|
+
| {
|
|
137
|
+
ok: false;
|
|
138
|
+
rule: "commit_style" | "policy_unavailable";
|
|
139
|
+
source: string;
|
|
140
|
+
attempted: string;
|
|
141
|
+
correction: string;
|
|
142
|
+
error: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Prefixes agents reach for, mapped to the git-flow kind that means the same. */
|
|
146
|
+
const KIND_SYNONYMS: Readonly<Record<string, string>> = {
|
|
147
|
+
fix: "bugfix",
|
|
148
|
+
fixes: "bugfix",
|
|
149
|
+
bug: "bugfix",
|
|
150
|
+
bugs: "bugfix",
|
|
151
|
+
bugfixes: "bugfix",
|
|
152
|
+
feat: "feature",
|
|
153
|
+
features: "feature",
|
|
154
|
+
chore: "feature",
|
|
155
|
+
docs: "feature",
|
|
156
|
+
doc: "feature",
|
|
157
|
+
refactor: "feature",
|
|
158
|
+
test: "feature",
|
|
159
|
+
tests: "feature",
|
|
160
|
+
ci: "feature",
|
|
161
|
+
build: "feature",
|
|
162
|
+
perf: "feature",
|
|
163
|
+
style: "feature",
|
|
164
|
+
deps: "feature",
|
|
165
|
+
task: "feature",
|
|
166
|
+
enhancement: "feature",
|
|
167
|
+
hot: "hotfix",
|
|
168
|
+
hotfixes: "hotfix",
|
|
169
|
+
releases: "release",
|
|
170
|
+
rel: "release",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** The `<kind>/` prefixes an allowed-pattern list names (`feature/*` -> feature). */
|
|
174
|
+
export const allowedKinds = (allowed: readonly RegExp[]): string[] => [
|
|
175
|
+
...new Set(
|
|
176
|
+
allowed
|
|
177
|
+
// RegExp#source escapes the slash: `^feature\/.*$`.
|
|
178
|
+
.map((pattern) => /^\^([\w.-]+)\\?\/\.\*\$$/u.exec(pattern.source)?.[1]?.toLowerCase())
|
|
179
|
+
.filter((kind): kind is string => Boolean(kind)),
|
|
180
|
+
),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const editDistance = (a: string, b: string): number => {
|
|
184
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
185
|
+
for (let i = 1; i <= a.length; i++) {
|
|
186
|
+
let previous = row[0];
|
|
187
|
+
row[0] = i;
|
|
188
|
+
for (let j = 1; j <= b.length; j++) {
|
|
189
|
+
const saved = row[j];
|
|
190
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
191
|
+
previous = saved;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return row[b.length];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The allowed name closest to `name`: its prefix mapped through the usual
|
|
199
|
+
* synonyms (fix/ -> bugfix/, chore/ -> feature/), else the kind within two
|
|
200
|
+
* edits, else feature/ (or the first kind); the rest of the name is kept.
|
|
201
|
+
* Null when the policy names no kinds.
|
|
202
|
+
*/
|
|
203
|
+
export const suggestBranchName = (name: string, kinds: readonly string[]): string | null => {
|
|
204
|
+
if (!kinds.length) return null;
|
|
205
|
+
const slash = name.indexOf("/");
|
|
206
|
+
const prefix = slash > 0 ? name.slice(0, slash).toLowerCase() : "";
|
|
207
|
+
const rest = (slash > 0 ? name.slice(slash + 1) : name) || "name";
|
|
208
|
+
const synonym = KIND_SYNONYMS[prefix];
|
|
209
|
+
const near = kinds
|
|
210
|
+
.map((kind) => ({ kind, distance: editDistance(prefix, kind) }))
|
|
211
|
+
.filter((entry) => prefix && entry.distance <= 2)
|
|
212
|
+
.toSorted((a, b) => a.distance - b.distance)[0]?.kind;
|
|
213
|
+
const kind =
|
|
214
|
+
(synonym && kinds.includes(synonym) ? synonym : null) ??
|
|
215
|
+
near ??
|
|
216
|
+
(kinds.includes("feature") ? "feature" : kinds[0]);
|
|
217
|
+
return `${kind}/${rest}`;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const kindList = (kinds: readonly string[]) => kinds.map((kind) => `${kind}/`).join(", ");
|
|
221
|
+
|
|
222
|
+
/** ` (fix/ is bugfix/, chore/ is feature/)`, naming only the kinds this policy allows. */
|
|
223
|
+
const aliasNote = (kinds: readonly string[]): string => {
|
|
224
|
+
const notes = [
|
|
225
|
+
["fix", "bugfix"],
|
|
226
|
+
["chore", "feature"],
|
|
227
|
+
]
|
|
228
|
+
.filter(([, kind]) => kinds.includes(kind))
|
|
229
|
+
.map(([alias, kind]) => `${alias}/ is ${kind}/`);
|
|
230
|
+
return notes.length ? ` (${notes.join(", ")})` : "";
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const validateBranchNamePolicy = (
|
|
234
|
+
name: string,
|
|
235
|
+
policy: BranchNamePolicy,
|
|
236
|
+
source: string,
|
|
237
|
+
): BranchNamePolicyCheck => {
|
|
238
|
+
const allowed = policy.allowed.map((pattern) => `/${pattern.source}/${pattern.flags}`);
|
|
239
|
+
const kinds = allowedKinds(policy.allowed);
|
|
240
|
+
if (policy.protected.has(name.toLowerCase())) {
|
|
241
|
+
const correction = kinds.length
|
|
242
|
+
? `choose a non-protected branch of an allowed kind (${kindList(kinds)}), e.g. ${kinds[0]}/<slug>: workit git branch --kind ${kinds[0]} --slug <slug>`
|
|
243
|
+
: `choose a non-protected branch matching one of: ${allowed.join(", ")}`;
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
rule: "protected_ref",
|
|
247
|
+
source,
|
|
248
|
+
attempted: name,
|
|
249
|
+
correction,
|
|
250
|
+
error: `protected_ref: branch ${JSON.stringify(name)} is protected by ${source}; ${correction}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (!policy.allowed.some((pattern) => pattern.test(name))) {
|
|
254
|
+
const suggestion = suggestBranchName(name, kinds);
|
|
255
|
+
const correction =
|
|
256
|
+
suggestion && policy.allowed.some((pattern) => pattern.test(suggestion))
|
|
257
|
+
? `use ${JSON.stringify(suggestion)}; the allowed kinds are ${kindList(kinds)} (workit git branch ${suggestion})`
|
|
258
|
+
: `choose a branch matching one of: ${allowed.join(", ")}`;
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
rule: "allowed_pattern",
|
|
262
|
+
source,
|
|
263
|
+
attempted: name,
|
|
264
|
+
correction,
|
|
265
|
+
error: `allowed_pattern: branch ${JSON.stringify(name)} violates ${source} policy; ${correction}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return { ok: true };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const branchPolicySnapshotFor = (workspaceRoot: string) => {
|
|
272
|
+
const resolved = effectiveWorkspacePolicy(workspaceRoot, "branch");
|
|
273
|
+
const branchPolicy = resolved.branchPolicy;
|
|
274
|
+
const source = resolved.provenance.branchPolicy;
|
|
275
|
+
const allowed = branchPolicy.allowed
|
|
276
|
+
.map((pattern) => `${pattern.source}/${pattern.flags}`)
|
|
277
|
+
.toSorted();
|
|
278
|
+
const protectedRefs = [...branchPolicy.protected].map((name) => name.toLowerCase()).toSorted();
|
|
279
|
+
const fingerprint = createHash("sha256")
|
|
280
|
+
.update(JSON.stringify({ allowed, protectedRefs, source }))
|
|
281
|
+
.digest("hex");
|
|
282
|
+
return {
|
|
283
|
+
branchPolicy,
|
|
284
|
+
namePolicy: { allowed: branchPolicy.allowed, protected: branchPolicy.protected },
|
|
285
|
+
source,
|
|
286
|
+
fingerprint,
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export const commitPolicySnapshotFor = (workspaceRoot: string) => {
|
|
291
|
+
const resolved = effectiveWorkspacePolicy(workspaceRoot, "commit");
|
|
292
|
+
const policy = resolved.commitPolicy;
|
|
293
|
+
const source = resolved.provenance.commitPolicy;
|
|
294
|
+
const fingerprint = createHash("sha256")
|
|
295
|
+
.update(JSON.stringify({ preset: policy.preset, pattern: policy.pattern ?? null, source }))
|
|
296
|
+
.digest("hex");
|
|
297
|
+
return { policy, source, fingerprint };
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export const validateCommitMessagePolicy = (
|
|
301
|
+
message: string,
|
|
302
|
+
policy: { preset: Parameters<typeof matchCommitFlavor>[1]; pattern?: string },
|
|
303
|
+
source: string,
|
|
304
|
+
): CommitMessagePolicyCheck => {
|
|
305
|
+
const attempted = message.split("\n", 1)[0] ?? "";
|
|
306
|
+
if (matchCommitFlavor(message, policy.preset, policy.pattern)) return { ok: true };
|
|
307
|
+
const correction =
|
|
308
|
+
policy.preset === "custom" && policy.pattern
|
|
309
|
+
? `use a commit subject matching ${JSON.stringify(policy.pattern)}`
|
|
310
|
+
: `use a ${policy.preset} commit subject`;
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
rule: "commit_style",
|
|
314
|
+
source,
|
|
315
|
+
attempted,
|
|
316
|
+
correction,
|
|
317
|
+
error: `commit_style: commit subject ${JSON.stringify(attempted)} violates ${source} ${policy.preset} policy; ${correction}`,
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A TypeError or ReferenceError while resolving a policy is a bug in this
|
|
323
|
+
* runtime (or a host too old for it), not a config problem the user can
|
|
324
|
+
* repair: it propagates so the hook's failure decision applies instead of a
|
|
325
|
+
* misleading "repair or select a valid workspace policy".
|
|
326
|
+
*/
|
|
327
|
+
const rethrowRuntimeBug = (error: unknown): void => {
|
|
328
|
+
if (error instanceof TypeError || error instanceof ReferenceError) throw error;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export const validateBranchNameFor = (
|
|
332
|
+
workspaceRoot: string,
|
|
333
|
+
name: string,
|
|
334
|
+
): BranchNamePolicyCheck => {
|
|
335
|
+
try {
|
|
336
|
+
const resolved = branchPolicySnapshotFor(workspaceRoot);
|
|
337
|
+
return validateBranchNamePolicy(name, resolved.namePolicy, resolved.source);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
rethrowRuntimeBug(error);
|
|
340
|
+
const correction = "repair or select a valid workspace policy before creating a branch";
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
rule: "policy_unavailable",
|
|
344
|
+
source: "workspaces.json",
|
|
345
|
+
attempted: name,
|
|
346
|
+
correction,
|
|
347
|
+
error: `policy_unavailable: cannot validate branch ${JSON.stringify(name)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export const validateCommitMessageFor = (
|
|
353
|
+
workspaceRoot: string,
|
|
354
|
+
message: string,
|
|
355
|
+
): CommitMessagePolicyCheck => {
|
|
356
|
+
const attempted = message.split("\n", 1)[0] ?? "";
|
|
357
|
+
try {
|
|
358
|
+
const resolved = commitPolicySnapshotFor(workspaceRoot);
|
|
359
|
+
return validateCommitMessagePolicy(message, resolved.policy, resolved.source);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
rethrowRuntimeBug(error);
|
|
362
|
+
const correction = "repair or select a valid workspace policy before committing";
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
rule: "policy_unavailable",
|
|
366
|
+
source: "workspaces.json",
|
|
367
|
+
attempted,
|
|
368
|
+
correction,
|
|
369
|
+
error: `policy_unavailable: cannot validate commit subject ${JSON.stringify(attempted)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** CA-09: one policy resolver; profiles layer above the matched workspace. */
|
|
375
|
+
export const resolveBranchPolicyFor = (workspaceRoot: string, profileName?: string) =>
|
|
376
|
+
effectiveWorkspacePolicy(workspaceRoot, "branch", profileName).branchPolicy;
|
|
377
|
+
|
|
378
|
+
/** Commit-flavor equivalent using the same profile and provenance chain. */
|
|
379
|
+
export const resolveCommitPolicyFor = (workspaceRoot: string, profileName?: string) =>
|
|
380
|
+
effectiveWorkspacePolicy(workspaceRoot, "commit", profileName).commitPolicy;
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// git-flow routes: where each branch kind starts and lands
|
|
384
|
+
|
|
385
|
+
export const BRANCH_KINDS = ["feature", "bugfix", "release", "hotfix"] as const;
|
|
386
|
+
export type BranchKind = (typeof BRANCH_KINDS)[number];
|
|
387
|
+
|
|
388
|
+
/** The git-flow kind a branch name's prefix names (`release/4.1.0` -> release), or null. */
|
|
389
|
+
export const branchKindOf = (name: string | null | undefined): BranchKind | null => {
|
|
390
|
+
const prefix = name?.split("/", 1)[0]?.toLowerCase() ?? "";
|
|
391
|
+
return name?.includes("/") && (BRANCH_KINDS as readonly string[]).includes(prefix)
|
|
392
|
+
? (prefix as BranchKind)
|
|
393
|
+
: null;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
export type GitFlow = {
|
|
397
|
+
/** The integration branch: the workspace's `developBranch`, else develop. */
|
|
398
|
+
develop: string;
|
|
399
|
+
/** The production branch: main, else master. */
|
|
400
|
+
production: string;
|
|
401
|
+
/** The develop branch exists locally or on origin. */
|
|
402
|
+
developExists: boolean;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
export type FlowRoute = {
|
|
406
|
+
kind: BranchKind;
|
|
407
|
+
/** Where a new branch of this kind starts. */
|
|
408
|
+
base: string;
|
|
409
|
+
/** Where its PR lands. */
|
|
410
|
+
target: string;
|
|
411
|
+
/** The branch that must receive the landed work afterwards (release/hotfix -> develop). */
|
|
412
|
+
mergeBack: string | null;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The repository's git-flow branches when the resolved branch policy is the
|
|
417
|
+
* gitflow preset; null for github-flow, trunk-based or custom (they route
|
|
418
|
+
* every kind to the default target). `integration` is the resolved default
|
|
419
|
+
* target: used as develop when the workspace names no developBranch and it
|
|
420
|
+
* is not the production branch.
|
|
421
|
+
*/
|
|
422
|
+
export const gitFlowOf = (root: string, integration?: string | null): GitFlow | null => {
|
|
423
|
+
try {
|
|
424
|
+
if (resolveBranchPolicyFor(root).preset !== "gitflow") return null;
|
|
425
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
426
|
+
root,
|
|
427
|
+
"branch",
|
|
428
|
+
process.env.WORKIT_PROFILE?.trim() || undefined,
|
|
429
|
+
).policy as WorkspaceBranchPolicy | undefined;
|
|
430
|
+
const names = branchNames(root);
|
|
431
|
+
const production = productionBranchOf(names);
|
|
432
|
+
const develop =
|
|
433
|
+
selected?.developBranch?.trim() ||
|
|
434
|
+
(integration && integration.toLowerCase() !== production ? integration : "develop");
|
|
435
|
+
return { develop, production, developExists: names.has(develop) };
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/** git-flow: feature, bugfix and release start from develop; hotfix from production. */
|
|
442
|
+
export const flowRoute = (flow: GitFlow, kind: BranchKind): FlowRoute =>
|
|
443
|
+
kind === "hotfix"
|
|
444
|
+
? { kind, base: flow.production, target: flow.production, mergeBack: flow.develop }
|
|
445
|
+
: kind === "release"
|
|
446
|
+
? { kind, base: flow.develop, target: flow.production, mergeBack: flow.develop }
|
|
447
|
+
: { kind, base: flow.develop, target: flow.develop, mergeBack: null };
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* What `workit git push --release` may push, per preset: git-flow its
|
|
451
|
+
* production and develop branches; github-flow, trunk-based and custom no
|
|
452
|
+
* branch (only tags). `production` is the branch a release tag's commit must
|
|
453
|
+
* already be on (git-flow production, else the default target). Empty when
|
|
454
|
+
* the policy cannot be read.
|
|
455
|
+
*/
|
|
456
|
+
export const releaseBranchesOf = (
|
|
457
|
+
cwd: string,
|
|
458
|
+
): { preset: string; branches: string[]; production: string | null } => {
|
|
459
|
+
try {
|
|
460
|
+
const resolved = resolveBranchPolicyFor(cwd);
|
|
461
|
+
const flow = resolved.preset === "gitflow" ? gitFlowOf(cwd) : null;
|
|
462
|
+
// Outside git-flow a protected trunk only moves through a reviewed PR:
|
|
463
|
+
// --release pushes tags there, nothing else.
|
|
464
|
+
return {
|
|
465
|
+
preset: resolved.preset,
|
|
466
|
+
branches: flow ? [flow.production, flow.develop] : [],
|
|
467
|
+
production: flow ? flow.production : resolved.defaultTargetBranch,
|
|
468
|
+
};
|
|
469
|
+
} catch {
|
|
470
|
+
return { preset: "unknown", branches: [], production: null };
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/** A branch `workit git push --release` may push here. */
|
|
475
|
+
export const releaseBranch = (cwd: string, branch: string): boolean =>
|
|
476
|
+
releaseBranchesOf(cwd).branches.some((name) => name.toLowerCase() === branch.toLowerCase());
|
|
477
|
+
|
|
478
|
+
/** `origin/develop`, `refs/remotes/origin/develop`, `refs/heads/develop` -> develop. */
|
|
479
|
+
export const shortBranch = (ref: string): string =>
|
|
480
|
+
ref.replace(/^refs\/heads\//u, "").replace(/^(?:refs\/remotes\/)?origin\//u, "");
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* A git-flow mismatch: an explicit base or PR target that is the other
|
|
484
|
+
* long-lived branch than the kind's route (a feature from main, a hotfix
|
|
485
|
+
* from develop). Only once develop exists, so a main-only repository on the
|
|
486
|
+
* gitflow preset keeps working. Null when it fits.
|
|
487
|
+
*/
|
|
488
|
+
export const flowMismatch = (
|
|
489
|
+
flow: GitFlow | null,
|
|
490
|
+
kind: BranchKind | null,
|
|
491
|
+
ref: string,
|
|
492
|
+
role: "base" | "target",
|
|
493
|
+
): string | null => {
|
|
494
|
+
if (!flow || !kind || !flow.developExists) return null;
|
|
495
|
+
const route = flowRoute(flow, kind);
|
|
496
|
+
const expected = role === "base" ? route.base : route.target;
|
|
497
|
+
const given = shortBranch(ref).toLowerCase();
|
|
498
|
+
const longLived = [flow.develop, flow.production].map((name) => name.toLowerCase());
|
|
499
|
+
// A release or hotfix PR into develop is the git-flow back-merge.
|
|
500
|
+
const backMerge = role === "target" && route.mergeBack?.toLowerCase() === given;
|
|
501
|
+
if (!longLived.includes(given) || given === expected.toLowerCase() || backMerge) return null;
|
|
502
|
+
return role === "base"
|
|
503
|
+
? `gitflow_base: git-flow starts ${kind}/* from ${expected}, not ${shortBranch(ref)}`
|
|
504
|
+
: `gitflow_target: git-flow lands ${kind}/* on ${expected}, not ${shortBranch(ref)}`;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* One line for an agent about to create a branch at `cwd`: the allowed kinds
|
|
509
|
+
* and where each starts. Plain text (no `workit git` command), so it fits an
|
|
510
|
+
* agent that runs git itself. Null when the policy cannot be read.
|
|
511
|
+
*/
|
|
512
|
+
export const branchGuidance = (cwd: string): string | null => {
|
|
513
|
+
try {
|
|
514
|
+
const patterns = branchPolicySnapshotFor(cwd).namePolicy.allowed;
|
|
515
|
+
const kinds = allowedKinds(patterns);
|
|
516
|
+
const kindShaped = new Set(kinds.map((kind) => branchGlobPattern(`${kind}/*`).source));
|
|
517
|
+
const anyName = patterns.some((pattern) => pattern.source === "^.*$");
|
|
518
|
+
const others = patterns
|
|
519
|
+
.filter((pattern) => !kindShaped.has(pattern.source))
|
|
520
|
+
.map((pattern) => globOf(pattern));
|
|
521
|
+
const allowed = anyName
|
|
522
|
+
? "Any non-protected branch name is allowed here."
|
|
523
|
+
: `${kinds.length ? `Allowed branch kinds here: ${kindList(kinds)}${aliasNote(kinds)}` : "Allowed branch names here:"}${others.length ? `${kinds.length ? "; also" : ""} names matching ${others.join(", ")}` : ""}; anything else is denied.`;
|
|
524
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
525
|
+
const integration =
|
|
526
|
+
resolved.ok === false ? null : String(resolved.defaultTargetBranch ?? "") || null;
|
|
527
|
+
const flow = resolved.releaseTrack?.track ? null : gitFlowOf(cwd, integration);
|
|
528
|
+
if (flow?.developExists)
|
|
529
|
+
return `${allowed} git-flow: feature/, bugfix/ and release/ start from origin/${flow.develop}, hotfix/ from origin/${flow.production}; feature/ and bugfix/ land on ${flow.develop}, release/ and hotfix/ on ${flow.production}.`;
|
|
530
|
+
// Name only a base that exists: a preset default (custom's develop,
|
|
531
|
+
// github-flow's main) may not.
|
|
532
|
+
const names = branchNames(cwd);
|
|
533
|
+
const configured = String(resolved.baseBranch ?? integration ?? "") || null;
|
|
534
|
+
const trunk = productionBranchOf(names);
|
|
535
|
+
const base =
|
|
536
|
+
configured && names.has(configured)
|
|
537
|
+
? `Branch from origin/${configured}.`
|
|
538
|
+
: names.has(trunk)
|
|
539
|
+
? `Branch from origin/${trunk}${configured ? ` (the default target ${configured} does not exist here)` : ""}.`
|
|
540
|
+
: "No base branch exists here yet; pass the base explicitly.";
|
|
541
|
+
return `${allowed} ${base}`;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/** An allowed pattern back as its glob (`^fix-.*$` -> fix-*). */
|
|
548
|
+
const globOf = (pattern: RegExp): string =>
|
|
549
|
+
pattern.source
|
|
550
|
+
.replace(/^\^|\$$/gu, "")
|
|
551
|
+
.replaceAll(".*", "*")
|
|
552
|
+
.replace(/\\(.)/gu, "$1");
|
|
553
|
+
|
|
554
|
+
const policy = (root: string) => resolveBranchPolicyFor(root);
|
|
555
|
+
const allowedBranch = (root: string, name: string) =>
|
|
556
|
+
policy(root).allowed.some((r) => r.test(name));
|
|
557
|
+
const isProtected = (root: string, name: string) => policy(root).protected.has(name.toLowerCase());
|
|
558
|
+
|
|
559
|
+
/** Public guardrail: protected refs fail closed anywhere, never ask. */
|
|
560
|
+
export const isProtectedTarget = (root: string, name: string): boolean => {
|
|
561
|
+
try {
|
|
562
|
+
return isProtected(root, name);
|
|
563
|
+
} catch {
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Push identity check as code: the credential actually used must belong to
|
|
570
|
+
* the workspace area account. Anything unresolvable fails closed — an
|
|
571
|
+
* unreachable host never reads as a match.
|
|
572
|
+
*/
|
|
573
|
+
export const verifyPushIdentity = (
|
|
574
|
+
root: string,
|
|
575
|
+
provider: string,
|
|
576
|
+
account: string,
|
|
577
|
+
): { ok: true } | { ok: false; error: string } => {
|
|
578
|
+
if (!account || typeof account !== "string")
|
|
579
|
+
return { ok: false, error: "push identity requires a configured area account" };
|
|
580
|
+
const identity = vcsCliIdentity(root);
|
|
581
|
+
if (!identity.ok || identity.provider !== provider)
|
|
582
|
+
return { ok: false, error: identity.error ?? "hosting CLI identity could not be resolved" };
|
|
583
|
+
if (identity.username?.toLowerCase() !== account.toLowerCase())
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
error: `push identity ${identity.username || "(unknown)"} does not match area account ${account}`,
|
|
587
|
+
};
|
|
588
|
+
return { ok: true };
|
|
589
|
+
};
|
|
590
|
+
// RL-01: malformed vcs.json blocks branch resolution with an exact-path error.
|
|
591
|
+
// With release tracks, the base is the resolved track's base branch.
|
|
592
|
+
const baseBranch = (cwd: string): { base: string; trackBranches: string[] } | { error: string } => {
|
|
593
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
594
|
+
if (resolved.ok === false) return { error: String(resolved.error) };
|
|
595
|
+
// Creating a branch on an undetermined release line would guess the base.
|
|
596
|
+
if (resolved.releaseTrack?.blocking) return { error: String(resolved.releaseTrack.blocking) };
|
|
597
|
+
return {
|
|
598
|
+
base: String(resolved.baseBranch ?? resolved.defaultTargetBranch ?? "develop"),
|
|
599
|
+
trackBranches: resolved.releaseTrack ? trackBranches(resolved.releaseTrack.tracks) : [],
|
|
600
|
+
};
|
|
601
|
+
};
|
|
602
|
+
const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
|
|
603
|
+
const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
|
|
604
|
+
// Windows portability: journal lines and stash-coverage sets carry
|
|
605
|
+
// repo-relative paths, which must match git's POSIX separator output even
|
|
606
|
+
// though path.join emits platform separators.
|
|
607
|
+
const readSafe = (p: string): string | null => {
|
|
608
|
+
try {
|
|
609
|
+
return readFileSync(p, "utf8");
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const normalizeBranch = (root: string, name: string): string | null => {
|
|
616
|
+
const n = name.trim().replace(/`/g, "").replace(/\.+$/, "");
|
|
617
|
+
if (!validateBranchNameFor(root, n).ok) return null;
|
|
618
|
+
const parts = n
|
|
619
|
+
.toLowerCase()
|
|
620
|
+
.split("/")
|
|
621
|
+
.map((p) =>
|
|
622
|
+
p
|
|
623
|
+
.replace(/[^\w.-]+/g, "-")
|
|
624
|
+
.replace(/^-+|-+$/g, "")
|
|
625
|
+
.replace(/-{2,}/g, "-"),
|
|
626
|
+
);
|
|
627
|
+
if (parts.some((p) => !p)) return null;
|
|
628
|
+
return parts.join("/");
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const deriveSlug = (planPath: string): string => {
|
|
632
|
+
// New layout: plan lives at docs/<slug>/plan.md — slug is the dir name.
|
|
633
|
+
const dirName = path.basename(path.dirname(planPath));
|
|
634
|
+
return dirName === "." || dirName === "/" || dirName === "" ? "" : dirName;
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const deriveKind = (
|
|
638
|
+
planPath: string,
|
|
639
|
+
fallback: "feature" | "bugfix" = "feature",
|
|
640
|
+
): "feature" | "bugfix" => {
|
|
641
|
+
const slug = deriveSlug(planPath);
|
|
642
|
+
const text = readSafe(planPath) ?? "";
|
|
643
|
+
let kind = fallback;
|
|
644
|
+
if (/\bbugfix\b/i.test(slug) || /^fix-/i.test(slug)) {
|
|
645
|
+
kind = "bugfix";
|
|
646
|
+
} else {
|
|
647
|
+
const goal =
|
|
648
|
+
text
|
|
649
|
+
.split("\n")
|
|
650
|
+
.find((line) => line.startsWith("**Goal:**"))
|
|
651
|
+
?.toLowerCase() ?? "";
|
|
652
|
+
if (/\b(bugfix|bug fix)\b/.test(goal) && !/\b(feat|feature|upgrade|add)\b/.test(goal)) {
|
|
653
|
+
kind = "bugfix";
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return kind;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// Port of scripts/lib/resolve-handoff-branch.sh
|
|
660
|
+
export const resolveBranch = ({
|
|
661
|
+
spec_path,
|
|
662
|
+
plan_path,
|
|
663
|
+
workspace_root,
|
|
664
|
+
}: {
|
|
665
|
+
spec_path: string;
|
|
666
|
+
plan_path: string;
|
|
667
|
+
workspace_root: string;
|
|
668
|
+
}) => {
|
|
669
|
+
const cwd = path.resolve(workspace_root);
|
|
670
|
+
const abs = (p: string) => (path.isAbsolute(p) ? p : path.join(cwd, p));
|
|
671
|
+
const spec = abs(spec_path);
|
|
672
|
+
const plan = abs(plan_path);
|
|
673
|
+
const git = gitContext(cwd);
|
|
674
|
+
const current = git.branch;
|
|
675
|
+
|
|
676
|
+
const finish = (branch: string, source: string) => ({
|
|
677
|
+
branch,
|
|
678
|
+
source,
|
|
679
|
+
current_branch: current,
|
|
680
|
+
dirty: Boolean(git.status_short.trim()),
|
|
681
|
+
needs_checkout: current !== branch,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
for (const file of [spec, plan]) {
|
|
685
|
+
const text = readSafe(file);
|
|
686
|
+
if (!text) continue;
|
|
687
|
+
if (USE_CURRENT_RE.test(text)) {
|
|
688
|
+
if (!current) return { error: `use-current but HEAD ${current} is not an allowed branch` };
|
|
689
|
+
const policyCheck = validateBranchNameFor(cwd, current);
|
|
690
|
+
if (!policyCheck.ok)
|
|
691
|
+
return { error: `use-current but HEAD ${current} is not allowed: ${policyCheck.error}` };
|
|
692
|
+
return finish(current, "use-current");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (current && allowedBranch(cwd, current) && !isProtected(cwd, current))
|
|
697
|
+
return finish(current, "keep-current");
|
|
698
|
+
|
|
699
|
+
let declaredButInvalid: string | null = null;
|
|
700
|
+
for (const file of [spec, plan]) {
|
|
701
|
+
const text = readSafe(file);
|
|
702
|
+
if (!text) continue;
|
|
703
|
+
for (const match of text.matchAll(DECLARE_RE)) {
|
|
704
|
+
const normalized = normalizeBranch(cwd, match[1]);
|
|
705
|
+
if (normalized) return finish(normalized, file === spec ? "spec" : "plan");
|
|
706
|
+
declaredButInvalid ??= match[1];
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (declaredButInvalid) {
|
|
710
|
+
const policyCheck = validateBranchNameFor(cwd, declaredButInvalid);
|
|
711
|
+
return {
|
|
712
|
+
error: policyCheck.ok
|
|
713
|
+
? `declared branch ${JSON.stringify(declaredButInvalid)} is not a valid branch name`
|
|
714
|
+
: policyCheck.error,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const slug = deriveSlug(plan);
|
|
719
|
+
if (!slug) return { error: `cannot derive branch slug from plan ${plan}` };
|
|
720
|
+
const kind = deriveKind(plan);
|
|
721
|
+
return finish(`${kind}/${slug}`, "derived");
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// Port of scripts/lib/resolve-docs-branch.sh
|
|
725
|
+
export const docsBranch = ({
|
|
726
|
+
plan_path,
|
|
727
|
+
kind,
|
|
728
|
+
workspace_root,
|
|
729
|
+
}: {
|
|
730
|
+
plan_path?: string;
|
|
731
|
+
kind?: string;
|
|
732
|
+
workspace_root: string;
|
|
733
|
+
}) => {
|
|
734
|
+
const cwd = path.resolve(workspace_root);
|
|
735
|
+
const git = gitContext(cwd);
|
|
736
|
+
const current = git.branch;
|
|
737
|
+
const kindArg = (kind ?? "feature").toLowerCase();
|
|
738
|
+
const baseResolved = baseBranch(cwd);
|
|
739
|
+
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
740
|
+
const base = baseResolved.base;
|
|
741
|
+
|
|
742
|
+
if (
|
|
743
|
+
current === base ||
|
|
744
|
+
current === "main" ||
|
|
745
|
+
current === "master" ||
|
|
746
|
+
current === "develop" ||
|
|
747
|
+
baseResolved.trackBranches.includes(current)
|
|
748
|
+
) {
|
|
749
|
+
let slug = "";
|
|
750
|
+
if (plan_path) {
|
|
751
|
+
const plan = path.isAbsolute(plan_path) ? plan_path : path.join(cwd, plan_path);
|
|
752
|
+
slug = deriveSlug(plan);
|
|
753
|
+
}
|
|
754
|
+
if (!slug) {
|
|
755
|
+
return {
|
|
756
|
+
error: "plan_path required to derive branch slug when not on feature/* or bugfix/*",
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
const branchKind = kindArg === "bugfix" ? "bugfix" : "feature";
|
|
760
|
+
return {
|
|
761
|
+
branch: `${branchKind}/${slug}`,
|
|
762
|
+
action: base === "develop" ? "create_from_develop" : "create_from_base",
|
|
763
|
+
current_branch: current,
|
|
764
|
+
base,
|
|
765
|
+
dirty: Boolean(git.status_short.trim()),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (current && allowedBranch(cwd, current) && !isProtected(cwd, current)) {
|
|
769
|
+
return {
|
|
770
|
+
branch: current,
|
|
771
|
+
action: "keep",
|
|
772
|
+
current_branch: current,
|
|
773
|
+
base,
|
|
774
|
+
dirty: Boolean(git.status_short.trim()),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return { error: `cannot resolve docs branch from HEAD ${JSON.stringify(current)}` };
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// Read-only half of ensureBaseBranch (fetch --prune + show-ref origin/base):
|
|
781
|
+
// safe to run before any mutation so a missing origin/<base> fails before a
|
|
782
|
+
// stash push empties the tree.
|
|
783
|
+
const originBaseReady = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
784
|
+
const run = (args: string[]) =>
|
|
785
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
786
|
+
try {
|
|
787
|
+
try {
|
|
788
|
+
run(["fetch", "origin", base, "--prune"]);
|
|
789
|
+
} catch {
|
|
790
|
+
run(["fetch", "origin", "--prune"]);
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${base}`], {
|
|
794
|
+
cwd,
|
|
795
|
+
stdio: "pipe",
|
|
796
|
+
});
|
|
797
|
+
} catch {
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
error: `origin/${base} missing — push ${base} before creating feature/* or bugfix/* branches`,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
return { ok: true };
|
|
804
|
+
} catch (error) {
|
|
805
|
+
return {
|
|
806
|
+
ok: false,
|
|
807
|
+
error: error instanceof Error ? error.message : "ensure-base-branch failed",
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// Mutating half of ensureBaseBranch: fast-forwards the local base (creating
|
|
813
|
+
// it from origin/<base> if needed). Only safe on a clean tree.
|
|
814
|
+
const fastForwardBase = (
|
|
815
|
+
cwd: string,
|
|
816
|
+
base: string,
|
|
817
|
+
expectedRemoteBase?: string,
|
|
818
|
+
expectedLocalBase?: string | null,
|
|
819
|
+
): { ok: boolean; error?: string } => {
|
|
820
|
+
const run = (args: string[]) =>
|
|
821
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
822
|
+
try {
|
|
823
|
+
let hasLocalBase = true;
|
|
824
|
+
try {
|
|
825
|
+
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${base}`], {
|
|
826
|
+
cwd,
|
|
827
|
+
stdio: "pipe",
|
|
828
|
+
});
|
|
829
|
+
} catch {
|
|
830
|
+
hasLocalBase = false;
|
|
831
|
+
}
|
|
832
|
+
if (expectedLocalBase !== undefined) {
|
|
833
|
+
let localBase: string | null = null;
|
|
834
|
+
try {
|
|
835
|
+
localBase = run(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
|
|
836
|
+
} catch {}
|
|
837
|
+
if (localBase !== expectedLocalBase)
|
|
838
|
+
return { ok: false, error: "approved local base changed before branch setup" };
|
|
839
|
+
}
|
|
840
|
+
if (hasLocalBase) {
|
|
841
|
+
run(["checkout", base]);
|
|
842
|
+
if (expectedLocalBase && run(["rev-parse", "HEAD"]).trim() !== expectedLocalBase)
|
|
843
|
+
return { ok: false, error: "approved local base changed before branch setup" };
|
|
844
|
+
try {
|
|
845
|
+
run(["merge", "--ff-only", expectedRemoteBase ?? `origin/${base}`]);
|
|
846
|
+
} catch {
|
|
847
|
+
/* non-fast-forward: keep local */
|
|
848
|
+
}
|
|
849
|
+
} else if (expectedRemoteBase) {
|
|
850
|
+
run(["checkout", "-b", base, expectedRemoteBase]);
|
|
851
|
+
run(["branch", "--set-upstream-to", `origin/${base}`, base]);
|
|
852
|
+
} else {
|
|
853
|
+
run(["checkout", "-b", base, "--track", `origin/${base}`]);
|
|
854
|
+
}
|
|
855
|
+
return { ok: true };
|
|
856
|
+
} catch (error) {
|
|
857
|
+
return {
|
|
858
|
+
ok: false,
|
|
859
|
+
error: error instanceof Error ? error.message : "ensure-base-branch failed",
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
865
|
+
const git = gitContext(cwd);
|
|
866
|
+
if (!git.branch || git.branch === "unknown")
|
|
867
|
+
return { ok: false, error: "not in a git repository" };
|
|
868
|
+
const ready = originBaseReady(cwd, base);
|
|
869
|
+
if (!ready.ok) return ready;
|
|
870
|
+
return fastForwardBase(cwd, base);
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
export type BranchSetupResult =
|
|
874
|
+
| {
|
|
875
|
+
action: "setup";
|
|
876
|
+
ok: true;
|
|
877
|
+
branch: string;
|
|
878
|
+
previous_branch: string;
|
|
879
|
+
stash_ref: string | null;
|
|
880
|
+
manifest: string;
|
|
881
|
+
}
|
|
882
|
+
| { action: "reapply_stash"; ok: true }
|
|
883
|
+
| { error: string; phase: "preflight" | "post_mutation" };
|
|
884
|
+
|
|
885
|
+
// Port of scripts/branch/setup-branch.sh
|
|
886
|
+
export type BranchDirt = "clean" | "carry" | "stash-required";
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Classify working-tree dirt for branch setup. Untracked files ride along
|
|
890
|
+
* (`git switch -c` never touches them), and tracked modifications confined
|
|
891
|
+
* to `docs/` are branch-owned by construction in this workflow — both
|
|
892
|
+
* auto-carry with no stash question. Anything else keeps the stash-bound
|
|
893
|
+
* proposal, since the base-checkout sequence could disturb it. Any
|
|
894
|
+
* inspection failure reads as clean so a broken git surfaces at its own
|
|
895
|
+
* error instead of inventing a stash demand.
|
|
896
|
+
*/
|
|
897
|
+
export const classifyBranchDirt = (workspaceRoot: string): BranchDirt => {
|
|
898
|
+
let raw: string;
|
|
899
|
+
try {
|
|
900
|
+
raw = execFileSync("git", ["status", "--porcelain=v1"], {
|
|
901
|
+
cwd: path.resolve(workspaceRoot),
|
|
902
|
+
encoding: "utf8",
|
|
903
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
904
|
+
});
|
|
905
|
+
} catch {
|
|
906
|
+
return "clean";
|
|
907
|
+
}
|
|
908
|
+
let dirty = false;
|
|
909
|
+
for (const line of raw.split("\n")) {
|
|
910
|
+
if (!line.trim()) continue;
|
|
911
|
+
dirty = true;
|
|
912
|
+
const code = line.slice(0, 2);
|
|
913
|
+
if (code === "??") continue;
|
|
914
|
+
let entry = line.slice(3);
|
|
915
|
+
const arrow = entry.indexOf(" -> ");
|
|
916
|
+
if (arrow >= 0) entry = entry.slice(arrow + 4);
|
|
917
|
+
const unquoted = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
|
|
918
|
+
if (unquoted !== "docs" && !unquoted.startsWith("docs/")) return "stash-required";
|
|
919
|
+
}
|
|
920
|
+
return dirty ? "carry" : "clean";
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
/** The tracked changes outside docs/ that block `workit git branch`, at most 8, as one line. */
|
|
924
|
+
export const dirtyPaths = (workspaceRoot: string): string => {
|
|
925
|
+
let raw = "";
|
|
926
|
+
try {
|
|
927
|
+
raw = execFileSync("git", ["status", "--porcelain=v1"], {
|
|
928
|
+
cwd: path.resolve(workspaceRoot),
|
|
929
|
+
encoding: "utf8",
|
|
930
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
931
|
+
});
|
|
932
|
+
} catch {}
|
|
933
|
+
const paths = raw
|
|
934
|
+
.split("\n")
|
|
935
|
+
.filter((line) => line.trim() && !line.startsWith("??"))
|
|
936
|
+
.map((line) => line.slice(3).split(" -> ").pop() ?? "")
|
|
937
|
+
.filter((entry) => entry !== "docs" && !entry.startsWith("docs/"));
|
|
938
|
+
const shown = paths.slice(0, 8).join(", ");
|
|
939
|
+
return paths.length > 8 ? `${shown} and ${paths.length - 8} more` : shown;
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
export const branchSetup = ({
|
|
943
|
+
action,
|
|
944
|
+
sdd_dir,
|
|
945
|
+
target_branch,
|
|
946
|
+
stash,
|
|
947
|
+
workspace_root,
|
|
948
|
+
log,
|
|
949
|
+
expected_remote_base,
|
|
950
|
+
expected_local_base,
|
|
951
|
+
}: {
|
|
952
|
+
action?: string;
|
|
953
|
+
sdd_dir?: string;
|
|
954
|
+
target_branch?: string;
|
|
955
|
+
stash?: string;
|
|
956
|
+
workspace_root: string;
|
|
957
|
+
log?: (message: string) => void;
|
|
958
|
+
expected_remote_base?: string;
|
|
959
|
+
expected_local_base?: string | null;
|
|
960
|
+
}): BranchSetupResult => {
|
|
961
|
+
const cwd = path.resolve(workspace_root);
|
|
962
|
+
const exec = (args: string[]): string =>
|
|
963
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
964
|
+
const current = gitContext(cwd).branch;
|
|
965
|
+
if (!current || current === "unknown")
|
|
966
|
+
return { error: "not in a git repository", phase: "preflight" };
|
|
967
|
+
const sdd = sdd_dir ?? "docs";
|
|
968
|
+
const manifestPath = path.isAbsolute(sdd)
|
|
969
|
+
? path.join(sdd, "manifest.json")
|
|
970
|
+
: path.join(cwd, sdd, "manifest.json");
|
|
971
|
+
const ensureManifestDir = () => {
|
|
972
|
+
const dir = path.dirname(manifestPath);
|
|
973
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
974
|
+
};
|
|
975
|
+
const readManifest = (): Record<string, unknown> => {
|
|
976
|
+
try {
|
|
977
|
+
return JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
978
|
+
} catch {
|
|
979
|
+
return {};
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
const writeManifest = (data: Record<string, unknown>) => {
|
|
983
|
+
ensureManifestDir();
|
|
984
|
+
writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const journal = (message: string) => log?.(`branch-setup: ${message}`);
|
|
988
|
+
|
|
989
|
+
if (action === "reapply_stash") {
|
|
990
|
+
const manifest = readManifest();
|
|
991
|
+
const ref = manifest.stash_ref;
|
|
992
|
+
if (!ref) return { error: "no stash_ref in manifest", phase: "preflight" };
|
|
993
|
+
journal(`pre-pop: ${String(ref)}`);
|
|
994
|
+
try {
|
|
995
|
+
exec(["stash", "pop", String(ref)]);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
journal("pop: failed");
|
|
998
|
+
return {
|
|
999
|
+
error: error instanceof Error ? error.message : "stash pop failed",
|
|
1000
|
+
phase: "post_mutation",
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
journal("pop: ok");
|
|
1004
|
+
delete manifest.stash_ref;
|
|
1005
|
+
delete manifest.stash_created_at;
|
|
1006
|
+
writeManifest(manifest);
|
|
1007
|
+
return { action: "reapply_stash", ok: true };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const target = target_branch ?? "";
|
|
1011
|
+
if (!target)
|
|
1012
|
+
return {
|
|
1013
|
+
error: "target_branch (the working branch to create or switch to) is required for setup",
|
|
1014
|
+
phase: "preflight",
|
|
1015
|
+
};
|
|
1016
|
+
if (expected_remote_base && !/^[a-f0-9]{40,64}$/i.test(expected_remote_base))
|
|
1017
|
+
return { error: "approved remote base commit is invalid", phase: "preflight" };
|
|
1018
|
+
try {
|
|
1019
|
+
exec(["check-ref-format", "--branch", target]);
|
|
1020
|
+
} catch {
|
|
1021
|
+
return { error: `invalid Git branch name ${target}`, phase: "preflight" };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// CA-02: resolve the base up front so an unresolvable base fails before
|
|
1025
|
+
// any mutation. The origin/<base> validation runs after the stash gate
|
|
1026
|
+
// below but still BEFORE any mutation (no snapshot, no stash push).
|
|
1027
|
+
let base: string | undefined;
|
|
1028
|
+
let targetExists = true;
|
|
1029
|
+
try {
|
|
1030
|
+
exec(["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]);
|
|
1031
|
+
} catch {
|
|
1032
|
+
targetExists = false;
|
|
1033
|
+
}
|
|
1034
|
+
if (!targetExists) {
|
|
1035
|
+
const namePolicy = validateBranchNameFor(cwd, target);
|
|
1036
|
+
if (!namePolicy.ok) return { error: namePolicy.error, phase: "preflight" };
|
|
1037
|
+
}
|
|
1038
|
+
if (!targetExists) {
|
|
1039
|
+
const baseResolved = baseBranch(cwd);
|
|
1040
|
+
if ("error" in baseResolved) return { error: baseResolved.error, phase: "preflight" };
|
|
1041
|
+
base = baseResolved.base;
|
|
1042
|
+
}
|
|
1043
|
+
journal(`entry: current=${current} target=${target} base=${base ?? "-"}`);
|
|
1044
|
+
|
|
1045
|
+
let stash_ref: string | undefined;
|
|
1046
|
+
// Best-effort restore; if the pop itself fails, the caller's error gains a
|
|
1047
|
+
// suffix pointing at the stash so stranded work stays discoverable.
|
|
1048
|
+
const failAfterStash = (
|
|
1049
|
+
message: string,
|
|
1050
|
+
phase: "preflight" | "post_mutation" = "post_mutation",
|
|
1051
|
+
): { error: string; phase: "preflight" | "post_mutation" } => {
|
|
1052
|
+
let suffix = "";
|
|
1053
|
+
if (stash_ref) {
|
|
1054
|
+
journal(`pre-pop: ${stash_ref}`);
|
|
1055
|
+
try {
|
|
1056
|
+
exec(["stash", "pop", stash_ref]);
|
|
1057
|
+
stash_ref = undefined;
|
|
1058
|
+
journal("pop: ok");
|
|
1059
|
+
} catch {
|
|
1060
|
+
journal("pop: failed");
|
|
1061
|
+
suffix = " (changes preserved in stash)";
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return { error: `${message}${suffix}`, phase: stash_ref ? "post_mutation" : phase };
|
|
1065
|
+
};
|
|
1066
|
+
if (current !== target) {
|
|
1067
|
+
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
1068
|
+
if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
|
|
1069
|
+
return {
|
|
1070
|
+
error:
|
|
1071
|
+
"dirty working tree — obtain native host approval before stashing and setting up the branch",
|
|
1072
|
+
phase: "preflight",
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
// CA-02: validate origin/<base> before ANY mutation (no snapshot, no
|
|
1076
|
+
// stash push, no checkout) so a missing origin/<base> fails with the
|
|
1077
|
+
// tree untouched. The mutating fast-forward stays below: it checks out
|
|
1078
|
+
// the base branch — unsafe on a dirty tree.
|
|
1079
|
+
let validatedBase: string | undefined;
|
|
1080
|
+
let validatedRemoteBase: string | undefined;
|
|
1081
|
+
let validatedLocalBase: string | null | undefined;
|
|
1082
|
+
if (!targetExists && base !== undefined) {
|
|
1083
|
+
const ready = originBaseReady(cwd, base);
|
|
1084
|
+
if (!ready.ok)
|
|
1085
|
+
return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
|
|
1086
|
+
const fetchedBase = exec(["rev-parse", "--verify", `refs/remotes/origin/${base}`]).trim();
|
|
1087
|
+
if (expected_remote_base && fetchedBase !== expected_remote_base)
|
|
1088
|
+
return { error: "approved remote base changed before branch setup", phase: "preflight" };
|
|
1089
|
+
if (expected_local_base !== undefined) {
|
|
1090
|
+
let localBase: string | null = null;
|
|
1091
|
+
try {
|
|
1092
|
+
localBase = exec(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
|
|
1093
|
+
} catch {}
|
|
1094
|
+
if (localBase !== expected_local_base)
|
|
1095
|
+
return { error: "approved local base changed before branch setup", phase: "preflight" };
|
|
1096
|
+
validatedLocalBase = expected_local_base;
|
|
1097
|
+
}
|
|
1098
|
+
if (expected_remote_base) validatedRemoteBase = expected_remote_base;
|
|
1099
|
+
validatedBase = base;
|
|
1100
|
+
}
|
|
1101
|
+
if (dirty && stash === "yes") {
|
|
1102
|
+
try {
|
|
1103
|
+
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
return {
|
|
1106
|
+
error: error instanceof Error ? error.message : "stash push failed",
|
|
1107
|
+
phase: "post_mutation",
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
stash_ref = "stash@{0}";
|
|
1111
|
+
journal(`stash push: ${stash_ref}`);
|
|
1112
|
+
}
|
|
1113
|
+
try {
|
|
1114
|
+
exec(["checkout", target]);
|
|
1115
|
+
journal("post-checkout");
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
const message = error instanceof Error ? error.message : "checkout failed";
|
|
1118
|
+
if (/worktree/i.test(message)) {
|
|
1119
|
+
return failAfterStash(
|
|
1120
|
+
`branch ${target} is locked by an existing git worktree — remove it first (we do not use worktrees)`,
|
|
1121
|
+
"preflight",
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
try {
|
|
1125
|
+
let effectiveBase = base;
|
|
1126
|
+
if (effectiveBase === undefined) {
|
|
1127
|
+
const lateResolved = baseBranch(cwd);
|
|
1128
|
+
if ("error" in lateResolved) return failAfterStash(lateResolved.error, "preflight");
|
|
1129
|
+
effectiveBase = lateResolved.base;
|
|
1130
|
+
}
|
|
1131
|
+
// Pre-validated above when the target was missing: only the
|
|
1132
|
+
// fast-forward mutation remains post-stash.
|
|
1133
|
+
const baseResult =
|
|
1134
|
+
validatedBase !== undefined
|
|
1135
|
+
? fastForwardBase(cwd, effectiveBase, validatedRemoteBase, validatedLocalBase)
|
|
1136
|
+
: ensureBaseBranch(cwd, effectiveBase);
|
|
1137
|
+
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
1138
|
+
const targetBase = exec(["rev-parse", "HEAD"]).trim();
|
|
1139
|
+
if (
|
|
1140
|
+
validatedRemoteBase &&
|
|
1141
|
+
validatedLocalBase !== undefined &&
|
|
1142
|
+
![validatedRemoteBase, validatedLocalBase].includes(targetBase)
|
|
1143
|
+
)
|
|
1144
|
+
return failAfterStash("approved branch base changed before branch creation", "preflight");
|
|
1145
|
+
exec(["checkout", "-b", target, targetBase]);
|
|
1146
|
+
journal("post-create");
|
|
1147
|
+
} catch (createError) {
|
|
1148
|
+
return failAfterStash(
|
|
1149
|
+
createError instanceof Error ? createError.message : "branch create failed",
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
try {
|
|
1156
|
+
const manifest = readManifest();
|
|
1157
|
+
manifest.branch = target;
|
|
1158
|
+
manifest.previous_branch = current;
|
|
1159
|
+
if (stash_ref) {
|
|
1160
|
+
manifest.stash_ref = stash_ref;
|
|
1161
|
+
manifest.stash_created_at = new Date().toISOString();
|
|
1162
|
+
}
|
|
1163
|
+
writeManifest(manifest);
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
const result = failAfterStash(
|
|
1166
|
+
error instanceof Error
|
|
1167
|
+
? `manifest update failed: ${error.message}`
|
|
1168
|
+
: "manifest update failed",
|
|
1169
|
+
);
|
|
1170
|
+
// After a successful pop, don't strand HEAD on the half-created target:
|
|
1171
|
+
// return to the originating branch (best-effort; a conflicting tree can
|
|
1172
|
+
// still refuse the checkout and keeps the popped state).
|
|
1173
|
+
if (stash_ref === undefined && gitContext(cwd).branch !== current) {
|
|
1174
|
+
try {
|
|
1175
|
+
exec(["checkout", current]);
|
|
1176
|
+
} catch {
|
|
1177
|
+
try {
|
|
1178
|
+
exec(["checkout", "-m", current]);
|
|
1179
|
+
} catch {}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return result;
|
|
1183
|
+
}
|
|
1184
|
+
return {
|
|
1185
|
+
action: "setup",
|
|
1186
|
+
ok: true,
|
|
1187
|
+
branch: target,
|
|
1188
|
+
previous_branch: current,
|
|
1189
|
+
stash_ref: stash_ref ?? null,
|
|
1190
|
+
manifest: manifestPath,
|
|
1191
|
+
};
|
|
1192
|
+
};
|