@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,455 @@
|
|
|
1
|
+
// `workit fanout check` (G2): the fan-in gate over a recorded fanout plan.
|
|
2
|
+
//
|
|
3
|
+
// For each slice branch it reads what git says, never what a worker reported:
|
|
4
|
+
// - scope: `git diff --name-only <base>...<branch>` against the slice's
|
|
5
|
+
// manifest (its scope and owns, minus files another slice owns); any other
|
|
6
|
+
// file is an out-of-scope edit;
|
|
7
|
+
// - trunk: `git merge-tree --write-tree` of the branch with the trunk;
|
|
8
|
+
// - siblings: the same merge between every pair of slice branches, so two
|
|
9
|
+
// PRs that would conflict once the first lands are caught before either
|
|
10
|
+
// does. A pairwise conflict is charged to the slice that lands later; the
|
|
11
|
+
// earlier one stays landable.
|
|
12
|
+
// - case: a file or directory it adds that the trunk spells differently only
|
|
13
|
+
// in case (one path on macOS and Windows checkouts).
|
|
14
|
+
// A slice that already landed (fanout-landed.ts: its PR merged, squash
|
|
15
|
+
// merges included, or git shows its change on the trunk) is done: it is not
|
|
16
|
+
// checked, and slices that depend on it no longer wait for it.
|
|
17
|
+
// The landing order puts dependencies first, then the plan's order. A slice
|
|
18
|
+
// is ready when none of the above fired and every slice it depends on is
|
|
19
|
+
// ready or landed. Verdicts are reported (`workit ledger check`) but gated by
|
|
20
|
+
// `workit pr merge`, not here. Refs are read as they are locally: fetch first.
|
|
21
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
22
|
+
import { checkVerdicts, readLedger, type ReadRow } from "./ledger";
|
|
23
|
+
import {
|
|
24
|
+
FANOUT_VERSION,
|
|
25
|
+
branchRef,
|
|
26
|
+
caseClash,
|
|
27
|
+
caseIndex,
|
|
28
|
+
fanoutFail,
|
|
29
|
+
gitRun,
|
|
30
|
+
inScope,
|
|
31
|
+
landingOrder,
|
|
32
|
+
nulList,
|
|
33
|
+
trunkRef,
|
|
34
|
+
type FanoutFile,
|
|
35
|
+
type FanoutResult,
|
|
36
|
+
type Slice,
|
|
37
|
+
} from "./fanout";
|
|
38
|
+
import {
|
|
39
|
+
detectLanding,
|
|
40
|
+
forgeBreaker,
|
|
41
|
+
sliceStart,
|
|
42
|
+
type LandingContext,
|
|
43
|
+
type SliceLanding,
|
|
44
|
+
} from "./fanout-landed";
|
|
45
|
+
|
|
46
|
+
export type SliceStatus = "ready" | "blocked" | "landed";
|
|
47
|
+
|
|
48
|
+
export type SliceCheck = {
|
|
49
|
+
id: string;
|
|
50
|
+
branch: string;
|
|
51
|
+
head: string | null;
|
|
52
|
+
base: string;
|
|
53
|
+
status: SliceStatus;
|
|
54
|
+
changed: number;
|
|
55
|
+
outOfScope: string[];
|
|
56
|
+
/** Paths it adds that the trunk spells differently only in case: `ours ~ trunk's`. */
|
|
57
|
+
caseCollisions: string[];
|
|
58
|
+
trunkConflicts: string[];
|
|
59
|
+
siblingConflicts: Array<{ with: string; paths: string[] }>;
|
|
60
|
+
waitsFor: string[];
|
|
61
|
+
verdict: { accepted: boolean; basis: string };
|
|
62
|
+
/** How it landed, when it did (then nothing else was checked). */
|
|
63
|
+
landed: { how: string; pr: number | null } | null;
|
|
64
|
+
reasons: string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type SiblingConflict = {
|
|
68
|
+
/** Lands first in the landing order. */
|
|
69
|
+
first: string;
|
|
70
|
+
/** Lands second; it carries the conflict. */
|
|
71
|
+
second: string;
|
|
72
|
+
paths: string[];
|
|
73
|
+
/** The two branches name one file with different case (one file on macOS and Windows). */
|
|
74
|
+
caseOnly?: true;
|
|
75
|
+
resolution: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type CheckOutcome = {
|
|
79
|
+
name: string;
|
|
80
|
+
trunk: string;
|
|
81
|
+
trunkRef: string;
|
|
82
|
+
ready: boolean;
|
|
83
|
+
slices: SliceCheck[];
|
|
84
|
+
conflicts: SiblingConflict[];
|
|
85
|
+
landingOrder: string[];
|
|
86
|
+
next: string;
|
|
87
|
+
notes: string[];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type CheckOptions = {
|
|
91
|
+
only?: readonly string[];
|
|
92
|
+
trunkRef?: string | null;
|
|
93
|
+
/** Asks the forge whether a slice's PR merged; null checks with git alone. */
|
|
94
|
+
forge?: ResolvedForge | null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const SAFE = (value: string | null): value is string => Boolean(value) && !value?.startsWith("-");
|
|
98
|
+
|
|
99
|
+
/** Paths both sides change incompatibly, [] when clean, or an error. */
|
|
100
|
+
function mergeConflicts(cwd: string, a: string, b: string): string[] | { error: string } {
|
|
101
|
+
const run = gitRun(cwd, [
|
|
102
|
+
"merge-tree",
|
|
103
|
+
"--write-tree",
|
|
104
|
+
"--name-only",
|
|
105
|
+
"--no-messages",
|
|
106
|
+
"-z",
|
|
107
|
+
a,
|
|
108
|
+
b,
|
|
109
|
+
]);
|
|
110
|
+
if (run.status === 0) return [];
|
|
111
|
+
if (run.status === 1) return [...new Set(nulList(run.stdout).slice(1))];
|
|
112
|
+
const first = run.stderr.trim().split("\n")[0] || `git merge-tree exited ${run.status}`;
|
|
113
|
+
return {
|
|
114
|
+
error: /write-tree|usage: git merge-tree/u.test(run.stderr)
|
|
115
|
+
? `${first} (needs git >= 2.38 for merge-tree --write-tree)`
|
|
116
|
+
: first,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isAncestor = (cwd: string, a: string, b: string): boolean =>
|
|
121
|
+
gitRun(cwd, ["merge-base", "--is-ancestor", a, b]).ok;
|
|
122
|
+
|
|
123
|
+
export function checkFanout(
|
|
124
|
+
cwd: string,
|
|
125
|
+
plan: FanoutFile,
|
|
126
|
+
options: CheckOptions = {},
|
|
127
|
+
): FanoutResult<CheckOutcome> {
|
|
128
|
+
const unknown = (options.only ?? []).filter((id) => !plan.slices.some((s) => s.id === id));
|
|
129
|
+
if (unknown.length)
|
|
130
|
+
return fanoutFail(
|
|
131
|
+
"invalid_input",
|
|
132
|
+
`${unknown.join(", ")}: not a slice of fanout ${plan.name}`,
|
|
133
|
+
`slices: ${plan.slices.map((slice) => slice.id).join(", ")}`,
|
|
134
|
+
);
|
|
135
|
+
const trunk = options.trunkRef ?? trunkRef(cwd, plan.trunk);
|
|
136
|
+
if (!SAFE(trunk) || !resolves(cwd, trunk))
|
|
137
|
+
return fanoutFail(
|
|
138
|
+
"unavailable",
|
|
139
|
+
`trunk ${options.trunkRef ?? plan.trunk} does not resolve here`,
|
|
140
|
+
`git fetch, or pass --base <ref>`,
|
|
141
|
+
);
|
|
142
|
+
const order = landingOrder(plan.slices);
|
|
143
|
+
const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
|
|
144
|
+
const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
|
|
145
|
+
const ledger = readLedger(cwd);
|
|
146
|
+
const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
|
|
147
|
+
|
|
148
|
+
const breaker = forgeBreaker(options.forge ?? null);
|
|
149
|
+
const landings = sliceLandings(cwd, plan, trunk, tips, rows, breaker.forge);
|
|
150
|
+
const trunkCase = caseIndex(
|
|
151
|
+
nulList(gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", trunk]).stdout),
|
|
152
|
+
);
|
|
153
|
+
const checks = new Map<string, SliceCheck>();
|
|
154
|
+
const changedBy = new Map<string, string[]>();
|
|
155
|
+
for (const slice of plan.slices) {
|
|
156
|
+
const landing = landings.get(slice.id) as SliceLanding;
|
|
157
|
+
checks.set(
|
|
158
|
+
slice.id,
|
|
159
|
+
landing.landed
|
|
160
|
+
? landedCheck(slice, tips.get(slice.id) ?? null, landing)
|
|
161
|
+
: inspect(cwd, plan, slice, { trunk, trunkCase, tips, landings, rows }, changedBy),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Pairwise siblings: skip a pair where one contains the other (a stacked child).
|
|
166
|
+
const conflicts: SiblingConflict[] = [];
|
|
167
|
+
for (let i = 0; i < order.length; i += 1)
|
|
168
|
+
for (let j = i + 1; j < order.length; j += 1) {
|
|
169
|
+
const first = order[i];
|
|
170
|
+
const second = order[j];
|
|
171
|
+
const a = tips.get(first);
|
|
172
|
+
const b = tips.get(second);
|
|
173
|
+
if (landings.get(first)?.landed || landings.get(second)?.landed) continue;
|
|
174
|
+
if (!a || !b || isAncestor(cwd, a, b) || isAncestor(cwd, b, a)) continue;
|
|
175
|
+
const check = checks.get(second) as SliceCheck;
|
|
176
|
+
const theirs = new Map(
|
|
177
|
+
(changedBy.get(first) ?? []).map((file) => [file.toLowerCase(), file]),
|
|
178
|
+
);
|
|
179
|
+
const cased = (changedBy.get(second) ?? []).flatMap((file) => {
|
|
180
|
+
const other = theirs.get(file.toLowerCase());
|
|
181
|
+
return other !== undefined && other !== file ? [`${other} ~ ${file}`] : [];
|
|
182
|
+
});
|
|
183
|
+
if (cased.length) {
|
|
184
|
+
conflicts.push({
|
|
185
|
+
first,
|
|
186
|
+
second,
|
|
187
|
+
paths: cased,
|
|
188
|
+
caseOnly: true,
|
|
189
|
+
resolution: `use ${first}'s spelling on ${second} (git mv), then workit fanout check`,
|
|
190
|
+
});
|
|
191
|
+
check.siblingConflicts.push({ with: first, paths: cased });
|
|
192
|
+
check.reasons.push(`differs only in case from ${first}: ${cased.join(", ")}`);
|
|
193
|
+
}
|
|
194
|
+
const result = mergeConflicts(cwd, a, b);
|
|
195
|
+
if (!Array.isArray(result)) {
|
|
196
|
+
check.reasons.push(`cannot merge with ${first}: ${result.error}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (result.length === 0) continue;
|
|
200
|
+
const related = byId.get(second)?.dependsOn.includes(first);
|
|
201
|
+
conflicts.push({
|
|
202
|
+
first,
|
|
203
|
+
second,
|
|
204
|
+
paths: result,
|
|
205
|
+
resolution: related
|
|
206
|
+
? `rebase ${second} onto ${byId.get(first)?.branch} (its dependency), then workit fanout check`
|
|
207
|
+
: `land ${first} first, then rebase ${second} onto ${plan.trunk} and run workit fanout check; or make ${second} depend on ${first}`,
|
|
208
|
+
});
|
|
209
|
+
check.siblingConflicts.push({ with: first, paths: result });
|
|
210
|
+
check.reasons.push(`conflicts with ${first} on ${result.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const id of order) {
|
|
214
|
+
const check = checks.get(id) as SliceCheck;
|
|
215
|
+
if (check.status === "landed") continue;
|
|
216
|
+
check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter(
|
|
217
|
+
(dep) => checks.get(dep)?.status !== "ready" && checks.get(dep)?.status !== "landed",
|
|
218
|
+
);
|
|
219
|
+
if (check.waitsFor.length) check.reasons.push(`waits for ${check.waitsFor.join(", ")}`);
|
|
220
|
+
check.status = check.reasons.length ? "blocked" : "ready";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const selected = options.only?.length ? new Set(options.only) : null;
|
|
224
|
+
const slices = order
|
|
225
|
+
.filter((id) => !selected || selected.has(id))
|
|
226
|
+
.map((id) => checks.get(id) as SliceCheck);
|
|
227
|
+
const ready = slices.every((check) => check.status !== "blocked");
|
|
228
|
+
const blocked = slices.find((check) => check.status === "blocked");
|
|
229
|
+
const notes =
|
|
230
|
+
plan.v === FANOUT_VERSION
|
|
231
|
+
? []
|
|
232
|
+
: [
|
|
233
|
+
`plan file version ${plan.v} is not ${FANOUT_VERSION}; read leniently (re-plan to rewrite it)`,
|
|
234
|
+
];
|
|
235
|
+
const forgeErrors = [...landings.values()].flatMap((landing) =>
|
|
236
|
+
landing.forgeError ? [landing.forgeError] : [],
|
|
237
|
+
);
|
|
238
|
+
if (!options.forge)
|
|
239
|
+
notes.push("landed slices detected with git only (no forge): a squash merge may be missed");
|
|
240
|
+
else if (breaker.down())
|
|
241
|
+
notes.push(`forge went down (${breaker.down()}); git alone decided the remaining slices`);
|
|
242
|
+
else if (forgeErrors.length)
|
|
243
|
+
notes.push(`forge lookups failed (${forgeErrors[0]}); git alone decided those slices`);
|
|
244
|
+
const toLand = slices.filter((check) => check.status === "ready").map((check) => check.id);
|
|
245
|
+
const next = blocked
|
|
246
|
+
? unblockFor(blocked, plan)
|
|
247
|
+
: toLand.length
|
|
248
|
+
? `land in this order: ${toLand.join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)`
|
|
249
|
+
: "every slice has landed";
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
data: {
|
|
253
|
+
name: plan.name,
|
|
254
|
+
trunk: plan.trunk,
|
|
255
|
+
trunkRef: trunk,
|
|
256
|
+
ready,
|
|
257
|
+
slices,
|
|
258
|
+
conflicts: conflicts.filter(
|
|
259
|
+
(conflict) => !selected || selected.has(conflict.first) || selected.has(conflict.second),
|
|
260
|
+
),
|
|
261
|
+
landingOrder: order,
|
|
262
|
+
notes,
|
|
263
|
+
next,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const resolves = (cwd: string, ref: string): boolean =>
|
|
269
|
+
gitRun(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok;
|
|
270
|
+
|
|
271
|
+
/** Landed state per slice, dependencies first (a child's base is its parent's merged head). */
|
|
272
|
+
export function sliceLandings(
|
|
273
|
+
cwd: string,
|
|
274
|
+
plan: FanoutFile,
|
|
275
|
+
trunk: string,
|
|
276
|
+
tips: ReadonlyMap<string, string | null>,
|
|
277
|
+
rows: readonly ReadRow[],
|
|
278
|
+
forge: ResolvedForge | null,
|
|
279
|
+
): Map<string, SliceLanding> {
|
|
280
|
+
const ctx: LandingContext = { cwd, trunk, forge };
|
|
281
|
+
const out = new Map<string, SliceLanding>();
|
|
282
|
+
const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
|
|
283
|
+
for (const id of landingOrder(plan.slices)) {
|
|
284
|
+
const slice = byId.get(id) as Slice;
|
|
285
|
+
const parent = plan.slices.find((other) => other.branch === slice.base);
|
|
286
|
+
const parentHead = parent
|
|
287
|
+
? (tips.get(parent.id) ?? out.get(parent.id)?.landedHead ?? null)
|
|
288
|
+
: null;
|
|
289
|
+
out.set(
|
|
290
|
+
id,
|
|
291
|
+
detectLanding(ctx, slice, {
|
|
292
|
+
head: tips.get(id) ?? null,
|
|
293
|
+
createdFrom: sliceStart(cwd, rows, plan.name, slice),
|
|
294
|
+
parentHead,
|
|
295
|
+
fanout: plan.name,
|
|
296
|
+
since: slice.hashSince || plan.createdAt || null,
|
|
297
|
+
sliceHash: slice.hash || null,
|
|
298
|
+
rows,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const blank = (slice: Slice, head: string | null): SliceCheck => ({
|
|
306
|
+
id: slice.id,
|
|
307
|
+
branch: slice.branch,
|
|
308
|
+
head,
|
|
309
|
+
base: slice.base,
|
|
310
|
+
status: "blocked",
|
|
311
|
+
changed: 0,
|
|
312
|
+
outOfScope: [],
|
|
313
|
+
caseCollisions: [],
|
|
314
|
+
trunkConflicts: [],
|
|
315
|
+
siblingConflicts: [],
|
|
316
|
+
waitsFor: [],
|
|
317
|
+
verdict: { accepted: false, basis: "missing" },
|
|
318
|
+
landed: null,
|
|
319
|
+
reasons: [],
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
function landedCheck(slice: Slice, head: string | null, landing: SliceLanding): SliceCheck {
|
|
323
|
+
return {
|
|
324
|
+
...blank(slice, head),
|
|
325
|
+
status: "landed",
|
|
326
|
+
landed: { how: landing.how ?? "pr_merged", pr: landing.pr?.number ?? null },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
type InspectContext = {
|
|
331
|
+
trunk: string;
|
|
332
|
+
trunkCase: ReadonlyMap<string, ReadonlySet<string>>;
|
|
333
|
+
tips: ReadonlyMap<string, string | null>;
|
|
334
|
+
landings: ReadonlyMap<string, SliceLanding>;
|
|
335
|
+
rows: readonly ReadRow[];
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/** Files at `rev`, or null when it cannot be listed. */
|
|
339
|
+
const treeFiles = (cwd: string, rev: string | null): Set<string> | null => {
|
|
340
|
+
if (!rev) return null;
|
|
341
|
+
const run = gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", rev]);
|
|
342
|
+
return run.ok ? new Set(nulList(run.stdout)) : null;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Paths the slice adds or changes that the trunk spells differently only in
|
|
347
|
+
* case, unless the slice removed the trunk's spelling (a case rename).
|
|
348
|
+
*/
|
|
349
|
+
function trunkCaseCollisions(
|
|
350
|
+
cwd: string,
|
|
351
|
+
ctx: InspectContext,
|
|
352
|
+
head: string,
|
|
353
|
+
changed: readonly string[],
|
|
354
|
+
): string[] {
|
|
355
|
+
const headFiles = treeFiles(cwd, head);
|
|
356
|
+
const forkFiles = treeFiles(
|
|
357
|
+
cwd,
|
|
358
|
+
gitRun(cwd, ["merge-base", head, ctx.trunk]).stdout.trim() || null,
|
|
359
|
+
);
|
|
360
|
+
if (!headFiles) return [];
|
|
361
|
+
const present = (files: ReadonlySet<string> | null, prefix: string): boolean =>
|
|
362
|
+
Boolean(
|
|
363
|
+
files && (files.has(prefix) || [...files].some((file) => file.startsWith(`${prefix}/`))),
|
|
364
|
+
);
|
|
365
|
+
const out = new Set<string>();
|
|
366
|
+
for (const file of changed) {
|
|
367
|
+
if (!headFiles.has(file)) continue;
|
|
368
|
+
const clash = caseClash(ctx.trunkCase, file);
|
|
369
|
+
if (!clash) continue;
|
|
370
|
+
// The slice deleted the trunk's spelling it had: a deliberate case rename.
|
|
371
|
+
if (present(forkFiles, clash.trunk) && !present(headFiles, clash.trunk)) continue;
|
|
372
|
+
out.add(`${clash.path} ~ ${clash.trunk}`);
|
|
373
|
+
}
|
|
374
|
+
return [...out].toSorted();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function inspect(
|
|
378
|
+
cwd: string,
|
|
379
|
+
plan: FanoutFile,
|
|
380
|
+
slice: Slice,
|
|
381
|
+
ctx: InspectContext,
|
|
382
|
+
changedBy: Map<string, string[]>,
|
|
383
|
+
): SliceCheck {
|
|
384
|
+
const { trunk, tips, rows } = ctx;
|
|
385
|
+
const head = tips.get(slice.id) ?? null;
|
|
386
|
+
const check = blank(slice, head);
|
|
387
|
+
if (!head) {
|
|
388
|
+
check.reasons.push(`branch ${slice.branch} not found (not started, or not fetched)`);
|
|
389
|
+
return check;
|
|
390
|
+
}
|
|
391
|
+
const parent = plan.slices.find((other) => other.branch === slice.base);
|
|
392
|
+
// A landed parent whose branch is gone: diff against the head that merged.
|
|
393
|
+
// A child moved onto the trunk after its parent landed by squash (or
|
|
394
|
+
// started there) no longer contains the parent's pre-squash tip: the
|
|
395
|
+
// trunk, which holds the squashed change, is its base then (#506).
|
|
396
|
+
const parentLanded = parent ? ctx.landings.get(parent.id)?.landed === true : false;
|
|
397
|
+
const parentRef = parent
|
|
398
|
+
? (tips.get(parent.id) ?? ctx.landings.get(parent.id)?.landedHead ?? null)
|
|
399
|
+
: null;
|
|
400
|
+
const baseRef =
|
|
401
|
+
slice.base === plan.trunk
|
|
402
|
+
? trunk
|
|
403
|
+
: parent
|
|
404
|
+
? parentLanded && !(parentRef && isAncestor(cwd, parentRef, head))
|
|
405
|
+
? trunk
|
|
406
|
+
: parentRef
|
|
407
|
+
: branchRef(cwd, slice.base);
|
|
408
|
+
if (!SAFE(baseRef)) {
|
|
409
|
+
check.reasons.push(`base ${slice.base} not found`);
|
|
410
|
+
return check;
|
|
411
|
+
}
|
|
412
|
+
const diff = gitRun(cwd, ["diff", "--name-only", "--no-renames", "-z", `${baseRef}...${head}`]);
|
|
413
|
+
if (!diff.ok) {
|
|
414
|
+
check.reasons.push(`cannot diff ${slice.base}...${slice.branch}: ${diff.stderr.trim()}`);
|
|
415
|
+
return check;
|
|
416
|
+
}
|
|
417
|
+
const changed = nulList(diff.stdout);
|
|
418
|
+
changedBy.set(slice.id, changed);
|
|
419
|
+
check.changed = changed.length;
|
|
420
|
+
if (changed.length === 0) check.reasons.push(`no changes beyond ${slice.base}`);
|
|
421
|
+
check.outOfScope = changed.filter((file) => !inScope(plan, slice, file)).toSorted();
|
|
422
|
+
if (check.outOfScope.length)
|
|
423
|
+
check.reasons.push(`edits outside its scope: ${check.outOfScope.join(", ")}`);
|
|
424
|
+
check.caseCollisions = trunkCaseCollisions(cwd, ctx, head, changed);
|
|
425
|
+
if (check.caseCollisions.length)
|
|
426
|
+
check.reasons.push(
|
|
427
|
+
`differs only in case from ${plan.trunk}: ${check.caseCollisions.join(", ")}`,
|
|
428
|
+
);
|
|
429
|
+
const trunkResult = mergeConflicts(cwd, trunk, head);
|
|
430
|
+
if (Array.isArray(trunkResult)) {
|
|
431
|
+
check.trunkConflicts = trunkResult;
|
|
432
|
+
if (trunkResult.length)
|
|
433
|
+
check.reasons.push(`conflicts with ${plan.trunk} on ${trunkResult.join(", ")}`);
|
|
434
|
+
} else check.reasons.push(`cannot merge with ${plan.trunk}: ${trunkResult.error}`);
|
|
435
|
+
const verdict = checkVerdicts(cwd, slice.branch, rows);
|
|
436
|
+
check.verdict = { accepted: verdict.accepted.accepted, basis: verdict.current.basis };
|
|
437
|
+
return check;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function unblockFor(check: SliceCheck, plan: FanoutFile): string {
|
|
441
|
+
if (!check.head)
|
|
442
|
+
return `start or fetch ${check.branch}, then workit fanout check (a landed slice is detected through its merged PR when the forge is reachable)`;
|
|
443
|
+
if (check.outOfScope.length)
|
|
444
|
+
return `move ${check.outOfScope.join(", ")} off ${check.branch} (a follow-up slice), or widen slice ${check.id}'s scope with workit fanout plan, then workit fanout check`;
|
|
445
|
+
if (check.caseCollisions.length)
|
|
446
|
+
return `rename to ${plan.trunk}'s spelling on ${check.branch} (git mv), then workit fanout check`;
|
|
447
|
+
if (check.trunkConflicts.length)
|
|
448
|
+
return `rebase ${check.branch} onto ${plan.trunk} and resolve ${check.trunkConflicts.join(", ")}, then workit fanout check`;
|
|
449
|
+
if (check.siblingConflicts.length) {
|
|
450
|
+
const first = check.siblingConflicts[0].with;
|
|
451
|
+
return `land ${first} first, then rebase ${check.branch} onto ${plan.trunk} and run workit fanout check`;
|
|
452
|
+
}
|
|
453
|
+
if (check.waitsFor.length) return `unblock ${check.waitsFor.join(", ")} first`;
|
|
454
|
+
return check.reasons[0] ?? "workit fanout check";
|
|
455
|
+
}
|