@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,370 @@
|
|
|
1
|
+
// Has a fanout slice landed? Shared by `fanout status` and `fanout check`, so
|
|
2
|
+
// a landed slice is done for both and its dependents stop waiting.
|
|
3
|
+
//
|
|
4
|
+
// With the forge, the slice branch's PR reads merged. A squash merge never
|
|
5
|
+
// makes the branch an ancestor of the trunk, so this is the only reliable
|
|
6
|
+
// signal; it holds while the branch has nothing newer than the merged head.
|
|
7
|
+
// Without the forge (offline, no gh/glab, no PR yet), git decides:
|
|
8
|
+
// - on_trunk: the branch tip is on the trunk and its reflog proves commits of
|
|
9
|
+
// its own (a commit, cherry-pick or rebase made on it, inside start..head);
|
|
10
|
+
// a branch only fast-forwarded to a newer trunk or parent has none;
|
|
11
|
+
// - squash_patch: the branch's change has the same patch-id as one trunk
|
|
12
|
+
// commit (a squash merge that applied without the trunk touching the same
|
|
13
|
+
// lines). Anything else reads as not landed.
|
|
14
|
+
// A merged PR whose branch is gone counts only when the ledger links that
|
|
15
|
+
// branch to this slice: a worktree row made under the slice's current hash,
|
|
16
|
+
// or a row recorded on its merged head since that hash was planned. A reused
|
|
17
|
+
// branch name from older work, an older plan of the same name, or an earlier
|
|
18
|
+
// definition of this slice never links; editing a sibling changes nothing.
|
|
19
|
+
// A landing whose changed paths read on the trunk exactly as before it (a
|
|
20
|
+
// revert) is not landed; the note says so.
|
|
21
|
+
// A merged PR whose trunk ref lacks the merge (a remote-tracking ref not
|
|
22
|
+
// fetched since) is not read as a revert at once: the trunk is fetched once
|
|
23
|
+
// per run and the check repeats; a failed fetch leaves it landed, with a note.
|
|
24
|
+
// The forge is asked through a breaker: after the first unavailable answer
|
|
25
|
+
// (gh/glab missing, not logged in, timed out) it is not asked again in the
|
|
26
|
+
// same run, and git decides the rest.
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { fetchRefs, GIT_TIMEOUTS, hasCommit, mergeBase, patchId } from "./git/rev";
|
|
29
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
30
|
+
import type { ForgeResult, PrRef } from "./forge/types";
|
|
31
|
+
import { gitRun, type Slice } from "./fanout";
|
|
32
|
+
|
|
33
|
+
export type LandedHow = "pr_merged" | "on_trunk" | "squash_patch";
|
|
34
|
+
|
|
35
|
+
export type SliceLanding = {
|
|
36
|
+
landed: boolean;
|
|
37
|
+
how: LandedHow | null;
|
|
38
|
+
/** The slice's PR when the forge was asked and has one (open, closed or merged). */
|
|
39
|
+
pr: PrRef | null;
|
|
40
|
+
/** The merged head: a stacked child's diff base once the branch is gone. */
|
|
41
|
+
landedHead: string | null;
|
|
42
|
+
/** Why the forge could not answer for this slice; null when it did or was not asked. */
|
|
43
|
+
forgeError: string | null;
|
|
44
|
+
note: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type LandingContext = {
|
|
48
|
+
cwd: string;
|
|
49
|
+
/** The trunk ref (e.g. refs/remotes/origin/main). */
|
|
50
|
+
trunk: string;
|
|
51
|
+
forge: ResolvedForge | null;
|
|
52
|
+
/** Fetch the trunk's remote-tracking ref; an error text when it failed, else null. Set on first need, once per run. */
|
|
53
|
+
refreshTrunk?: () => string | null;
|
|
54
|
+
/** Trunk patch-ids per fork point, filled lazily. */
|
|
55
|
+
cache?: Map<string, Set<string>>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type ForgeBreaker = {
|
|
59
|
+
/** The forge whose calls stop after the first unavailable answer; null when there is none. */
|
|
60
|
+
forge: ResolvedForge | null;
|
|
61
|
+
/** Why it went down during this run, else null. */
|
|
62
|
+
down: () => string | null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap the forge so a missing, logged-out or hanging gh/glab costs one call
|
|
67
|
+
* per run, not one timeout per slice: after the first `unavailable` answer
|
|
68
|
+
* every later call fails at once with the same error.
|
|
69
|
+
*/
|
|
70
|
+
export function forgeBreaker(resolved: ResolvedForge | null): ForgeBreaker {
|
|
71
|
+
if (!resolved) return { forge: null, down: () => null };
|
|
72
|
+
let tripped: string | null = null;
|
|
73
|
+
const guard =
|
|
74
|
+
<A extends unknown[], T>(call: (...args: A) => ForgeResult<T>) =>
|
|
75
|
+
(...args: A): ForgeResult<T> => {
|
|
76
|
+
if (tripped) return { ok: false, code: "unavailable", error: tripped };
|
|
77
|
+
const result = call(...args);
|
|
78
|
+
if (!result.ok && result.code === "unavailable") tripped = result.error;
|
|
79
|
+
return result;
|
|
80
|
+
};
|
|
81
|
+
const inner = resolved.forge;
|
|
82
|
+
const forge = Object.create(inner) as typeof inner;
|
|
83
|
+
forge.findPr = guard(inner.findPr.bind(inner));
|
|
84
|
+
forge.prStatus = guard(inner.prStatus.bind(inner));
|
|
85
|
+
return { forge: { ...resolved, forge }, down: () => tripped };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A once-only refresh of a remote-tracking trunk ref (`git fetch <remote>
|
|
90
|
+
* <trunk>`, quiet, that one ref); undefined when the trunk is not one.
|
|
91
|
+
*/
|
|
92
|
+
export function trunkRefresher(cwd: string, trunk: string): (() => string | null) | undefined {
|
|
93
|
+
const match = /^refs\/remotes\/([^/]+)\/(.+)$/u.exec(trunk);
|
|
94
|
+
if (!match) return undefined;
|
|
95
|
+
const [, remote, name] = match;
|
|
96
|
+
let result: string | null | undefined;
|
|
97
|
+
return () => {
|
|
98
|
+
if (result !== undefined) return result;
|
|
99
|
+
const fetched = fetchRefs(cwd, remote, [`+refs/heads/${name}:${trunk}`]);
|
|
100
|
+
result = fetched.ok
|
|
101
|
+
? null
|
|
102
|
+
: `${remote}/${name} could not be refreshed, so a revert check may be stale: run git fetch ${remote} ${name}`;
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isAncestor = (cwd: string, a: string, b: string): boolean =>
|
|
108
|
+
gitRun(cwd, ["merge-base", "--is-ancestor", a, b]).ok;
|
|
109
|
+
|
|
110
|
+
/** Where a local branch began (its oldest reflog entry), or null. */
|
|
111
|
+
export function branchCreatedAt(cwd: string, branch: string): string | null {
|
|
112
|
+
if (!branch || branch.startsWith("-")) return null;
|
|
113
|
+
const log = gitRun(cwd, ["log", "-g", "--format=%H", `refs/heads/${branch}`, "--"]);
|
|
114
|
+
if (!log.ok) return null;
|
|
115
|
+
return log.stdout.trim().split("\n").findLast(Boolean) ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does the branch's reflog show a commit made on it inside `start..head`?
|
|
120
|
+
* A commit, cherry-pick or rebased pick counts; a fast-forward, reset or
|
|
121
|
+
* branch creation does not, and neither does a rebase that only moved it.
|
|
122
|
+
*/
|
|
123
|
+
export function hasOwnCommits(
|
|
124
|
+
cwd: string,
|
|
125
|
+
branch: string,
|
|
126
|
+
start: string | null,
|
|
127
|
+
head: string,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (!branch || branch.startsWith("-")) return false;
|
|
130
|
+
const log = gitRun(cwd, ["log", "-g", "--format=%H%x00%gs", `refs/heads/${branch}`, "--"]);
|
|
131
|
+
if (!log.ok) return false;
|
|
132
|
+
for (const line of log.stdout.split("\n")) {
|
|
133
|
+
const [sha, subject = ""] = line.split("\0");
|
|
134
|
+
if (!sha) continue;
|
|
135
|
+
const made =
|
|
136
|
+
/^(?:commit(?: \((?:initial|amend)\))?|cherry-pick|rebase(?: -i)? \(pick\)):/u.test(subject);
|
|
137
|
+
const onto = /^rebase(?: -i)? \(finish\): \S+ onto ([0-9a-f]+)/u.exec(subject)?.[1];
|
|
138
|
+
if (!made && !(onto && !sha.startsWith(onto) && !onto.startsWith(sha))) continue;
|
|
139
|
+
if (sha !== head && !isAncestor(cwd, sha, head)) continue;
|
|
140
|
+
if (start && (sha === start || isAncestor(cwd, sha, start))) continue;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The landing was undone: every path the change touched reads on the trunk
|
|
148
|
+
* exactly as before it (`git revert`, or a manual undo).
|
|
149
|
+
*/
|
|
150
|
+
function reverted(cwd: string, trunk: string, landedHead: string, start: string | null): boolean {
|
|
151
|
+
const fork = mergeBase(cwd, landedHead, trunk);
|
|
152
|
+
if (fork && fork !== landedHead) return unchangedSince(cwd, fork, landedHead, trunk);
|
|
153
|
+
// On the trunk already (merge or fast-forward): compare with where it began.
|
|
154
|
+
const before =
|
|
155
|
+
start && start !== landedHead && isAncestor(cwd, start, landedHead)
|
|
156
|
+
? start
|
|
157
|
+
: gitRun(cwd, ["rev-parse", "--verify", "-q", `${landedHead}^1`]).stdout.trim();
|
|
158
|
+
return before ? unchangedSince(cwd, before, landedHead, trunk) : false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function unchangedSince(cwd: string, before: string, after: string, trunk: string): boolean {
|
|
162
|
+
const paths = gitRun(cwd, ["diff", "--name-only", "--no-renames", "-z", before, after]);
|
|
163
|
+
const files = paths.ok ? paths.stdout.split("\0").filter(Boolean) : [];
|
|
164
|
+
if (files.length === 0) return false;
|
|
165
|
+
return gitRun(cwd, ["diff", "--quiet", before, trunk, "--", ...files]).ok;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const TRUNK_SCAN = 500;
|
|
169
|
+
|
|
170
|
+
/** Stable patch-ids of the trunk's own commits since `fork` (same pinned diff options as patchId). */
|
|
171
|
+
function trunkPatchIds(ctx: LandingContext, fork: string): Set<string> {
|
|
172
|
+
const cache = (ctx.cache ??= new Map());
|
|
173
|
+
const known = cache.get(fork);
|
|
174
|
+
if (known) return known;
|
|
175
|
+
const ids = new Set<string>();
|
|
176
|
+
const log = gitRun(ctx.cwd, [
|
|
177
|
+
"log",
|
|
178
|
+
"--no-merges",
|
|
179
|
+
`--max-count=${TRUNK_SCAN}`,
|
|
180
|
+
"-p",
|
|
181
|
+
"--format=commit %H",
|
|
182
|
+
"-U3",
|
|
183
|
+
"--diff-algorithm=myers",
|
|
184
|
+
"--indent-heuristic",
|
|
185
|
+
"--no-color",
|
|
186
|
+
"--no-ext-diff",
|
|
187
|
+
"--no-textconv",
|
|
188
|
+
"--no-renames",
|
|
189
|
+
"--full-index",
|
|
190
|
+
"--src-prefix=a/",
|
|
191
|
+
"--dst-prefix=b/",
|
|
192
|
+
`${fork}..${ctx.trunk}`,
|
|
193
|
+
"--",
|
|
194
|
+
]);
|
|
195
|
+
if (log.ok && log.stdout.trim()) {
|
|
196
|
+
const run = spawnSync("git", ["patch-id", "--stable"], {
|
|
197
|
+
cwd: ctx.cwd,
|
|
198
|
+
input: log.stdout,
|
|
199
|
+
encoding: "utf8",
|
|
200
|
+
timeout: GIT_TIMEOUTS.local,
|
|
201
|
+
windowsHide: true,
|
|
202
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
203
|
+
});
|
|
204
|
+
for (const line of (run.stdout ?? "").split("\n")) {
|
|
205
|
+
const id = line.trim().split(/\s+/u)[0];
|
|
206
|
+
if (id) ids.add(id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
cache.set(fork, ids);
|
|
210
|
+
return ids;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Does `row` belong to the slice's current run? A worktree row made under its
|
|
215
|
+
* hash does; any other row only when recorded since that hash was planned.
|
|
216
|
+
*/
|
|
217
|
+
export function inSliceRun(
|
|
218
|
+
row: Record<string, unknown>,
|
|
219
|
+
slice: Pick<Slice, "hash" | "hashSince">,
|
|
220
|
+
): boolean {
|
|
221
|
+
if (row.type === "fanout.worktree.created" && typeof row.sliceHash === "string" && slice.hash)
|
|
222
|
+
return row.sliceHash === slice.hash;
|
|
223
|
+
const since = Date.parse(slice.hashSince);
|
|
224
|
+
return Number.isNaN(since) || Date.parse(String(row.at)) >= since;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The ledger links `branch` to this slice: a worktree row, or a row recorded on `head`. */
|
|
228
|
+
function linked(
|
|
229
|
+
rows: readonly Record<string, unknown>[],
|
|
230
|
+
input: Pick<LandingInput, "fanout" | "since" | "sliceHash">,
|
|
231
|
+
slice: Pick<Slice, "id" | "branch">,
|
|
232
|
+
head: string | null,
|
|
233
|
+
): boolean {
|
|
234
|
+
// Only rows from this run of the slice: a plan of the same name made
|
|
235
|
+
// earlier, or this slice re-planned with another definition, lends nothing.
|
|
236
|
+
const since = input.since ? Date.parse(input.since) : Number.NaN;
|
|
237
|
+
const recent = (row: Record<string, unknown>) =>
|
|
238
|
+
Number.isNaN(since) || Date.parse(String(row.at)) >= since;
|
|
239
|
+
return rows.some((row) => {
|
|
240
|
+
if (
|
|
241
|
+
row.type === "fanout.worktree.created" &&
|
|
242
|
+
row.fanout === input.fanout &&
|
|
243
|
+
row.slice === slice.id
|
|
244
|
+
)
|
|
245
|
+
// A row from before slice hashes falls back to its time.
|
|
246
|
+
return typeof row.sliceHash === "string" && input.sliceHash
|
|
247
|
+
? row.sliceHash === input.sliceHash
|
|
248
|
+
: recent(row);
|
|
249
|
+
return recent(row) && head !== null && row.branch === slice.branch && row.head === head;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type LandingInput = {
|
|
254
|
+
/** The branch tip; null when the branch is gone. */
|
|
255
|
+
head: string | null;
|
|
256
|
+
/** The commit it began at when known (reflog or the worktree ledger row). */
|
|
257
|
+
createdFrom: string | null;
|
|
258
|
+
/** Its dependency branch's tip (or merged head) for a stacked slice. */
|
|
259
|
+
parentHead: string | null;
|
|
260
|
+
fanout: string;
|
|
261
|
+
/** When this slice definition was first planned (Slice.hashSince): older rows do not link. */
|
|
262
|
+
since: string | null;
|
|
263
|
+
/** The slice's definition hash: worktree rows link only under the same one. */
|
|
264
|
+
sliceHash: string | null;
|
|
265
|
+
rows: readonly Record<string, unknown>[];
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Has `slice` landed on the trunk? */
|
|
269
|
+
export function detectLanding(
|
|
270
|
+
ctx: LandingContext,
|
|
271
|
+
slice: Pick<Slice, "id" | "branch">,
|
|
272
|
+
input: LandingInput,
|
|
273
|
+
): SliceLanding {
|
|
274
|
+
const out: SliceLanding = {
|
|
275
|
+
landed: false,
|
|
276
|
+
how: null,
|
|
277
|
+
pr: null,
|
|
278
|
+
landedHead: null,
|
|
279
|
+
forgeError: null,
|
|
280
|
+
note: null,
|
|
281
|
+
};
|
|
282
|
+
const landed = (how: LandedHow, landedHead: string): SliceLanding => {
|
|
283
|
+
if (
|
|
284
|
+
!hasCommit(ctx.cwd, landedHead) ||
|
|
285
|
+
!reverted(ctx.cwd, ctx.trunk, landedHead, input.createdFrom)
|
|
286
|
+
)
|
|
287
|
+
return { ...out, landed: true, how, landedHead };
|
|
288
|
+
if (how === "pr_merged" && ctx.forge) {
|
|
289
|
+
// The forge says merged; a stale remote-tracking trunk may just not show it yet.
|
|
290
|
+
ctx.refreshTrunk ??= trunkRefresher(ctx.cwd, ctx.trunk) ?? (() => null);
|
|
291
|
+
const failed = ctx.refreshTrunk();
|
|
292
|
+
if (failed) return { ...out, landed: true, how, landedHead, note: failed };
|
|
293
|
+
if (!reverted(ctx.cwd, ctx.trunk, landedHead, input.createdFrom))
|
|
294
|
+
return { ...out, landed: true, how, landedHead };
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
...out,
|
|
298
|
+
note: `landed (${how}${out.pr ? ` #${out.pr.number}` : ""}), then reverted on the trunk: its paths read as before it`,
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
const { head } = input;
|
|
302
|
+
if (ctx.forge) {
|
|
303
|
+
const found = ctx.forge.forge.findPr(slice.branch, {
|
|
304
|
+
owner: ctx.forge.headRepo.split("/")[0] ?? null,
|
|
305
|
+
projectId: ctx.forge.headProjectId,
|
|
306
|
+
sha: head,
|
|
307
|
+
});
|
|
308
|
+
if (!found.ok) out.forgeError = found.error;
|
|
309
|
+
else if (found.data) {
|
|
310
|
+
out.pr = found.data;
|
|
311
|
+
if (found.data.state === "merged") {
|
|
312
|
+
const merged = found.data.headSha;
|
|
313
|
+
const label = `#${found.data.number}`;
|
|
314
|
+
if (!head) {
|
|
315
|
+
if (!linked(input.rows, input, slice, merged)) {
|
|
316
|
+
out.pr = null;
|
|
317
|
+
out.note = `${slice.branch} is gone; merged ${label} is not linked to this slice in the ledger (an older branch of the same name?)`;
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
return merged ? landed("pr_merged", merged) : { ...out, landed: true, how: "pr_merged" };
|
|
321
|
+
}
|
|
322
|
+
if (!merged || head === merged || isAncestor(ctx.cwd, head, merged))
|
|
323
|
+
return landed("pr_merged", merged ?? head);
|
|
324
|
+
out.note = isAncestor(ctx.cwd, merged, head)
|
|
325
|
+
? `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} is ahead of it with newer commits`
|
|
326
|
+
: `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} has diverged from it (rewritten after the merge)`;
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!head) return out;
|
|
332
|
+
const fork = mergeBase(ctx.cwd, head, ctx.trunk);
|
|
333
|
+
if (fork === head) {
|
|
334
|
+
// On the trunk: landed only when commits were made on the branch itself.
|
|
335
|
+
return hasOwnCommits(ctx.cwd, slice.branch, input.createdFrom, head)
|
|
336
|
+
? landed("on_trunk", head)
|
|
337
|
+
: out;
|
|
338
|
+
}
|
|
339
|
+
if (!fork) return out;
|
|
340
|
+
const trunkIds = trunkPatchIds(ctx, fork);
|
|
341
|
+
if (trunkIds.size === 0) return out;
|
|
342
|
+
const froms = [fork];
|
|
343
|
+
if (input.parentHead && input.parentHead !== head && isAncestor(ctx.cwd, input.parentHead, head))
|
|
344
|
+
froms.push(input.parentHead);
|
|
345
|
+
for (const from of froms) {
|
|
346
|
+
const id = patchId(ctx.cwd, from, head);
|
|
347
|
+
if (id && trunkIds.has(id)) return landed("squash_patch", head);
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The commit a slice branch began at: its reflog, else `fanout worktree create`'s ledger row. */
|
|
353
|
+
export function sliceStart(
|
|
354
|
+
cwd: string,
|
|
355
|
+
rows: readonly Record<string, unknown>[],
|
|
356
|
+
fanout: string,
|
|
357
|
+
slice: Pick<Slice, "id" | "branch" | "hash" | "hashSince">,
|
|
358
|
+
): string | null {
|
|
359
|
+
const reflog = branchCreatedAt(cwd, slice.branch);
|
|
360
|
+
if (reflog) return reflog;
|
|
361
|
+
const row = rows.findLast(
|
|
362
|
+
(candidate) =>
|
|
363
|
+
candidate.type === "fanout.worktree.created" &&
|
|
364
|
+
candidate.fanout === fanout &&
|
|
365
|
+
candidate.slice === slice.id &&
|
|
366
|
+
typeof candidate.baseSha === "string" &&
|
|
367
|
+
inSliceRun(candidate, slice),
|
|
368
|
+
);
|
|
369
|
+
return (row?.baseSha as string | undefined) ?? null;
|
|
370
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Fanout slices with `dependsOn` are stacks (W2.37). A slice whose base is
|
|
2
|
+
// another slice's branch starts on that branch (`fanout worktree create`), and
|
|
3
|
+
// the chain from its bottom slice up to it is recorded as a `workit stack`
|
|
4
|
+
// named `<fanout>/<bottom slice>`. Once the parent lands (a squash merge
|
|
5
|
+
// included), `workit stack sync` rebases the child onto the trunk with
|
|
6
|
+
// `--onto <the parent tip it was built on>`, so the parent's commits drop out.
|
|
7
|
+
//
|
|
8
|
+
// A fork (two children of one parent) is a second stack, named after the
|
|
9
|
+
// child. `fanout status` lists the recorded stacks and the planned chains not
|
|
10
|
+
// recorded yet, bottom first.
|
|
11
|
+
import { branchRef, type FanoutFile, type Slice } from "./fanout";
|
|
12
|
+
import { listStacks, recordStack, type StackFile } from "./stack";
|
|
13
|
+
|
|
14
|
+
const parentOf = (plan: FanoutFile, slice: Slice): Slice | undefined =>
|
|
15
|
+
slice.base === plan.trunk
|
|
16
|
+
? undefined
|
|
17
|
+
: plan.slices.find((other) => other.id !== slice.id && other.branch === slice.base);
|
|
18
|
+
|
|
19
|
+
/** The slices from the bottom of `slice`'s chain up to it, through parent branches. */
|
|
20
|
+
export function stackPath(plan: FanoutFile, slice: Slice): Slice[] {
|
|
21
|
+
const chain = [slice];
|
|
22
|
+
const seen = new Set([slice.id]);
|
|
23
|
+
for (let parent = parentOf(plan, slice); parent; parent = parentOf(plan, parent)) {
|
|
24
|
+
if (seen.has(parent.id)) break;
|
|
25
|
+
seen.add(parent.id);
|
|
26
|
+
chain.unshift(parent);
|
|
27
|
+
}
|
|
28
|
+
return chain;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One chain per slice nobody stacks on, when it has a parent slice: bottom first. */
|
|
32
|
+
function plannedChains(plan: FanoutFile): Slice[][] {
|
|
33
|
+
const parents = new Set(
|
|
34
|
+
plan.slices.map((slice) => parentOf(plan, slice)?.id).filter((id) => id !== undefined),
|
|
35
|
+
);
|
|
36
|
+
return plan.slices
|
|
37
|
+
.filter((slice) => !parents.has(slice.id))
|
|
38
|
+
.map((slice) => stackPath(plan, slice))
|
|
39
|
+
.filter((chain) => chain.length > 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The recorded stacks of this fanout (named `<fanout>/…`). */
|
|
43
|
+
function fanoutStacks(cwd: string, plan: FanoutFile): StackFile[] {
|
|
44
|
+
const all = listStacks(cwd);
|
|
45
|
+
return all.ok ? all.data.filter((stack) => stack.name.startsWith(`${plan.name}/`)) : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sameList = (a: readonly string[], b: readonly string[]): boolean =>
|
|
49
|
+
a.length === b.length && a.every((value, index) => value === b[index]);
|
|
50
|
+
|
|
51
|
+
export type SliceStack =
|
|
52
|
+
| { ok: true; name: string; slices: string[] }
|
|
53
|
+
| { ok: false; error: string; unblock: string };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record the stack `slice` was just started in: extend the stack that ends at
|
|
57
|
+
* its parent, refresh one that already holds it, else start
|
|
58
|
+
* `<fanout>/<bottom slice>` (`<fanout>/<slice>` when that name is taken by a
|
|
59
|
+
* sibling chain). Null when the slice is not stacked. Branches below the
|
|
60
|
+
* first one that exists locally (landed and deleted) are left out.
|
|
61
|
+
*/
|
|
62
|
+
export function recordSliceStack(cwd: string, plan: FanoutFile, slice: Slice): SliceStack | null {
|
|
63
|
+
const full = stackPath(plan, slice);
|
|
64
|
+
const first = full.findIndex((item) => branchRef(cwd, item.branch) !== null);
|
|
65
|
+
const chain = first < 0 ? [] : full.slice(first);
|
|
66
|
+
if (chain.length < 2) return null;
|
|
67
|
+
const branches = chain.map((item) => item.branch);
|
|
68
|
+
const stacks = fanoutStacks(cwd, plan);
|
|
69
|
+
const listOf = (stack: StackFile) => stack.branches.map((entry) => entry.branch);
|
|
70
|
+
const holding = stacks.find((stack) => listOf(stack).includes(slice.branch));
|
|
71
|
+
const below = stacks.find((stack) => sameList(listOf(stack), branches.slice(0, -1)));
|
|
72
|
+
const bottom = `${plan.name}/${chain[0].id}`;
|
|
73
|
+
const name =
|
|
74
|
+
holding?.name ??
|
|
75
|
+
below?.name ??
|
|
76
|
+
(stacks.some((stack) => stack.name === bottom) ? `${plan.name}/${slice.id}` : bottom);
|
|
77
|
+
const list = holding ? listOf(holding) : branches;
|
|
78
|
+
const recorded = recordStack(cwd, { name, trunk: plan.trunk, branches: list });
|
|
79
|
+
if (!recorded.ok)
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: `stack ${name} was not recorded: ${recorded.error}`,
|
|
83
|
+
unblock: recorded.unblock ?? `workit stack plan --name ${name} ${list.join(" ")}`,
|
|
84
|
+
};
|
|
85
|
+
const ids = new Map(plan.slices.map((item) => [item.branch, item.id]));
|
|
86
|
+
return { ok: true, name, slices: list.map((branch) => ids.get(branch) ?? branch) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type FanoutStackRow = {
|
|
90
|
+
/** The recorded stack's name; null for a planned chain not recorded yet. */
|
|
91
|
+
name: string | null;
|
|
92
|
+
/** Slice ids (a branch outside the plan by name), bottom first. */
|
|
93
|
+
slices: string[];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The recorded stacks of `plan`, then each planned chain no recorded stack equals. */
|
|
97
|
+
export function fanoutStackRows(cwd: string, plan: FanoutFile): FanoutStackRow[] {
|
|
98
|
+
const ids = new Map(plan.slices.map((slice) => [slice.branch, slice.id]));
|
|
99
|
+
const recorded = fanoutStacks(cwd, plan).map((stack) => ({
|
|
100
|
+
name: stack.name,
|
|
101
|
+
slices: stack.branches.map((entry) => ids.get(entry.branch) ?? entry.branch),
|
|
102
|
+
}));
|
|
103
|
+
const planned = plannedChains(plan)
|
|
104
|
+
.map((chain) => chain.map((slice) => slice.id))
|
|
105
|
+
.filter((chain) => !recorded.some((row) => sameList(row.slices, chain)))
|
|
106
|
+
.map((slices) => ({ name: null, slices }));
|
|
107
|
+
return [...recorded, ...planned];
|
|
108
|
+
}
|