@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,1745 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import {
|
|
7
|
+
COMMIT_PRESETS,
|
|
8
|
+
PRESETS,
|
|
9
|
+
configDir,
|
|
10
|
+
isConfigObject,
|
|
11
|
+
readConfig,
|
|
12
|
+
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
13
|
+
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
14
|
+
type BranchPreset,
|
|
15
|
+
type ToolkitConfig,
|
|
16
|
+
} from "./config";
|
|
17
|
+
import type { CommitFlavorPreset } from "./commit-flavors";
|
|
18
|
+
import { repoBranchPreset } from "./branch-policy";
|
|
19
|
+
import { remotePattern } from "./remotes";
|
|
20
|
+
|
|
21
|
+
export type VcsProvider = "gitlab" | "github";
|
|
22
|
+
|
|
23
|
+
export type IntegrationMode = "pr" | "merge";
|
|
24
|
+
|
|
25
|
+
export const nonBlank = z.string().refine((value) => value.trim().length > 0, "must not be blank");
|
|
26
|
+
export const branchPreset = z.custom<BranchPreset>(
|
|
27
|
+
(value) => typeof value === "string" && Object.hasOwn(PRESETS, value),
|
|
28
|
+
"unsupported branch preset",
|
|
29
|
+
);
|
|
30
|
+
export const commitPreset = z.custom<CommitFlavorPreset>(
|
|
31
|
+
(value) => typeof value === "string" && COMMIT_PRESETS.includes(value),
|
|
32
|
+
"unsupported commit preset",
|
|
33
|
+
);
|
|
34
|
+
const workspaceBranchPolicySchema = z
|
|
35
|
+
.object({
|
|
36
|
+
preset: branchPreset.describe(
|
|
37
|
+
"The branch model: gitflow, github-flow, trunk-based or custom (allowed/protected set by hand).",
|
|
38
|
+
),
|
|
39
|
+
developBranch: nonBlank
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("The integration branch gitflow uses (default develop)."),
|
|
42
|
+
prefixes: z
|
|
43
|
+
.object({
|
|
44
|
+
feature: nonBlank,
|
|
45
|
+
bugfix: nonBlank,
|
|
46
|
+
release: nonBlank,
|
|
47
|
+
hotfix: nonBlank,
|
|
48
|
+
})
|
|
49
|
+
.passthrough()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("The branch prefix per kind (feature/, bugfix/, release/, hotfix/)."),
|
|
52
|
+
allowed: z
|
|
53
|
+
.array(nonBlank)
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Branch name globs a new branch must match (`feature/*`)."),
|
|
56
|
+
protected: z
|
|
57
|
+
.array(nonBlank)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Branches nobody commits or pushes to directly."),
|
|
60
|
+
integration: z
|
|
61
|
+
.enum(["pr", "merge"])
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("How work reaches its base: `pr` (default) opens a PR, `merge` merges locally."),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
66
|
+
const workspaceCommitPolicySchema = z
|
|
67
|
+
.object({
|
|
68
|
+
preset: commitPreset.describe("The commit message flavor, e.g. conventional."),
|
|
69
|
+
pattern: z
|
|
70
|
+
.string()
|
|
71
|
+
.optional()
|
|
72
|
+
.describe("A regular expression every commit subject must match."),
|
|
73
|
+
})
|
|
74
|
+
.strict();
|
|
75
|
+
const workspaceProfileSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
branchPolicy: workspaceBranchPolicySchema.optional().describe("This profile's branch policy."),
|
|
78
|
+
commitPolicy: workspaceCommitPolicySchema.optional().describe("This profile's commit policy."),
|
|
79
|
+
})
|
|
80
|
+
.strict()
|
|
81
|
+
.refine((profile) => profile.branchPolicy !== undefined || profile.commitPolicy !== undefined, {
|
|
82
|
+
message: "a profile must define branchPolicy or commitPolicy",
|
|
83
|
+
});
|
|
84
|
+
const versionSourceSchema = z.discriminatedUnion("kind", [
|
|
85
|
+
z
|
|
86
|
+
.object({
|
|
87
|
+
kind: z.literal("package-json"),
|
|
88
|
+
path: nonBlank,
|
|
89
|
+
field: nonBlank,
|
|
90
|
+
})
|
|
91
|
+
.strict(),
|
|
92
|
+
z.object({ kind: z.literal("git-tag") }).strict(),
|
|
93
|
+
z.object({ kind: z.literal("manual") }).strict(),
|
|
94
|
+
]);
|
|
95
|
+
// Only the production and integration branches are required: runtime
|
|
96
|
+
// (core/release-tracks.ts) defaults the rest. Unknown keys pass through (D17:
|
|
97
|
+
// a newer Workit's field must not invalidate the file for an older one); a
|
|
98
|
+
// track lists the ones an older reader must not ignore in `critical`.
|
|
99
|
+
export const releaseTrackSchema = z
|
|
100
|
+
.object({
|
|
101
|
+
strategy: branchPreset.optional().describe("The branch model this track follows."),
|
|
102
|
+
productionBranch: nonBlank.describe("The branch releases land on (main)."),
|
|
103
|
+
integrationBranch: nonBlank.describe("The branch work integrates into (develop)."),
|
|
104
|
+
naming: z
|
|
105
|
+
.object({
|
|
106
|
+
feature: nonBlank.optional(),
|
|
107
|
+
release: nonBlank.optional(),
|
|
108
|
+
hotfix: nonBlank.optional(),
|
|
109
|
+
})
|
|
110
|
+
.passthrough()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("Branch name templates per kind, e.g. `feature/{name}`."),
|
|
113
|
+
baseBranch: nonBlank.optional().describe("The branch new work starts from."),
|
|
114
|
+
mergeBackBranches: z
|
|
115
|
+
.array(nonBlank)
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Branches a finished release or hotfix is merged back into."),
|
|
118
|
+
pullRequestTarget: nonBlank.optional().describe("The branch PRs target."),
|
|
119
|
+
tagNamespace: z.string().optional().describe("A prefix for this track's release tags."),
|
|
120
|
+
versionSource: versionSourceSchema
|
|
121
|
+
.optional()
|
|
122
|
+
.describe("Where the version comes from: package-json, git-tag or manual."),
|
|
123
|
+
requiredChecks: z
|
|
124
|
+
.array(nonBlank)
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("CI checks that must pass before a release."),
|
|
127
|
+
critical: z
|
|
128
|
+
.array(nonBlank)
|
|
129
|
+
.optional()
|
|
130
|
+
.describe(
|
|
131
|
+
"Fields an older Workit must not ignore: when one is unknown or malformed, Workit fails closed.",
|
|
132
|
+
),
|
|
133
|
+
// Read leniently (workspaceReleaseNotes).
|
|
134
|
+
releaseNotes: z
|
|
135
|
+
.unknown()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("This track's release notes mode, over the workspace's `releaseNotes`."),
|
|
138
|
+
})
|
|
139
|
+
.passthrough()
|
|
140
|
+
.superRefine((track, context) => {
|
|
141
|
+
for (const [field, values] of [
|
|
142
|
+
["mergeBackBranches", track.mergeBackBranches ?? []],
|
|
143
|
+
["requiredChecks", track.requiredChecks ?? []],
|
|
144
|
+
] as const) {
|
|
145
|
+
if (new Set(values).size !== values.length) {
|
|
146
|
+
context.addIssue({
|
|
147
|
+
code: "custom",
|
|
148
|
+
path: [field],
|
|
149
|
+
message: "entries must be unique",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
track.versionSource?.kind === "package-json" &&
|
|
155
|
+
(path.posix.isAbsolute(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
156
|
+
/^[A-Za-z]:\//u.test(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
157
|
+
track.versionSource.path.replaceAll("\\", "/").split("/").includes(".."))
|
|
158
|
+
) {
|
|
159
|
+
context.addIssue({
|
|
160
|
+
code: "custom",
|
|
161
|
+
path: ["versionSource", "path"],
|
|
162
|
+
message: "must stay inside the repository",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
// Read leniently (core/role-models.ts); one instance so the editor schema can name it.
|
|
167
|
+
export const modelRoles = z
|
|
168
|
+
.unknown()
|
|
169
|
+
.optional()
|
|
170
|
+
.describe(
|
|
171
|
+
"Per role (decider, implementer, verifier, reviewer, explore, babysit, default for a general-purpose spawn): a model name, `{ model, effort }`, or one of those per host (claude_code, opencode, codex, codex_cli, codex_desktop, cursor, pi, default). The implementer takes one per tier (mundane, standard, hard).",
|
|
172
|
+
);
|
|
173
|
+
export const workspaceConfigSchema = z
|
|
174
|
+
.object({
|
|
175
|
+
name: nonBlank.describe("The workspace's unique name."),
|
|
176
|
+
glob: nonBlank.describe(
|
|
177
|
+
"The directories this entry covers, an absolute path glob with * and ** (`/home/you/work/**`); the narrowest match wins.",
|
|
178
|
+
),
|
|
179
|
+
vcs: z
|
|
180
|
+
.object({
|
|
181
|
+
provider: z.enum(["gitlab", "github"]).describe("The forge: github or gitlab."),
|
|
182
|
+
defaultTargetBranch: nonBlank.optional().describe("The branch PRs target by default."),
|
|
183
|
+
tokenFile: nonBlank
|
|
184
|
+
.optional()
|
|
185
|
+
.describe("A file holding the forge token for this workspace."),
|
|
186
|
+
account: nonBlank
|
|
187
|
+
.optional()
|
|
188
|
+
.describe("The forge login delivery runs as; grants need it to reach the forge."),
|
|
189
|
+
// Read leniently (forge/resolve.ts).
|
|
190
|
+
host: z.unknown().optional().describe("A self-hosted forge's host name."),
|
|
191
|
+
})
|
|
192
|
+
.passthrough()
|
|
193
|
+
.optional()
|
|
194
|
+
.describe("The forge this workspace's repositories live on."),
|
|
195
|
+
autoApprove: z
|
|
196
|
+
.union([z.boolean(), z.array(nonBlank)])
|
|
197
|
+
.optional()
|
|
198
|
+
.describe("Legacy (≤4.x) standing approvals, read once as autonomy grants; use `autonomy`."),
|
|
199
|
+
// Read leniently (autonomy.ts endpointOf): an unknown value reads as commit.
|
|
200
|
+
defaultEndpoint: z
|
|
201
|
+
.unknown()
|
|
202
|
+
.optional()
|
|
203
|
+
.describe(
|
|
204
|
+
"Where an unnamed request stops: commit (default), pr, green or merged. Set with `workit grant set`, like grants.",
|
|
205
|
+
),
|
|
206
|
+
verification: z
|
|
207
|
+
.enum(["proportional", "self", "independent"])
|
|
208
|
+
.optional()
|
|
209
|
+
.describe(
|
|
210
|
+
"How a normal-risk behavior change is verified: proportional (default), self or independent. Set with `workit grant set`, like grants.",
|
|
211
|
+
),
|
|
212
|
+
// Read leniently (workspaceDelegation): an unknown value reads as unset.
|
|
213
|
+
delegation: z
|
|
214
|
+
.unknown()
|
|
215
|
+
.optional()
|
|
216
|
+
.describe(
|
|
217
|
+
"Who does the work: `inline-first` (default) keeps the lead working itself and refuses a slice without a reason to delegate; `lead-orchestrates` allows slices without one.",
|
|
218
|
+
),
|
|
219
|
+
// Read leniently (workspaceChangelog): an unknown value reads as off.
|
|
220
|
+
changelog: z
|
|
221
|
+
.unknown()
|
|
222
|
+
.optional()
|
|
223
|
+
.describe(
|
|
224
|
+
"The Keep a Changelog step before a PR opens: `off` (default), `update` (an existing CHANGELOG.md needs an Unreleased entry from a user-facing PR) or `create` (the entry is required, creating the file). `workit pr create` enforces it.",
|
|
225
|
+
),
|
|
226
|
+
// Read leniently like `changelog`.
|
|
227
|
+
docs: z
|
|
228
|
+
.unknown()
|
|
229
|
+
.optional()
|
|
230
|
+
.describe(
|
|
231
|
+
"The docs refresh the ship skill does before a PR opens: `off` (default) or `when-user-facing`.",
|
|
232
|
+
),
|
|
233
|
+
// Read leniently like `changelog`.
|
|
234
|
+
changelogPath: z
|
|
235
|
+
.unknown()
|
|
236
|
+
.optional()
|
|
237
|
+
.describe(
|
|
238
|
+
"Where the changelog lives, repo-relative: one path or glob, or a list (a monorepo with one changelog per package; any of them satisfies the gate). Default CHANGELOG.md at the repo root.",
|
|
239
|
+
),
|
|
240
|
+
// Read leniently (workspacePrTemplate): an unknown value reads as auto.
|
|
241
|
+
prTemplate: z
|
|
242
|
+
.unknown()
|
|
243
|
+
.optional()
|
|
244
|
+
.describe(
|
|
245
|
+
"Which template a PR description fills: `auto` (default: the repo's GitHub/GitLab template, else Workit's), `repo` (the repo's, refused when it has none), `workit` (Workit's Summary/Evidence/Merge danger) or a repo-relative path to one `.md` file.",
|
|
246
|
+
),
|
|
247
|
+
// Read leniently (workspaceReleaseNotes): an unknown value reads as auto.
|
|
248
|
+
releaseNotes: z
|
|
249
|
+
.unknown()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe(
|
|
252
|
+
"The release notes `workit release finish` publishes on the forge release: `auto` (default: the draft when there is one, else the notes built from the version's changelog section), `off` (no forge release) or `review` (finish refuses until `workit release notes approve`). A release track overrides it.",
|
|
253
|
+
),
|
|
254
|
+
trailers: z
|
|
255
|
+
.boolean()
|
|
256
|
+
.optional()
|
|
257
|
+
.describe(
|
|
258
|
+
"Whether commits get the `Workit-Session:` trailer (default true). With false, authorship lives in the ledger only.",
|
|
259
|
+
),
|
|
260
|
+
deleteBranch: z
|
|
261
|
+
.boolean()
|
|
262
|
+
.optional()
|
|
263
|
+
.describe(
|
|
264
|
+
"Whether `workit pr merge` deletes the head branch after a merge (default true); `--keep-branch` opts out for one merge.",
|
|
265
|
+
),
|
|
266
|
+
liveSessionGuard: z
|
|
267
|
+
.boolean()
|
|
268
|
+
.optional()
|
|
269
|
+
.describe(
|
|
270
|
+
"Whether the live-session guard denies edits in a checkout another live agent session owns (default true).",
|
|
271
|
+
),
|
|
272
|
+
// tierModels is read leniently (workspaceTierModel).
|
|
273
|
+
fanout: z
|
|
274
|
+
.object({
|
|
275
|
+
tierModels: z
|
|
276
|
+
.unknown()
|
|
277
|
+
.optional()
|
|
278
|
+
.describe("The model per slice TIER (mundane, standard, hard), as one name or per host."),
|
|
279
|
+
})
|
|
280
|
+
.passthrough()
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("Fanout settings."),
|
|
283
|
+
// roles is read leniently (core/role-models.ts).
|
|
284
|
+
models: z
|
|
285
|
+
.object({ roles: modelRoles })
|
|
286
|
+
.passthrough()
|
|
287
|
+
.optional()
|
|
288
|
+
.describe(
|
|
289
|
+
"Model and effort per role and host (roles), the lead's own advisory model (lead) and whether a model the lead names on a spawn wins (allowLeadOverride, default false); the same key at the top of workspaces.json is the global default.",
|
|
290
|
+
),
|
|
291
|
+
// Read leniently (core/resources.ts): an unknown or malformed field warns.
|
|
292
|
+
resources: z
|
|
293
|
+
.unknown()
|
|
294
|
+
.optional()
|
|
295
|
+
.describe(
|
|
296
|
+
"Heavy-check limits on this machine: slots, scope, heavy, light, memoryMax, cpuQuota, wait.",
|
|
297
|
+
),
|
|
298
|
+
remotes: z
|
|
299
|
+
.array(
|
|
300
|
+
nonBlank.refine(
|
|
301
|
+
(pattern) => remotePattern(pattern) !== null,
|
|
302
|
+
"is not a remote URL (https://host/owner/repo, git@host:owner/repo, a path, or owner/*)",
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
.optional()
|
|
306
|
+
.describe(
|
|
307
|
+
"Remote URLs this entry owns: a checkout pushing to or opening PRs on one never exceeds this entry's grants, wherever the folder is (`owner/*` claims a whole owner). Only the user changes it, in their own terminal.",
|
|
308
|
+
),
|
|
309
|
+
autonomy: z
|
|
310
|
+
.object(
|
|
311
|
+
Object.fromEntries(
|
|
312
|
+
["push", "pr", "merge", "release", "rerun"].map((kind) => [
|
|
313
|
+
kind,
|
|
314
|
+
z
|
|
315
|
+
.union([z.boolean(), z.literal("verified")])
|
|
316
|
+
.optional()
|
|
317
|
+
.describe(`The ${kind} grant: true, false, or "verified" (only after a verdict).`),
|
|
318
|
+
]),
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
.passthrough()
|
|
322
|
+
.optional()
|
|
323
|
+
.describe("Autonomy grants (D15): only ever set here, by the user, with `workit grant set`."),
|
|
324
|
+
youtrack: z
|
|
325
|
+
.object({
|
|
326
|
+
baseUrl: nonBlank.optional().describe("This workspace's YouTrack URL."),
|
|
327
|
+
link_issues: z
|
|
328
|
+
.boolean()
|
|
329
|
+
.optional()
|
|
330
|
+
.describe("Legacy alias of `issues: { provider: youtrack, link: true }`."),
|
|
331
|
+
})
|
|
332
|
+
.passthrough()
|
|
333
|
+
.optional()
|
|
334
|
+
.describe("YouTrack settings for this workspace."),
|
|
335
|
+
// relation, repo and host are read leniently (vcs-config.ts issueTrackerOf, D17).
|
|
336
|
+
issues: z
|
|
337
|
+
.object({
|
|
338
|
+
provider: z
|
|
339
|
+
.enum(["github", "gitlab", "youtrack"])
|
|
340
|
+
.optional()
|
|
341
|
+
.describe("The issue tracker PRs link to."),
|
|
342
|
+
link: z.boolean().optional().describe("Derive the issue from the branch name."),
|
|
343
|
+
relation: z
|
|
344
|
+
.unknown()
|
|
345
|
+
.optional()
|
|
346
|
+
.describe("How a PR names its issue: `closes` (default) or `refs`."),
|
|
347
|
+
backlink: z
|
|
348
|
+
.boolean()
|
|
349
|
+
.optional()
|
|
350
|
+
.describe("Post the PR URL on each linked issue after the PR is opened."),
|
|
351
|
+
link_on_pr: z.boolean().optional().describe("Legacy alias of `link`."),
|
|
352
|
+
repo: z
|
|
353
|
+
.unknown()
|
|
354
|
+
.optional()
|
|
355
|
+
.describe(
|
|
356
|
+
"The GitHub/GitLab repository (owner/name) that holds the issues; default the PR's own.",
|
|
357
|
+
),
|
|
358
|
+
host: z
|
|
359
|
+
.unknown()
|
|
360
|
+
.optional()
|
|
361
|
+
.describe("That repository's host; default the PR's host, or the tracker's public one."),
|
|
362
|
+
})
|
|
363
|
+
.passthrough()
|
|
364
|
+
.optional()
|
|
365
|
+
.describe("The issue tracker PRs link to (any forge with any tracker)."),
|
|
366
|
+
branchPolicy: workspaceBranchPolicySchema
|
|
367
|
+
.optional()
|
|
368
|
+
.describe("Which branches may be created and which are protected."),
|
|
369
|
+
commitPolicy: workspaceCommitPolicySchema.optional().describe("The commit message rules."),
|
|
370
|
+
defaultProfile: nonBlank
|
|
371
|
+
.optional()
|
|
372
|
+
.describe("The profile used when none is named; must be a key of `profiles`."),
|
|
373
|
+
profiles: z
|
|
374
|
+
.record(nonBlank, workspaceProfileSchema)
|
|
375
|
+
.optional()
|
|
376
|
+
.describe("Named branch/commit policy variants of this workspace."),
|
|
377
|
+
releaseTracks: z
|
|
378
|
+
.record(nonBlank, releaseTrackSchema)
|
|
379
|
+
.optional()
|
|
380
|
+
.describe(
|
|
381
|
+
"Named release tracks: production and integration branches, naming and versioning.",
|
|
382
|
+
),
|
|
383
|
+
})
|
|
384
|
+
.passthrough()
|
|
385
|
+
.superRefine((workspace, context) => {
|
|
386
|
+
if (
|
|
387
|
+
workspace.defaultProfile !== undefined &&
|
|
388
|
+
!Object.hasOwn(workspace.profiles ?? {}, workspace.defaultProfile)
|
|
389
|
+
) {
|
|
390
|
+
context.addIssue({
|
|
391
|
+
code: "custom",
|
|
392
|
+
path: ["defaultProfile"],
|
|
393
|
+
message: `does not name a configured profile`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
export const workspacesFileSchema = z
|
|
398
|
+
.object({
|
|
399
|
+
workspaces: z
|
|
400
|
+
.array(workspaceConfigSchema)
|
|
401
|
+
.optional()
|
|
402
|
+
.describe("Workspace entries, each mapping a path glob to its forge, policies and grants."),
|
|
403
|
+
models: z
|
|
404
|
+
.object({ roles: modelRoles })
|
|
405
|
+
.passthrough()
|
|
406
|
+
.optional()
|
|
407
|
+
.describe(
|
|
408
|
+
"The global default model and effort per role and host (roles), the lead's own advisory model (lead) and whether a model the lead names on a spawn wins (allowLeadOverride, default false).",
|
|
409
|
+
),
|
|
410
|
+
})
|
|
411
|
+
.passthrough()
|
|
412
|
+
.superRefine((file, context) => {
|
|
413
|
+
const names = new Set<string>();
|
|
414
|
+
for (const [index, workspace] of (file.workspaces ?? []).entries()) {
|
|
415
|
+
if (names.has(workspace.name)) {
|
|
416
|
+
context.addIssue({
|
|
417
|
+
code: "custom",
|
|
418
|
+
path: ["workspaces", index, "name"],
|
|
419
|
+
message: `duplicate workspace name ${JSON.stringify(workspace.name)}`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
names.add(workspace.name);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
export type WorkspaceBranchPolicy = z.infer<typeof workspaceBranchPolicySchema>;
|
|
427
|
+
export type WorkspaceCommitPolicy = z.infer<typeof workspaceCommitPolicySchema>;
|
|
428
|
+
export type WorkspaceProfile = z.infer<typeof workspaceProfileSchema>;
|
|
429
|
+
export type ReleaseTrack = z.infer<typeof releaseTrackSchema>;
|
|
430
|
+
export type WorkspaceConfig = z.infer<typeof workspaceConfigSchema>;
|
|
431
|
+
|
|
432
|
+
export type WorkspaceProfileResolution =
|
|
433
|
+
| {
|
|
434
|
+
status: "resolved";
|
|
435
|
+
profileName: string | null;
|
|
436
|
+
workspace: WorkspaceConfig;
|
|
437
|
+
provenance: { branchPolicy: string; commitPolicy: string };
|
|
438
|
+
}
|
|
439
|
+
| { status: "invalid"; error: string };
|
|
440
|
+
|
|
441
|
+
export type ReleaseTrackSelection =
|
|
442
|
+
| { status: "selected"; name: string; track: ReleaseTrack }
|
|
443
|
+
| { status: "not_configured" }
|
|
444
|
+
| { status: "choice_required"; choices: string[] }
|
|
445
|
+
| { status: "invalid"; error: string };
|
|
446
|
+
|
|
447
|
+
export type WorkspacePolicyResolution = {
|
|
448
|
+
status: "resolved";
|
|
449
|
+
workspace: WorkspaceConfig | null;
|
|
450
|
+
profileName: string | null;
|
|
451
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
452
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
453
|
+
provenance: { branchPolicy: string; commitPolicy: string };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
export const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
|
|
457
|
+
|
|
458
|
+
// RL-01: typed workspaces reader. Missing is a legitimate unconfigured state
|
|
459
|
+
// (empty list); malformed JSON is reported with the exact path so risky
|
|
460
|
+
// consumers (wizard/installer/doctor) can block instead of silently resetting.
|
|
461
|
+
export type WorkspacesResult = {
|
|
462
|
+
status: "missing" | "valid" | "malformed" | "invalid";
|
|
463
|
+
path: string;
|
|
464
|
+
entries: WorkspaceConfig[];
|
|
465
|
+
document?: Record<string, unknown>;
|
|
466
|
+
revision?: string;
|
|
467
|
+
error?: string;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
export const workspacesRevision = (contents: string | null): string =>
|
|
471
|
+
createHash("sha256")
|
|
472
|
+
.update(contents ?? "<missing>")
|
|
473
|
+
.digest("hex");
|
|
474
|
+
|
|
475
|
+
export const validateWorkspacesDocument = (parsed: unknown, file: string): WorkspacesResult => {
|
|
476
|
+
if (!isConfigObject(parsed)) {
|
|
477
|
+
return {
|
|
478
|
+
status: "malformed",
|
|
479
|
+
path: file,
|
|
480
|
+
entries: [],
|
|
481
|
+
error: `${file} is not a JSON object`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const validated = workspacesFileSchema.safeParse(parsed, {
|
|
485
|
+
reportInput: true,
|
|
486
|
+
});
|
|
487
|
+
if (!validated.success) {
|
|
488
|
+
const detail = validated.error.issues
|
|
489
|
+
.map((issue) => {
|
|
490
|
+
const field = String(issue.path[issue.path.length - 1] ?? "");
|
|
491
|
+
const attempted =
|
|
492
|
+
["provider", "preset", "strategy"].includes(field) && typeof issue.input === "string"
|
|
493
|
+
? ` (${JSON.stringify(issue.input)})`
|
|
494
|
+
: "";
|
|
495
|
+
return `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}${attempted}`;
|
|
496
|
+
})
|
|
497
|
+
.join("; ");
|
|
498
|
+
return {
|
|
499
|
+
status: "invalid",
|
|
500
|
+
path: file,
|
|
501
|
+
entries: [],
|
|
502
|
+
error: `${file} has invalid workspace configuration: ${detail}`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
for (const [index, workspace] of (validated.data.workspaces ?? []).entries()) {
|
|
506
|
+
const glob = validateWorkspaceGlob(workspace.glob);
|
|
507
|
+
if (!glob.ok) {
|
|
508
|
+
return {
|
|
509
|
+
status: "invalid",
|
|
510
|
+
path: file,
|
|
511
|
+
entries: [],
|
|
512
|
+
error: `${file} workspaces.${index}.glob: ${glob.error}`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// The schema has no transforms, so the parsed entries hold the same values;
|
|
517
|
+
// returning them (not zod's output) keeps the user's key order, so a write
|
|
518
|
+
// that leaves an entry alone keeps it byte-for-byte (#444).
|
|
519
|
+
const document = parsed as Record<string, unknown>;
|
|
520
|
+
return {
|
|
521
|
+
status: "valid",
|
|
522
|
+
path: file,
|
|
523
|
+
entries: (document.workspaces as WorkspaceConfig[] | undefined) ?? [],
|
|
524
|
+
document,
|
|
525
|
+
};
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResult => {
|
|
529
|
+
const file = path.join(dir, "workspaces.json");
|
|
530
|
+
let raw: string;
|
|
531
|
+
try {
|
|
532
|
+
raw = readFileSync(file, "utf8");
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
535
|
+
return {
|
|
536
|
+
status: "missing",
|
|
537
|
+
path: file,
|
|
538
|
+
entries: [],
|
|
539
|
+
revision: workspacesRevision(null),
|
|
540
|
+
};
|
|
541
|
+
return {
|
|
542
|
+
status: "malformed",
|
|
543
|
+
path: file,
|
|
544
|
+
entries: [],
|
|
545
|
+
error: `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
let parsed: unknown;
|
|
549
|
+
try {
|
|
550
|
+
parsed = JSON.parse(raw);
|
|
551
|
+
} catch {
|
|
552
|
+
return {
|
|
553
|
+
status: "malformed",
|
|
554
|
+
path: file,
|
|
555
|
+
entries: [],
|
|
556
|
+
revision: workspacesRevision(raw),
|
|
557
|
+
error: `${file} is not valid JSON`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
...validateWorkspacesDocument(parsed, file),
|
|
562
|
+
revision: workspacesRevision(raw),
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
|
|
567
|
+
export const loadWorkspacesFrom = (dir: string): WorkspaceConfig[] =>
|
|
568
|
+
readWorkspacesResult(dir).entries;
|
|
569
|
+
|
|
570
|
+
// ponytail: only globstar (`**`) is supported; if more minimatch parity is needed
|
|
571
|
+
// (char classes, braces, `?`), swap this matcher for the minimatch dependency.
|
|
572
|
+
const globToRegExp = (glob: string): RegExp => {
|
|
573
|
+
let out = "";
|
|
574
|
+
for (let i = 0; i < glob.length; i++) {
|
|
575
|
+
const c = glob[i];
|
|
576
|
+
if (c === "*") {
|
|
577
|
+
if (glob[i + 1] === "*") {
|
|
578
|
+
if (glob[i + 2] === "/") {
|
|
579
|
+
// **/ matches zero or more segments; a leading **/ also consumes the leading slash
|
|
580
|
+
if (out === "") out += "/?";
|
|
581
|
+
out += "(?:[^/]+/)*";
|
|
582
|
+
i += 2;
|
|
583
|
+
} else {
|
|
584
|
+
// trailing ** also matches the bare parent: /x/y/** -> /x/y and /x/y/deep.
|
|
585
|
+
// (?:/.*)? only when the prefix ends with / (POSIX absolute); otherwise
|
|
586
|
+
// the trailing ** must match any remainder, e.g. a bare ** against a
|
|
587
|
+
// drive-letter cwd like D:/a/x.
|
|
588
|
+
if (i + 2 >= glob.length) {
|
|
589
|
+
if (out.endsWith("/")) {
|
|
590
|
+
out = out.slice(0, -1);
|
|
591
|
+
out += "(?:/.*)?";
|
|
592
|
+
} else {
|
|
593
|
+
out += ".*";
|
|
594
|
+
}
|
|
595
|
+
} else {
|
|
596
|
+
out += ".*";
|
|
597
|
+
}
|
|
598
|
+
i++;
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
out += "[^/]*";
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
out += c.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return new RegExp(`^${out}$`);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/** Shared authoritative workspace matcher (WZ-12): the wizard's pattern
|
|
611
|
+
* preview and resolveWorkspaceFrom both route through it. */
|
|
612
|
+
export const matchWorkspace = (glob: string, target: string): boolean =>
|
|
613
|
+
globToRegExp(glob.replaceAll("\\", "/")).test(target.replaceAll("\\", "/"));
|
|
614
|
+
|
|
615
|
+
// RL-08: the matcher above implements `*` and `**` only. Classic glob
|
|
616
|
+
// metacharacters it would otherwise store as literals (char classes, `?`,
|
|
617
|
+
// brace expansion) are rejected at write time so a saved pattern never
|
|
618
|
+
// silently matches nothing (Task 15 advisory: unsupported patterns were
|
|
619
|
+
// accepted and shown as "no match").
|
|
620
|
+
const UNSUPPORTED_GLOB = /[?[\]{}]/;
|
|
621
|
+
|
|
622
|
+
// B3: `!`-negation and extglob prefixes (@(...), +(...), *(...)) are the same
|
|
623
|
+
// no-match trap — the matcher stores them as literals, so `!**` or `@(a|b)`
|
|
624
|
+
// silently matches nothing. Reject them at write time too; the Task 15 match
|
|
625
|
+
// preview still routes through matchWorkspace (literals -> no match) and is
|
|
626
|
+
// unchanged.
|
|
627
|
+
const UNSUPPORTED_EXTGLOB = /[@+*]\(|!/;
|
|
628
|
+
|
|
629
|
+
export type GlobValidation = { ok: true } | { ok: false; error: string };
|
|
630
|
+
|
|
631
|
+
export const validateWorkspaceGlob = (glob: string): GlobValidation => {
|
|
632
|
+
const trimmed = glob.trim();
|
|
633
|
+
if (!trimmed) return { ok: false, error: "workspace pattern is required" };
|
|
634
|
+
const m = UNSUPPORTED_GLOB.exec(trimmed);
|
|
635
|
+
if (m) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
error: `unsupported glob character ${JSON.stringify(m[0])} in workspace pattern ${JSON.stringify(glob)} — the matcher supports * and ** only (e.g. /work/**)`,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const ext = UNSUPPORTED_EXTGLOB.exec(trimmed);
|
|
642
|
+
if (ext) {
|
|
643
|
+
const token = ext[0];
|
|
644
|
+
const kind = token === "!" ? "negation (!)" : `extglob (${token})`;
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
error: `unsupported glob ${kind} in workspace pattern ${JSON.stringify(glob)} — the matcher supports * and ** only (e.g. /work/**)`,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
return { ok: true };
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
/** A workspaces.json problem for the user to fix; CLI verbs report it as `blocked`. */
|
|
654
|
+
export class WorkspaceConfigError extends Error {
|
|
655
|
+
override name = "WorkspaceConfigError";
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
type Probe = { main: string | null; superproject: string | null };
|
|
659
|
+
const probes = new Map<string, Probe | null>();
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* What git says about the checkout at `cwd`: its main checkout when it is a
|
|
663
|
+
* linked worktree, its superproject when it is a submodule; null when no
|
|
664
|
+
* `.git` sits at or above it (no repository, git is not asked). An Error when
|
|
665
|
+
* git did not answer (missing, broken gitdir, timeout): callers fail closed
|
|
666
|
+
* (#540), and a failure is never cached.
|
|
667
|
+
*/
|
|
668
|
+
const probeCheckout = (cwd: string): Probe | null | Error => {
|
|
669
|
+
const cached = probes.get(cwd);
|
|
670
|
+
if (cached !== undefined) return cached;
|
|
671
|
+
let dir = path.resolve(cwd);
|
|
672
|
+
while (!existsSync(path.join(dir, ".git"))) {
|
|
673
|
+
// Git's own discovery: a folder holding HEAD, objects and refs is a
|
|
674
|
+
// repository with no work tree (bare, or bare-style with borrowed
|
|
675
|
+
// internals, #552). Only the git directory of a checkout git can name
|
|
676
|
+
// (cwd inside `<checkout>/.git`) keeps grants, capped at that checkout.
|
|
677
|
+
if (["HEAD", "objects", "refs"].every((name) => existsSync(path.join(dir, name)))) {
|
|
678
|
+
const real = realpathOf(dir);
|
|
679
|
+
const owner = ownInternals(real) === true ? gitDirOwner(real) : null;
|
|
680
|
+
if (owner === null)
|
|
681
|
+
return Error(`${cwd} is in a repository with no work tree at ${real}: no grants apply`);
|
|
682
|
+
const probe = { main: owner, superproject: null };
|
|
683
|
+
probes.set(cwd, probe);
|
|
684
|
+
return probe;
|
|
685
|
+
}
|
|
686
|
+
const up = path.dirname(dir);
|
|
687
|
+
if (up === dir) return null;
|
|
688
|
+
dir = up;
|
|
689
|
+
}
|
|
690
|
+
// The repository at cwd, not one an inherited GIT_DIR/GIT_WORK_TREE names.
|
|
691
|
+
const env = { ...process.env };
|
|
692
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"])
|
|
693
|
+
delete env[name];
|
|
694
|
+
const git = (args: string[]): string | null => {
|
|
695
|
+
const run = spawnSync("git", args, {
|
|
696
|
+
cwd,
|
|
697
|
+
env,
|
|
698
|
+
encoding: "utf8",
|
|
699
|
+
timeout: 5000,
|
|
700
|
+
windowsHide: true,
|
|
701
|
+
});
|
|
702
|
+
return run.status === 0 ? run.stdout : null;
|
|
703
|
+
};
|
|
704
|
+
const failed = Error(`git could not tell what checkout ${cwd} is`);
|
|
705
|
+
const [gitDir, common, superproject] = (
|
|
706
|
+
git([
|
|
707
|
+
"rev-parse",
|
|
708
|
+
"--path-format=absolute",
|
|
709
|
+
"--git-dir",
|
|
710
|
+
"--git-common-dir",
|
|
711
|
+
"--show-superproject-working-tree",
|
|
712
|
+
]) ?? ""
|
|
713
|
+
)
|
|
714
|
+
.trim()
|
|
715
|
+
.split(/\r?\n/u);
|
|
716
|
+
if (!gitDir || !common) return failed;
|
|
717
|
+
let main: string | null = null;
|
|
718
|
+
if (path.resolve(gitDir) === path.resolve(common)) {
|
|
719
|
+
// Its own repository only when `.git` here is that directory itself: a
|
|
720
|
+
// `gitdir:` file or a symlink into another checkout's repository is capped
|
|
721
|
+
// at that checkout (#548); an owner that cannot be named caps at nothing.
|
|
722
|
+
const real = realpathOf(common);
|
|
723
|
+
if (!ownGitDir(path.join(dir, ".git"), real)) {
|
|
724
|
+
const owner = gitDirOwner(real);
|
|
725
|
+
if (owner === null)
|
|
726
|
+
return Error(`${cwd} uses the repository at ${real}, owned by no checkout workit can name`);
|
|
727
|
+
if (owner !== realpathOf(dir)) main = owner;
|
|
728
|
+
}
|
|
729
|
+
// Its refs, objects and config must be its own too (#552): borrowed ones
|
|
730
|
+
// cap at the checkout that owns them, or at nothing.
|
|
731
|
+
const lender = ownInternals(real);
|
|
732
|
+
if (lender === false)
|
|
733
|
+
return Error(`${cwd} borrows git internals from outside ${real} that no checkout owns`);
|
|
734
|
+
if (lender !== true && lender !== realpathOf(dir)) {
|
|
735
|
+
if (main !== null && main !== lender)
|
|
736
|
+
return Error(`${cwd} mixes the repositories of ${main} and ${lender}`);
|
|
737
|
+
main = lender;
|
|
738
|
+
}
|
|
739
|
+
} else {
|
|
740
|
+
// The first `worktree` entry is the main one, also with --separate-git-dir
|
|
741
|
+
// (where the common dir is not <main>/.git); a bare repo lists its own path.
|
|
742
|
+
const list = git(["worktree", "list", "--porcelain"]);
|
|
743
|
+
if (list === null) return failed;
|
|
744
|
+
main =
|
|
745
|
+
/^worktree (.+)$/mu.exec(list)?.[1]?.trim() ||
|
|
746
|
+
(path.basename(common) === ".git" ? path.dirname(common) : common);
|
|
747
|
+
}
|
|
748
|
+
const probe = { main, superproject: superproject || null };
|
|
749
|
+
probes.set(cwd, probe);
|
|
750
|
+
return probe;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
/** Is `dotGit` a real directory (not a symlink) that is the repository at `real`? */
|
|
754
|
+
const ownGitDir = (dotGit: string, real: string): boolean => {
|
|
755
|
+
try {
|
|
756
|
+
return lstatSync(dotGit).isDirectory() && realpathOf(dotGit) === real;
|
|
757
|
+
} catch {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
// What decides the repository git acts on; hooks and info cannot change refs,
|
|
763
|
+
// objects or the remote, so a shared hooks folder keeps the grants.
|
|
764
|
+
const INTERNALS = ["refs", "objects", "config", "packed-refs", "commondir"];
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Whether the git directory at `real` owns what git reads through it (#552):
|
|
768
|
+
* true when every present INTERNALS entry resolves inside it; the one checkout
|
|
769
|
+
* (gitDirOwner) whose git directory the borrowed ones resolve into; false
|
|
770
|
+
* when one resolves elsewhere, dangles, or they name several checkouts.
|
|
771
|
+
*/
|
|
772
|
+
const ownInternals = (real: string): true | string | false => {
|
|
773
|
+
let lender: string | null = null;
|
|
774
|
+
for (const name of INTERNALS) {
|
|
775
|
+
const at = path.join(real, name);
|
|
776
|
+
try {
|
|
777
|
+
lstatSync(at);
|
|
778
|
+
} catch {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
let resolved: string;
|
|
782
|
+
try {
|
|
783
|
+
resolved = (realpathSync.native ?? realpathSync)(at);
|
|
784
|
+
} catch {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
const rel = path.relative(real, resolved);
|
|
788
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) continue;
|
|
789
|
+
const owner = path.basename(resolved) === name ? gitDirOwner(path.dirname(resolved)) : null;
|
|
790
|
+
if (owner === null || (lender !== null && lender !== owner)) return false;
|
|
791
|
+
lender = owner;
|
|
792
|
+
}
|
|
793
|
+
return lender ?? true;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The checkout a git directory belongs to: `<checkout>/.git`, or the
|
|
798
|
+
* superproject of `<checkout>/.git/modules/<name>` (also through one of its
|
|
799
|
+
* linked worktrees, `.git/worktrees/<w>/modules/<name>`); null for anything else
|
|
800
|
+
* (a `--separate-git-dir`, a bare repository).
|
|
801
|
+
*/
|
|
802
|
+
const gitDirOwner = (real: string): string | null => {
|
|
803
|
+
const parts = real.split(/[\\/]/u);
|
|
804
|
+
const at = parts.lastIndexOf(".git");
|
|
805
|
+
if (at < 1) return null;
|
|
806
|
+
const rest = parts.slice(at + 1);
|
|
807
|
+
const module = rest[0] === "modules" || (rest[0] === "worktrees" && rest[2] === "modules");
|
|
808
|
+
return rest.length === 0 || module ? parts.slice(0, at).join(path.sep) || path.sep : null;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* The main checkout of the linked worktree at `cwd` (null when cwd is not a
|
|
813
|
+
* linked worktree, or git did not answer). A worktree outside every glob
|
|
814
|
+
* (~/.codex/worktrees/…, ../x, /tmp) belongs to the workspace of the checkout
|
|
815
|
+
* it was added from (B2).
|
|
816
|
+
*/
|
|
817
|
+
const mainCheckoutOf = (cwd: string): string | null => {
|
|
818
|
+
const probe = probeCheckout(cwd);
|
|
819
|
+
return probe instanceof Error ? null : (probe?.main ?? null);
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Every checkout whose grants cap the one at `cwd` (#540): a linked
|
|
824
|
+
* worktree's main checkout, a submodule's superproject, and theirs in turn.
|
|
825
|
+
* An Error when git did not answer for one of them.
|
|
826
|
+
*/
|
|
827
|
+
export const checkoutsAbove = (cwd: string, seen = new Set<string>()): string[] | Error => {
|
|
828
|
+
const probe = probeCheckout(cwd);
|
|
829
|
+
if (probe instanceof Error) return probe;
|
|
830
|
+
const out: string[] = [];
|
|
831
|
+
for (const above of [probe?.main, probe?.superproject])
|
|
832
|
+
if (above && !seen.has(above)) {
|
|
833
|
+
seen.add(above);
|
|
834
|
+
const rest = checkoutsAbove(above, seen);
|
|
835
|
+
if (rest instanceof Error) return rest;
|
|
836
|
+
out.push(above, ...rest);
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The entries whose glob matches cwd (its logical and real path); for a linked
|
|
843
|
+
* worktree that matches none, the entries matching its main checkout.
|
|
844
|
+
*/
|
|
845
|
+
const matchingEntries = <T extends { glob: string }>(cwd: string, entries: readonly T[]): T[] => {
|
|
846
|
+
const match = (where: string): T[] => {
|
|
847
|
+
const targets = [where, realpathOf(where)].map((value) => value.replaceAll("\\", "/"));
|
|
848
|
+
return entries.filter((entry) => {
|
|
849
|
+
const glob = entry.glob.replaceAll("\\", "/");
|
|
850
|
+
const canonical = canonicalGlob(glob);
|
|
851
|
+
return targets.some(
|
|
852
|
+
(target) =>
|
|
853
|
+
matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
|
|
854
|
+
);
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
const direct = match(cwd);
|
|
858
|
+
if (direct.length) return direct;
|
|
859
|
+
const main = mainCheckoutOf(cwd);
|
|
860
|
+
return main ? match(main) : direct;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const realpathOf = (p: string): string => {
|
|
864
|
+
// realpathSync.native goes through libuv, which expands Windows 8.3 short
|
|
865
|
+
// names (C:\Users\RUNNER~1 -> C:\Users\runneradmin); the plain realpathSync
|
|
866
|
+
// keeps them, so a git-derived long path would miss a glob written with the
|
|
867
|
+
// short form (GitHub runner TMP uses the short name).
|
|
868
|
+
const real = realpathSync.native ?? realpathSync;
|
|
869
|
+
try {
|
|
870
|
+
return real(p);
|
|
871
|
+
} catch {
|
|
872
|
+
return p;
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// Rebuild a glob with the realpath of its static prefix, so a config glob
|
|
877
|
+
// written with the logical path also matches a canonical target (and vice
|
|
878
|
+
// versa). The glob metacharacters themselves are left untouched.
|
|
879
|
+
const canonicalGlob = (glob: string): string => {
|
|
880
|
+
const m = /[*?[\]{]/.exec(glob);
|
|
881
|
+
const prefix = m ? glob.slice(0, m.index) : glob;
|
|
882
|
+
const rest = m ? glob.slice(m.index) : "";
|
|
883
|
+
if (!prefix) return glob;
|
|
884
|
+
const real = realpathOf(prefix.replace(/\/+$/, "") || "/");
|
|
885
|
+
return real + (prefix.endsWith("/") ? "/" : "") + rest;
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
/** The literal path segments of a glob; the most specific matching glob wins. */
|
|
889
|
+
export const globSpecificity = (glob: string): number =>
|
|
890
|
+
glob
|
|
891
|
+
.replaceAll("\\", "/")
|
|
892
|
+
.split("/")
|
|
893
|
+
.filter((segment) => segment.length > 0 && !/[*?[\]{]/.test(segment)).length;
|
|
894
|
+
|
|
895
|
+
/** The literal directory a glob starts from: `/home/u/work/**` -> `/home/u/work`. */
|
|
896
|
+
export const globRoot = (glob: string): string => {
|
|
897
|
+
const normalized = glob.replaceAll("\\", "/");
|
|
898
|
+
const wild = /[*?[\]{]/.exec(normalized);
|
|
899
|
+
const prefix = wild ? normalized.slice(0, wild.index) : normalized;
|
|
900
|
+
return prefix.replace(/\/+$/u, "") || "/";
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
/** The text of a glob before its first wildcard, in both its logical and real form. */
|
|
904
|
+
const literalPrefixes = (glob: string): string[] => {
|
|
905
|
+
const normalized = glob.replaceAll("\\", "/");
|
|
906
|
+
const prefixOf = (value: string) => {
|
|
907
|
+
const wild = /[*?[\]{]/.exec(value);
|
|
908
|
+
return wild ? value.slice(0, wild.index) : value;
|
|
909
|
+
};
|
|
910
|
+
return [
|
|
911
|
+
...new Set([prefixOf(normalized), prefixOf(canonicalGlob(normalized).replaceAll("\\", "/"))]),
|
|
912
|
+
];
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Whether two globs might match a common path. Every path a glob matches
|
|
917
|
+
* starts with its literal prefix, so globs whose prefixes are not one a text
|
|
918
|
+
* prefix of the other never overlap; anything else is treated as overlapping
|
|
919
|
+
* (conservative: #444's verbs refuse or clamp rather than risk a raise).
|
|
920
|
+
*/
|
|
921
|
+
const globsMayOverlap = (a: string, b: string): boolean =>
|
|
922
|
+
literalPrefixes(a).some((pa) =>
|
|
923
|
+
literalPrefixes(b).some((pb) => pa.startsWith(pb) || pb.startsWith(pa)),
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
/** Every entry whose glob may match a path `glob` matches. */
|
|
927
|
+
export const overlappingEntries = <T extends { name: string; glob: string }>(
|
|
928
|
+
glob: string,
|
|
929
|
+
entries: readonly T[],
|
|
930
|
+
): T[] => entries.filter((entry) => globsMayOverlap(entry.glob, glob));
|
|
931
|
+
|
|
932
|
+
/** One path segment as a regex: `*` stays inside the segment, anything else is literal. */
|
|
933
|
+
const segmentRegExp = (segment: string): RegExp =>
|
|
934
|
+
new RegExp(`^${segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*")}$`);
|
|
935
|
+
|
|
936
|
+
/** Whether two path-glob segment lists can name a common path (a globstar ends the comparison). */
|
|
937
|
+
const segmentsIntersect = (a: readonly string[], b: readonly string[]): boolean => {
|
|
938
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
939
|
+
const [x, y] = [a[i], b[i]];
|
|
940
|
+
if (x.includes("**") || y.includes("**")) return true;
|
|
941
|
+
const [xWild, yWild] = [x.includes("*"), y.includes("*")];
|
|
942
|
+
if (xWild && yWild) continue;
|
|
943
|
+
if (xWild ? !segmentRegExp(x).test(y) : yWild ? !segmentRegExp(y).test(x) : x !== y)
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
// One glob ends first: the shorter one names a folder that holds the other.
|
|
947
|
+
return true;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Whether two globs really can match a common folder, segment by segment: a
|
|
952
|
+
* stricter reading than `globsMayOverlap`, for what the verbs print (the
|
|
953
|
+
* safety decisions still use the conservative one). Compared in logical and
|
|
954
|
+
* real form, so a symlinked root cannot hide an overlap.
|
|
955
|
+
*/
|
|
956
|
+
export const globsIntersect = (a: string, b: string): boolean => {
|
|
957
|
+
const split = (glob: string) => glob.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
958
|
+
const forms = (glob: string) => [glob, canonicalGlob(glob.replaceAll("\\", "/"))];
|
|
959
|
+
return forms(a).some((fa) => forms(b).some((fb) => segmentsIntersect(split(fa), split(fb))));
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
/** Every entry whose glob can really match a folder `glob` matches. */
|
|
963
|
+
export const intersectingEntries = <T extends { name: string; glob: string }>(
|
|
964
|
+
glob: string,
|
|
965
|
+
entries: readonly T[],
|
|
966
|
+
): T[] => entries.filter((entry) => globsIntersect(entry.glob, glob));
|
|
967
|
+
|
|
968
|
+
export type WorkspaceOverlap = {
|
|
969
|
+
/** The entry nested inside (or tied with) `outer`. */
|
|
970
|
+
inner: string;
|
|
971
|
+
outer: string;
|
|
972
|
+
/** Where both match: the inner glob's root. */
|
|
973
|
+
at: string;
|
|
974
|
+
/** The entry selected there; null when the globs tie (ambiguous). */
|
|
975
|
+
winner: string | null;
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
/** Pairs of entries whose globs both match some directory, and which one wins. */
|
|
979
|
+
export const workspaceOverlaps = (
|
|
980
|
+
entries: readonly { name: string; glob: string }[],
|
|
981
|
+
): WorkspaceOverlap[] => {
|
|
982
|
+
const out: WorkspaceOverlap[] = [];
|
|
983
|
+
for (const [i, a] of entries.entries())
|
|
984
|
+
for (const b of entries.slice(i + 1)) {
|
|
985
|
+
const aInB = matchWorkspace(b.glob, globRoot(a.glob));
|
|
986
|
+
const bInA = matchWorkspace(a.glob, globRoot(b.glob));
|
|
987
|
+
if (!aInB && !bInA && !globsMayOverlap(a.glob, b.glob)) continue;
|
|
988
|
+
const [inner, outer] =
|
|
989
|
+
aInB && !bInA
|
|
990
|
+
? [a, b]
|
|
991
|
+
: bInA && !aInB
|
|
992
|
+
? [b, a]
|
|
993
|
+
: globSpecificity(a.glob) >= globSpecificity(b.glob)
|
|
994
|
+
? [a, b]
|
|
995
|
+
: [b, a];
|
|
996
|
+
const si = globSpecificity(inner.glob);
|
|
997
|
+
const so = globSpecificity(outer.glob);
|
|
998
|
+
out.push({
|
|
999
|
+
inner: inner.name,
|
|
1000
|
+
outer: outer.name,
|
|
1001
|
+
at: globRoot(inner.glob),
|
|
1002
|
+
winner: si === so ? null : si > so ? inner.name : outer.name,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
return out;
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
/** Every entry whose glob matches cwd (its logical and real path, or its main checkout). */
|
|
1009
|
+
export const workspaceMatches = <T extends { name: string; glob: string }>(
|
|
1010
|
+
cwd: string,
|
|
1011
|
+
entries: readonly T[],
|
|
1012
|
+
): T[] => matchingEntries(cwd, entries);
|
|
1013
|
+
|
|
1014
|
+
const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
|
|
1015
|
+
matches: T[],
|
|
1016
|
+
cwd: string,
|
|
1017
|
+
workspaceName?: string,
|
|
1018
|
+
): T | undefined => {
|
|
1019
|
+
if (workspaceName !== undefined) {
|
|
1020
|
+
const named = matches.filter((entry) => entry.name === workspaceName);
|
|
1021
|
+
if (named.length === 1) return named[0];
|
|
1022
|
+
if (named.length > 1)
|
|
1023
|
+
throw new WorkspaceConfigError(
|
|
1024
|
+
`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`,
|
|
1025
|
+
);
|
|
1026
|
+
if (matches.length > 0) {
|
|
1027
|
+
throw new WorkspaceConfigError(
|
|
1028
|
+
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
throw new WorkspaceConfigError(
|
|
1032
|
+
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (matches.length < 2) return matches[0];
|
|
1037
|
+
const highestSpecificity = Math.max(...matches.map((entry) => globSpecificity(entry.glob)));
|
|
1038
|
+
const mostSpecific = matches.filter(
|
|
1039
|
+
(entry) => globSpecificity(entry.glob) === highestSpecificity,
|
|
1040
|
+
);
|
|
1041
|
+
if (mostSpecific.length > 1) {
|
|
1042
|
+
throw new WorkspaceConfigError(
|
|
1043
|
+
`ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
return mostSpecific[0];
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
/** Shared pure workspace matcher for draft previews and disk-backed resolution. */
|
|
1050
|
+
export const resolveWorkspaceFromEntries = <T extends { name: string; glob: string }>(
|
|
1051
|
+
cwd: string,
|
|
1052
|
+
entries: readonly T[],
|
|
1053
|
+
workspaceName?: string,
|
|
1054
|
+
): T | null => {
|
|
1055
|
+
// macOS/Windows tmpdir symlinks (/var -> /private/var): git's
|
|
1056
|
+
// --show-toplevel returns the realpath while config globs are usually
|
|
1057
|
+
// written with the logical path, so a workspace would silently stop
|
|
1058
|
+
// matching on macOS. Match both forms on each side; on Linux both forms
|
|
1059
|
+
// are identical so behavior is unchanged.
|
|
1060
|
+
const matches = matchingEntries(cwd, entries);
|
|
1061
|
+
return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
/** Match a cwd against the workspaces.json under an explicit config dir. */
|
|
1065
|
+
export const resolveWorkspaceFrom = (
|
|
1066
|
+
cwd: string,
|
|
1067
|
+
dir: string,
|
|
1068
|
+
workspaceName?: string,
|
|
1069
|
+
): WorkspaceConfig | null => {
|
|
1070
|
+
const result = readWorkspacesResult(dir);
|
|
1071
|
+
if (result.status === "malformed" || result.status === "invalid")
|
|
1072
|
+
throw new WorkspaceConfigError(result.error);
|
|
1073
|
+
return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
export const resolveWorkspace = (
|
|
1077
|
+
cwd: string,
|
|
1078
|
+
workspaceName = process.env.WORKIT_WORKSPACE_NAME?.trim() || undefined,
|
|
1079
|
+
): WorkspaceConfig | null => resolveWorkspaceFrom(cwd, configDir(), workspaceName);
|
|
1080
|
+
|
|
1081
|
+
type RuntimeWorkspacePolicy = "branch" | "commit" | "vcs";
|
|
1082
|
+
type RuntimeWorkspaceCandidate = {
|
|
1083
|
+
name: string;
|
|
1084
|
+
glob: string;
|
|
1085
|
+
defaultProfile?: string;
|
|
1086
|
+
profiles?: Record<string, Record<string, unknown>>;
|
|
1087
|
+
branchPolicy?: WorkspaceBranchPolicy;
|
|
1088
|
+
commitPolicy?: WorkspaceCommitPolicy;
|
|
1089
|
+
vcs?: WorkspaceConfig["vcs"];
|
|
1090
|
+
youtrack?: unknown;
|
|
1091
|
+
issues?: unknown;
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
const runtimeWorkspacePolicySchema = (kind: RuntimeWorkspacePolicy) => {
|
|
1095
|
+
const key = kind === "branch" ? "branchPolicy" : kind === "commit" ? "commitPolicy" : "vcs";
|
|
1096
|
+
const policySchema =
|
|
1097
|
+
kind === "branch"
|
|
1098
|
+
? workspaceBranchPolicySchema
|
|
1099
|
+
: kind === "commit"
|
|
1100
|
+
? workspaceCommitPolicySchema
|
|
1101
|
+
: z
|
|
1102
|
+
.object({
|
|
1103
|
+
provider: z.enum(["gitlab", "github"]),
|
|
1104
|
+
defaultTargetBranch: nonBlank.optional(),
|
|
1105
|
+
account: nonBlank.optional(),
|
|
1106
|
+
})
|
|
1107
|
+
.passthrough();
|
|
1108
|
+
const profileSchema = z.object({ [key]: policySchema.optional() }).passthrough();
|
|
1109
|
+
const entrySchema = z
|
|
1110
|
+
.object({
|
|
1111
|
+
name: nonBlank,
|
|
1112
|
+
glob: nonBlank,
|
|
1113
|
+
[key]: policySchema.optional(),
|
|
1114
|
+
defaultProfile: nonBlank.optional(),
|
|
1115
|
+
profiles: z.record(nonBlank, profileSchema).optional(),
|
|
1116
|
+
})
|
|
1117
|
+
.passthrough();
|
|
1118
|
+
return entrySchema;
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
export type RuntimeWorkspaceVcs = Pick<
|
|
1122
|
+
RuntimeWorkspaceCandidate,
|
|
1123
|
+
"name" | "branchPolicy" | "vcs" | "youtrack" | "issues"
|
|
1124
|
+
>;
|
|
1125
|
+
|
|
1126
|
+
export const resolveRuntimeWorkspaceVcs = (cwd: string): RuntimeWorkspaceVcs | null =>
|
|
1127
|
+
resolveRuntimeWorkspaceCandidate(cwd, "vcs");
|
|
1128
|
+
|
|
1129
|
+
const runtimeWorkspaceIndexSchema = z
|
|
1130
|
+
.object({
|
|
1131
|
+
workspaces: z.array(z.object({ name: nonBlank, glob: nonBlank }).passthrough()).optional(),
|
|
1132
|
+
})
|
|
1133
|
+
.passthrough();
|
|
1134
|
+
|
|
1135
|
+
/** The workspaces.json entry matching cwd, checked only for name/glob (D17). */
|
|
1136
|
+
const matchRuntimeWorkspace = (
|
|
1137
|
+
cwd: string,
|
|
1138
|
+
dir: string = configDir(),
|
|
1139
|
+
): {
|
|
1140
|
+
file: string;
|
|
1141
|
+
selected: Record<string, unknown> & { name: string };
|
|
1142
|
+
/** Every entry whose glob matches cwd, the selected one included. */
|
|
1143
|
+
matches: Array<Record<string, unknown> & { name: string; glob: string }>;
|
|
1144
|
+
} | null => {
|
|
1145
|
+
const file = path.join(dir, "workspaces.json");
|
|
1146
|
+
let raw: string;
|
|
1147
|
+
try {
|
|
1148
|
+
raw = readFileSync(file, "utf8");
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
1151
|
+
throw new WorkspaceConfigError(
|
|
1152
|
+
`${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
1153
|
+
{ cause: error },
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
let parsed: unknown;
|
|
1157
|
+
try {
|
|
1158
|
+
parsed = JSON.parse(raw);
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw new WorkspaceConfigError(`${file} is not valid JSON`);
|
|
1161
|
+
}
|
|
1162
|
+
const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
|
|
1163
|
+
if (!validated.success) {
|
|
1164
|
+
const detail = validated.error.issues
|
|
1165
|
+
.map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
|
|
1166
|
+
.join("; ");
|
|
1167
|
+
throw new WorkspaceConfigError(`${file} has invalid workspace matching data: ${detail}`);
|
|
1168
|
+
}
|
|
1169
|
+
const entries = validated.data.workspaces;
|
|
1170
|
+
for (const entry of entries ?? []) {
|
|
1171
|
+
const glob = validateWorkspaceGlob(entry.glob);
|
|
1172
|
+
if (!glob.ok) throw new WorkspaceConfigError(`${file} workspace ${entry.name}: ${glob.error}`);
|
|
1173
|
+
}
|
|
1174
|
+
const matches = matchingEntries(cwd, entries ?? []);
|
|
1175
|
+
const workspaceName = process.env.WORKIT_WORKSPACE_NAME?.trim() || undefined;
|
|
1176
|
+
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
1177
|
+
return selected ? { file, selected, matches } : null;
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* The raw `releaseTracks` of the workspace matching cwd, unvalidated: the
|
|
1182
|
+
* runtime reads them leniently (core/release-tracks.ts) so one malformed track
|
|
1183
|
+
* is reported instead of disabling every command.
|
|
1184
|
+
*/
|
|
1185
|
+
export const resolveRuntimeReleaseTracks = (
|
|
1186
|
+
cwd: string,
|
|
1187
|
+
): { workspace: string; releaseTracks: unknown } | null => {
|
|
1188
|
+
const matched = matchRuntimeWorkspace(cwd);
|
|
1189
|
+
return matched
|
|
1190
|
+
? {
|
|
1191
|
+
workspace: matched.selected.name,
|
|
1192
|
+
releaseTracks: matched.selected.releaseTracks,
|
|
1193
|
+
}
|
|
1194
|
+
: null;
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
type RuntimeEntry = Record<string, unknown> & { name: string };
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* The workspace selected for cwd and the entry a setting comes from: the
|
|
1201
|
+
* selected workspace when `has(entry)`, else the nearest enclosing workspace
|
|
1202
|
+
* that also matches cwd (a repo workspace nested in `work`). Read leniently:
|
|
1203
|
+
* no workspace, or an unreadable workspaces.json, reads as null.
|
|
1204
|
+
*/
|
|
1205
|
+
const inheritedSetting = (
|
|
1206
|
+
cwd: string,
|
|
1207
|
+
dir: string | undefined,
|
|
1208
|
+
has: (entry: RuntimeEntry) => boolean,
|
|
1209
|
+
): { workspace: string; source: RuntimeEntry | null } | null => {
|
|
1210
|
+
let matched: ReturnType<typeof matchRuntimeWorkspace>;
|
|
1211
|
+
try {
|
|
1212
|
+
matched = matchRuntimeWorkspace(cwd, dir);
|
|
1213
|
+
} catch {
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
if (!matched) return null;
|
|
1217
|
+
const { selected, matches } = matched;
|
|
1218
|
+
const enclosing = matches
|
|
1219
|
+
.filter((entry) => entry !== selected)
|
|
1220
|
+
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
1221
|
+
return {
|
|
1222
|
+
workspace: selected.name,
|
|
1223
|
+
source: [selected, ...enclosing].find(has) ?? null,
|
|
1224
|
+
};
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
/** Whether `pr merge` deletes the head branch by default: true unless a covering entry sets `deleteBranch: false`. */
|
|
1228
|
+
export const workspaceDeleteBranch = (cwd: string, dir?: string): boolean =>
|
|
1229
|
+
inheritedSetting(cwd, dir, (entry) => typeof entry.deleteBranch === "boolean")?.source
|
|
1230
|
+
?.deleteBranch !== false;
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
|
|
1234
|
+
* The selected workspace's own `trailers` wins; unset, it inherits from the
|
|
1235
|
+
* nearest enclosing workspace that also matches cwd, and defaults to true.
|
|
1236
|
+
* No workspace, or an unreadable workspaces.json, keeps the trailer. `from`
|
|
1237
|
+
* names the entry the value came from (null for the default).
|
|
1238
|
+
*/
|
|
1239
|
+
export const workspaceTrailers = (
|
|
1240
|
+
cwd: string,
|
|
1241
|
+
dir?: string,
|
|
1242
|
+
): { enabled: boolean; workspace: string | null; from: string | null } => {
|
|
1243
|
+
const found = inheritedSetting(cwd, dir, (entry) => typeof entry.trailers === "boolean");
|
|
1244
|
+
return {
|
|
1245
|
+
enabled: found?.source?.trailers !== false,
|
|
1246
|
+
workspace: found?.workspace ?? null,
|
|
1247
|
+
from: found?.source?.name ?? null,
|
|
1248
|
+
};
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Whether the live-session guard runs for cwd's workspace (`liveSessionGuard`,
|
|
1253
|
+
* default true; inherited like `trailers`). An unreadable workspaces.json
|
|
1254
|
+
* keeps it on: the guard itself fails open on any state error.
|
|
1255
|
+
*/
|
|
1256
|
+
export const workspaceLiveSessionGuard = (cwd: string, dir?: string): boolean =>
|
|
1257
|
+
inheritedSetting(cwd, dir, (entry) => typeof entry.liveSessionGuard === "boolean")?.source
|
|
1258
|
+
?.liveSessionGuard !== false;
|
|
1259
|
+
|
|
1260
|
+
export const DELEGATION_MODES = ["inline-first", "lead-orchestrates"] as const;
|
|
1261
|
+
export type DelegationMode = (typeof DELEGATION_MODES)[number];
|
|
1262
|
+
|
|
1263
|
+
const isDelegation = (value: unknown): value is DelegationMode =>
|
|
1264
|
+
(DELEGATION_MODES as readonly unknown[]).includes(value);
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* The workspace's `delegation` mode, default `inline-first`; inherited like
|
|
1268
|
+
* `trailers`. A value this version does not know reads as unset.
|
|
1269
|
+
*/
|
|
1270
|
+
export const workspaceDelegation = (cwd: string, dir?: string): DelegationMode => {
|
|
1271
|
+
const source = inheritedSetting(cwd, dir, (entry) => isDelegation(entry.delegation))?.source;
|
|
1272
|
+
return isDelegation(source?.delegation) ? source.delegation : "inline-first";
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
export const CHANGELOG_MODES = ["off", "update", "create"] as const;
|
|
1276
|
+
export type ChangelogMode = (typeof CHANGELOG_MODES)[number];
|
|
1277
|
+
export const DOCS_MODES = ["off", "when-user-facing"] as const;
|
|
1278
|
+
export type DocsMode = (typeof DOCS_MODES)[number];
|
|
1279
|
+
|
|
1280
|
+
const isChangelogMode = (value: unknown): value is ChangelogMode =>
|
|
1281
|
+
(CHANGELOG_MODES as readonly unknown[]).includes(value);
|
|
1282
|
+
const isDocsMode = (value: unknown): value is DocsMode =>
|
|
1283
|
+
(DOCS_MODES as readonly unknown[]).includes(value);
|
|
1284
|
+
|
|
1285
|
+
export const DEFAULT_CHANGELOG_PATH = "CHANGELOG.md";
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* A repo-relative changelog path in `/` form, or null when `value` is not one
|
|
1289
|
+
* (absolute, empty, or climbing out with `..`).
|
|
1290
|
+
*/
|
|
1291
|
+
export const changelogPathOf = (value: unknown): string | null => {
|
|
1292
|
+
if (typeof value !== "string") return null;
|
|
1293
|
+
const parts = value
|
|
1294
|
+
.trim()
|
|
1295
|
+
.replaceAll("\\", "/")
|
|
1296
|
+
.split("/")
|
|
1297
|
+
.filter((part) => part && part !== ".");
|
|
1298
|
+
if (!parts.length || value.trim().startsWith("/") || /^[A-Za-z]:/u.test(value.trim()))
|
|
1299
|
+
return null;
|
|
1300
|
+
return parts.includes("..") ? null : parts.join("/");
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
/** `changelogPath` as a list of valid paths, or null when any entry is invalid. */
|
|
1304
|
+
export const changelogPathsOf = (value: unknown): string[] | null => {
|
|
1305
|
+
const list = Array.isArray(value) ? value : [value];
|
|
1306
|
+
const paths = list.map(changelogPathOf).filter((one) => one !== null);
|
|
1307
|
+
return list.length && paths.length === list.length ? paths : null;
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* The workspace's changelog and docs steps, both default `off`, and where the
|
|
1312
|
+
* changelog lives (default CHANGELOG.md); each is inherited like `trailers`. A
|
|
1313
|
+
* value this version does not know reads as the default and is named in
|
|
1314
|
+
* `issues`, so `grant show` can say why.
|
|
1315
|
+
*/
|
|
1316
|
+
export const workspaceChangelog = (
|
|
1317
|
+
cwd: string,
|
|
1318
|
+
dir?: string,
|
|
1319
|
+
): {
|
|
1320
|
+
changelog: ChangelogMode;
|
|
1321
|
+
docs: DocsMode;
|
|
1322
|
+
paths: string[];
|
|
1323
|
+
pathConfigured: boolean;
|
|
1324
|
+
/** The workspace matching cwd, for a `workit workspace set` hint. */
|
|
1325
|
+
workspace: string | null;
|
|
1326
|
+
issues: string[];
|
|
1327
|
+
} => {
|
|
1328
|
+
const issues: string[] = [];
|
|
1329
|
+
const read = <T extends string>(
|
|
1330
|
+
key: "changelog" | "docs",
|
|
1331
|
+
valid: (value: unknown) => value is T,
|
|
1332
|
+
allowed: readonly string[],
|
|
1333
|
+
): T | "off" => {
|
|
1334
|
+
const source = inheritedSetting(cwd, dir, (entry) => entry[key] !== undefined)?.source;
|
|
1335
|
+
const value = source?.[key];
|
|
1336
|
+
if (value === undefined) return "off";
|
|
1337
|
+
if (valid(value)) return value;
|
|
1338
|
+
issues.push(
|
|
1339
|
+
`${key} ${JSON.stringify(value)} in workspace ${source?.name} is not ${allowed.join("|")}; read as off`,
|
|
1340
|
+
);
|
|
1341
|
+
return "off";
|
|
1342
|
+
};
|
|
1343
|
+
const pathSource = inheritedSetting(
|
|
1344
|
+
cwd,
|
|
1345
|
+
dir,
|
|
1346
|
+
(entry) => entry.changelogPath !== undefined,
|
|
1347
|
+
)?.source;
|
|
1348
|
+
const configured = pathSource ? changelogPathsOf(pathSource.changelogPath) : null;
|
|
1349
|
+
if (pathSource && !configured)
|
|
1350
|
+
issues.push(
|
|
1351
|
+
`changelogPath ${JSON.stringify(pathSource.changelogPath)} in workspace ${pathSource.name} is not a repo-relative path or list of them; read as ${DEFAULT_CHANGELOG_PATH}`,
|
|
1352
|
+
);
|
|
1353
|
+
return {
|
|
1354
|
+
changelog: read("changelog", isChangelogMode, CHANGELOG_MODES),
|
|
1355
|
+
docs: read("docs", isDocsMode, DOCS_MODES),
|
|
1356
|
+
paths: configured ?? [DEFAULT_CHANGELOG_PATH],
|
|
1357
|
+
pathConfigured: configured !== null,
|
|
1358
|
+
workspace: inheritedSetting(cwd, dir, () => true)?.workspace ?? null,
|
|
1359
|
+
issues,
|
|
1360
|
+
};
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
export const PR_TEMPLATE_MODES = ["auto", "repo", "workit"] as const;
|
|
1364
|
+
export type PrTemplateMode = (typeof PR_TEMPLATE_MODES)[number];
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* A `prTemplate` value in its stored form: a mode, or a repo-relative `.md`
|
|
1368
|
+
* path in `/` form; null for anything else (another word, an absolute path,
|
|
1369
|
+
* one climbing out with `..`).
|
|
1370
|
+
*/
|
|
1371
|
+
export const prTemplateOf = (value: unknown): string | null => {
|
|
1372
|
+
if ((PR_TEMPLATE_MODES as readonly unknown[]).includes(value)) return value as PrTemplateMode;
|
|
1373
|
+
const file = changelogPathOf(value);
|
|
1374
|
+
return file && file.toLowerCase().endsWith(".md") ? file : null;
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The workspace's `prTemplate` (default `auto`), inherited like `trailers`. A
|
|
1379
|
+
* value this version does not know reads as auto and is named in `issues`.
|
|
1380
|
+
*/
|
|
1381
|
+
export const workspacePrTemplate = (
|
|
1382
|
+
cwd: string,
|
|
1383
|
+
dir?: string,
|
|
1384
|
+
): { setting: string; workspace: string | null; issues: string[] } => {
|
|
1385
|
+
const source = inheritedSetting(cwd, dir, (entry) => entry.prTemplate !== undefined)?.source;
|
|
1386
|
+
const setting = source ? prTemplateOf(source.prTemplate) : null;
|
|
1387
|
+
return {
|
|
1388
|
+
setting: setting ?? "auto",
|
|
1389
|
+
workspace: inheritedSetting(cwd, dir, () => true)?.workspace ?? null,
|
|
1390
|
+
issues:
|
|
1391
|
+
source && !setting
|
|
1392
|
+
? [
|
|
1393
|
+
`prTemplate ${JSON.stringify(source.prTemplate)} in workspace ${source.name} is not ${PR_TEMPLATE_MODES.join("|")} or a repo-relative .md path; read as auto`,
|
|
1394
|
+
]
|
|
1395
|
+
: [],
|
|
1396
|
+
};
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
export const RELEASE_NOTES_MODES = ["auto", "off", "review"] as const;
|
|
1400
|
+
export type ReleaseNotesMode = (typeof RELEASE_NOTES_MODES)[number];
|
|
1401
|
+
|
|
1402
|
+
export const isReleaseNotesMode = (value: unknown): value is ReleaseNotesMode =>
|
|
1403
|
+
(RELEASE_NOTES_MODES as readonly unknown[]).includes(value);
|
|
1404
|
+
|
|
1405
|
+
const tracksOf = (entry: RuntimeEntry): Record<string, Record<string, unknown>> => {
|
|
1406
|
+
const tracks = entry.releaseTracks;
|
|
1407
|
+
return tracks && typeof tracks === "object" && !Array.isArray(tracks)
|
|
1408
|
+
? (tracks as Record<string, Record<string, unknown>>)
|
|
1409
|
+
: {};
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* The release notes mode for cwd (default `auto`), inherited like `trailers`:
|
|
1414
|
+
* the release track's own `releaseTracks.<track>.releaseNotes` wins over the
|
|
1415
|
+
* workspace's `releaseNotes`. A value this version does not know reads as
|
|
1416
|
+
* auto and is named in `issues`; `tracks` lists the tracks that override it.
|
|
1417
|
+
*/
|
|
1418
|
+
export const workspaceReleaseNotes = (
|
|
1419
|
+
cwd: string,
|
|
1420
|
+
track?: string | null,
|
|
1421
|
+
dir?: string,
|
|
1422
|
+
): {
|
|
1423
|
+
mode: ReleaseNotesMode;
|
|
1424
|
+
/** Where the mode comes from: the track, the workspace, or the default. */
|
|
1425
|
+
from: "track" | "workspace" | "default";
|
|
1426
|
+
workspace: string | null;
|
|
1427
|
+
/** Tracks that set their own mode. */
|
|
1428
|
+
tracks: Record<string, ReleaseNotesMode>;
|
|
1429
|
+
issues: string[];
|
|
1430
|
+
} => {
|
|
1431
|
+
const issues: string[] = [];
|
|
1432
|
+
const valid = (value: unknown, where: string): ReleaseNotesMode | null => {
|
|
1433
|
+
if (isReleaseNotesMode(value)) return value;
|
|
1434
|
+
issues.push(
|
|
1435
|
+
`releaseNotes ${JSON.stringify(value)} in ${where} is not ${RELEASE_NOTES_MODES.join("|")}; read as auto`,
|
|
1436
|
+
);
|
|
1437
|
+
return null;
|
|
1438
|
+
};
|
|
1439
|
+
const workspace = inheritedSetting(cwd, dir, () => true)?.workspace ?? null;
|
|
1440
|
+
const trackSource = inheritedSetting(
|
|
1441
|
+
cwd,
|
|
1442
|
+
dir,
|
|
1443
|
+
(entry) => Object.keys(tracksOf(entry)).length > 0,
|
|
1444
|
+
)?.source;
|
|
1445
|
+
const tracks: Record<string, ReleaseNotesMode> = {};
|
|
1446
|
+
for (const [name, config] of Object.entries(trackSource ? tracksOf(trackSource) : {}))
|
|
1447
|
+
if (config?.releaseNotes !== undefined) {
|
|
1448
|
+
const mode = valid(
|
|
1449
|
+
config.releaseNotes,
|
|
1450
|
+
`release track ${name} of workspace ${trackSource?.name}`,
|
|
1451
|
+
);
|
|
1452
|
+
if (mode) tracks[name] = mode;
|
|
1453
|
+
}
|
|
1454
|
+
const source = inheritedSetting(cwd, dir, (entry) => entry.releaseNotes !== undefined)?.source;
|
|
1455
|
+
const own = source ? valid(source.releaseNotes, `workspace ${source.name}`) : null;
|
|
1456
|
+
if (track && tracks[track])
|
|
1457
|
+
return { mode: tracks[track], from: "track", workspace, tracks, issues };
|
|
1458
|
+
return own
|
|
1459
|
+
? { mode: own, from: "workspace", workspace, tracks, issues }
|
|
1460
|
+
: { mode: "auto", from: "default", workspace, tracks, issues };
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Where `models.roles` and `fanout.tierModels` can come from for cwd, nearest
|
|
1465
|
+
* first: the selected workspace, each enclosing one (most specific first),
|
|
1466
|
+
* then the global default at the top of workspaces.json. Read leniently: an
|
|
1467
|
+
* unreadable file reads as no sources.
|
|
1468
|
+
*/
|
|
1469
|
+
export const modelSources = (
|
|
1470
|
+
cwd: string,
|
|
1471
|
+
dir?: string,
|
|
1472
|
+
): Array<{
|
|
1473
|
+
name: string;
|
|
1474
|
+
global: boolean;
|
|
1475
|
+
models: unknown;
|
|
1476
|
+
tierModels: unknown;
|
|
1477
|
+
}> => {
|
|
1478
|
+
const out: Array<{
|
|
1479
|
+
name: string;
|
|
1480
|
+
global: boolean;
|
|
1481
|
+
models: unknown;
|
|
1482
|
+
tierModels: unknown;
|
|
1483
|
+
}> = [];
|
|
1484
|
+
try {
|
|
1485
|
+
const matched = matchRuntimeWorkspace(cwd, dir);
|
|
1486
|
+
if (matched) {
|
|
1487
|
+
const enclosing = matched.matches
|
|
1488
|
+
.filter((entry) => entry !== matched.selected)
|
|
1489
|
+
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
1490
|
+
for (const entry of [matched.selected, ...enclosing])
|
|
1491
|
+
out.push({
|
|
1492
|
+
name: entry.name,
|
|
1493
|
+
global: false,
|
|
1494
|
+
models: entry.models,
|
|
1495
|
+
tierModels: (entry.fanout as { tierModels?: unknown } | undefined)?.tierModels,
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
} catch {
|
|
1499
|
+
// An unreadable or invalid file: no workspace sources.
|
|
1500
|
+
}
|
|
1501
|
+
try {
|
|
1502
|
+
const document: unknown = JSON.parse(
|
|
1503
|
+
readFileSync(path.join(dir ?? configDir(), "workspaces.json"), "utf8"),
|
|
1504
|
+
);
|
|
1505
|
+
if (document && typeof document === "object" && !Array.isArray(document))
|
|
1506
|
+
out.push({
|
|
1507
|
+
name: "global",
|
|
1508
|
+
global: true,
|
|
1509
|
+
models: (document as Record<string, unknown>).models,
|
|
1510
|
+
tierModels: undefined,
|
|
1511
|
+
});
|
|
1512
|
+
} catch {
|
|
1513
|
+
// No global default.
|
|
1514
|
+
}
|
|
1515
|
+
return out;
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
export const hostFamily = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
|
|
1519
|
+
|
|
1520
|
+
const TIER_KEYS = new Set(["mundane", "standard", "hard"]);
|
|
1521
|
+
/** The host keys a per-host model value takes (`fanout.tierModels`, `models.roles`). */
|
|
1522
|
+
export const MODEL_HOST_KEYS = [
|
|
1523
|
+
"claude_code",
|
|
1524
|
+
"opencode",
|
|
1525
|
+
"codex",
|
|
1526
|
+
"codex_cli",
|
|
1527
|
+
"codex_desktop",
|
|
1528
|
+
"cursor",
|
|
1529
|
+
"pi",
|
|
1530
|
+
"default",
|
|
1531
|
+
] as const;
|
|
1532
|
+
const TIER_HOSTS = new Set<string>(MODEL_HOST_KEYS);
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* What `fanout.tierModels` in cwd's workspaces holds that Workit ignores: an
|
|
1536
|
+
* unknown tier or host key, or a value that is not a model name. Read
|
|
1537
|
+
* leniently (each one is skipped, never an error); `workit grant show`
|
|
1538
|
+
* reports them.
|
|
1539
|
+
*/
|
|
1540
|
+
export const workspaceTierModelIssues = (cwd: string, dir?: string): string[] => {
|
|
1541
|
+
let matched: ReturnType<typeof matchRuntimeWorkspace>;
|
|
1542
|
+
try {
|
|
1543
|
+
matched = matchRuntimeWorkspace(cwd, dir);
|
|
1544
|
+
} catch {
|
|
1545
|
+
return [];
|
|
1546
|
+
}
|
|
1547
|
+
const issues: string[] = [];
|
|
1548
|
+
for (const entry of matched?.matches ?? []) {
|
|
1549
|
+
const models = (entry.fanout as { tierModels?: unknown } | undefined)?.tierModels;
|
|
1550
|
+
if (models === undefined) continue;
|
|
1551
|
+
const where = `workspace ${entry.name} fanout.tierModels`;
|
|
1552
|
+
if (!models || typeof models !== "object" || Array.isArray(models)) {
|
|
1553
|
+
issues.push(`${where} is not an object; ignored`);
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
for (const [tier, value] of Object.entries(models)) {
|
|
1557
|
+
if (!TIER_KEYS.has(tier)) issues.push(`${where}.${tier}: unknown tier; ignored`);
|
|
1558
|
+
else if (typeof value === "string") {
|
|
1559
|
+
if (!value.trim()) issues.push(`${where}.${tier}: empty model name; ignored`);
|
|
1560
|
+
} else if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1561
|
+
issues.push(`${where}.${tier}: not a model name or a per-host object; ignored`);
|
|
1562
|
+
else
|
|
1563
|
+
for (const [host, model] of Object.entries(value)) {
|
|
1564
|
+
if (!TIER_HOSTS.has(host)) issues.push(`${where}.${tier}.${host}: unknown host; ignored`);
|
|
1565
|
+
else if (typeof model !== "string" || !model.trim())
|
|
1566
|
+
issues.push(`${where}.${tier}.${host}: not a model name; ignored`);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return issues;
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
const resolveRuntimeWorkspaceCandidate = (
|
|
1574
|
+
cwd: string,
|
|
1575
|
+
kind: RuntimeWorkspacePolicy,
|
|
1576
|
+
): RuntimeWorkspaceCandidate | null => {
|
|
1577
|
+
const matched = matchRuntimeWorkspace(cwd);
|
|
1578
|
+
if (!matched) return null;
|
|
1579
|
+
const { file, selected } = matched;
|
|
1580
|
+
const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
|
|
1581
|
+
if (!policy.success) {
|
|
1582
|
+
const detail = policy.error.issues
|
|
1583
|
+
.map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
|
|
1584
|
+
.join("; ");
|
|
1585
|
+
throw new WorkspaceConfigError(`${file} has invalid ${kind} policy configuration: ${detail}`);
|
|
1586
|
+
}
|
|
1587
|
+
return policy.data as RuntimeWorkspaceCandidate;
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
export const resolveRuntimeWorkspacePolicy = (
|
|
1591
|
+
cwd: string,
|
|
1592
|
+
kind: RuntimeWorkspacePolicy,
|
|
1593
|
+
requestedProfile?: string,
|
|
1594
|
+
): {
|
|
1595
|
+
policy: WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
|
1596
|
+
source: string;
|
|
1597
|
+
} => {
|
|
1598
|
+
const candidate = resolveRuntimeWorkspaceCandidate(cwd, kind);
|
|
1599
|
+
if (!candidate) return { policy: undefined, source: "user-default" };
|
|
1600
|
+
const key = kind === "branch" ? "branchPolicy" : "commitPolicy";
|
|
1601
|
+
const profileName = requestedProfile?.trim() || candidate.defaultProfile;
|
|
1602
|
+
if (
|
|
1603
|
+
candidate.defaultProfile &&
|
|
1604
|
+
!Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
|
|
1605
|
+
)
|
|
1606
|
+
throw new WorkspaceConfigError(
|
|
1607
|
+
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
|
|
1608
|
+
);
|
|
1609
|
+
const profile = profileName ? candidate.profiles?.[profileName] : undefined;
|
|
1610
|
+
if (profileName && !profile)
|
|
1611
|
+
throw new WorkspaceConfigError(
|
|
1612
|
+
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
1613
|
+
);
|
|
1614
|
+
const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
|
1615
|
+
const workspacePolicy = candidate[key];
|
|
1616
|
+
return {
|
|
1617
|
+
policy: profilePolicy ?? workspacePolicy,
|
|
1618
|
+
source: profilePolicy
|
|
1619
|
+
? `profile:${profileName}`
|
|
1620
|
+
: workspacePolicy
|
|
1621
|
+
? `workspace:${candidate.name}`
|
|
1622
|
+
: "user-default",
|
|
1623
|
+
};
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
export const resolveWorkspaceProfile = (
|
|
1627
|
+
workspace: WorkspaceConfig,
|
|
1628
|
+
requestedProfile?: string,
|
|
1629
|
+
): WorkspaceProfileResolution => {
|
|
1630
|
+
const profileName = requestedProfile ?? workspace.defaultProfile ?? null;
|
|
1631
|
+
const profile = profileName ? workspace.profiles?.[profileName] : undefined;
|
|
1632
|
+
if (profileName && !profile) {
|
|
1633
|
+
return {
|
|
1634
|
+
status: "invalid",
|
|
1635
|
+
error: `workspace ${JSON.stringify(workspace.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
const effectiveWorkspace = profile
|
|
1639
|
+
? {
|
|
1640
|
+
...workspace,
|
|
1641
|
+
...(profile.branchPolicy ? { branchPolicy: profile.branchPolicy } : {}),
|
|
1642
|
+
...(profile.commitPolicy ? { commitPolicy: profile.commitPolicy } : {}),
|
|
1643
|
+
}
|
|
1644
|
+
: workspace;
|
|
1645
|
+
return {
|
|
1646
|
+
status: "resolved",
|
|
1647
|
+
profileName,
|
|
1648
|
+
workspace: effectiveWorkspace,
|
|
1649
|
+
provenance: {
|
|
1650
|
+
branchPolicy: profile?.branchPolicy
|
|
1651
|
+
? `profile:${profileName}`
|
|
1652
|
+
: workspace.branchPolicy
|
|
1653
|
+
? `workspace:${workspace.name}`
|
|
1654
|
+
: "user-default",
|
|
1655
|
+
commitPolicy: profile?.commitPolicy
|
|
1656
|
+
? `profile:${profileName}`
|
|
1657
|
+
: workspace.commitPolicy
|
|
1658
|
+
? `workspace:${workspace.name}`
|
|
1659
|
+
: "user-default",
|
|
1660
|
+
},
|
|
1661
|
+
};
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
export const resolveWorkspacePolicy = (
|
|
1665
|
+
config: ToolkitConfig,
|
|
1666
|
+
workspace: WorkspaceConfig | null,
|
|
1667
|
+
profileName?: string,
|
|
1668
|
+
/** The preset the checkout's branches imply, used when no policy is configured. */
|
|
1669
|
+
repoPreset?: () => BranchPreset | null,
|
|
1670
|
+
): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
|
|
1671
|
+
const selected = workspace
|
|
1672
|
+
? resolveWorkspaceProfile(workspace, profileName)
|
|
1673
|
+
: {
|
|
1674
|
+
status: "resolved" as const,
|
|
1675
|
+
profileName: null,
|
|
1676
|
+
workspace: null,
|
|
1677
|
+
provenance: {
|
|
1678
|
+
branchPolicy: "user-default",
|
|
1679
|
+
commitPolicy: "user-default",
|
|
1680
|
+
},
|
|
1681
|
+
};
|
|
1682
|
+
if (selected.status === "invalid") return selected;
|
|
1683
|
+
return {
|
|
1684
|
+
status: "resolved",
|
|
1685
|
+
workspace: selected.workspace,
|
|
1686
|
+
profileName: selected.profileName,
|
|
1687
|
+
branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace, repoPreset),
|
|
1688
|
+
commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
|
|
1689
|
+
provenance: selected.provenance,
|
|
1690
|
+
};
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
export const selectReleaseTrack = (
|
|
1694
|
+
workspace: WorkspaceConfig,
|
|
1695
|
+
requestedTrack?: string,
|
|
1696
|
+
): ReleaseTrackSelection => {
|
|
1697
|
+
const tracks = workspace.releaseTracks ?? {};
|
|
1698
|
+
const choices = Object.keys(tracks).toSorted();
|
|
1699
|
+
if (requestedTrack === undefined) {
|
|
1700
|
+
if (choices.length === 0) return { status: "not_configured" };
|
|
1701
|
+
if (choices.length > 1) return { status: "choice_required", choices };
|
|
1702
|
+
const name = choices[0];
|
|
1703
|
+
return { status: "selected", name, track: tracks[name] };
|
|
1704
|
+
}
|
|
1705
|
+
if (!Object.hasOwn(tracks, requestedTrack)) {
|
|
1706
|
+
return {
|
|
1707
|
+
status: "invalid",
|
|
1708
|
+
error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
return {
|
|
1712
|
+
status: "selected",
|
|
1713
|
+
name: requestedTrack,
|
|
1714
|
+
track: tracks[requestedTrack],
|
|
1715
|
+
};
|
|
1716
|
+
};
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* The workspace default target before release tracks: the one computation
|
|
1720
|
+
* vcsConfig, `workit doctor` and `workit grant show` share (CA-05/CA-02).
|
|
1721
|
+
* Explicit workspace vcs.defaultTargetBranch wins; a workspace with its own
|
|
1722
|
+
* branchPolicy uses its preset's default (gitflow -> develop, github-flow ->
|
|
1723
|
+
* main, trunk-based -> master); otherwise the global vcs.json
|
|
1724
|
+
* `defaultTargetBranch` (`globalDefault`), else the effective preset's.
|
|
1725
|
+
*/
|
|
1726
|
+
export function workspaceDefaultTarget(cwd: string, globalDefault: unknown): string {
|
|
1727
|
+
const ws = resolveRuntimeWorkspaceVcs(cwd);
|
|
1728
|
+
const wp = (ws?.branchPolicy ?? {}) as Record<string, unknown>;
|
|
1729
|
+
const hasWorkspacePolicy = typeof wp.preset === "string" && Object.hasOwn(PRESETS, wp.preset);
|
|
1730
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
1731
|
+
cwd,
|
|
1732
|
+
"branch",
|
|
1733
|
+
process.env.WORKIT_PROFILE?.trim() || undefined,
|
|
1734
|
+
);
|
|
1735
|
+
const policyDefault = resolveConfiguredBranchPolicy(
|
|
1736
|
+
readConfig(),
|
|
1737
|
+
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
1738
|
+
() => repoBranchPreset(cwd),
|
|
1739
|
+
).defaultTargetBranch;
|
|
1740
|
+
return String(
|
|
1741
|
+
(ws?.vcs as { defaultTargetBranch?: unknown } | undefined)?.defaultTargetBranch ??
|
|
1742
|
+
(hasWorkspacePolicy ? policyDefault : (globalDefault ?? policyDefault)) ??
|
|
1743
|
+
"develop",
|
|
1744
|
+
);
|
|
1745
|
+
}
|