@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
package/src/stack.ts
ADDED
|
@@ -0,0 +1,2570 @@
|
|
|
1
|
+
// `workit stack` (D7, design §2.1 S12): plain base-branch chains on GitHub and
|
|
2
|
+
// GitLab, no Graphite and no `gh stack`/git-spice dependency.
|
|
3
|
+
//
|
|
4
|
+
// Truth is git ancestry plus the forge's PR bases. The stack file
|
|
5
|
+
// `<store>/stacks/<name>.json` (D13: under the git common dir, so it is shared
|
|
6
|
+
// by every worktree and survives worktree removal) is a reconstructible
|
|
7
|
+
// cache of the order and of what each branch was last built on:
|
|
8
|
+
// {v:1, name, trunk, forge, repo, branches:[{branch, parent, pr, lastHead,
|
|
9
|
+
// lastParentHead, patchId, merged}], updatedAt}
|
|
10
|
+
// `lastParentHead` is the parent tip a branch was last rebased onto, so a
|
|
11
|
+
// restack is `git rebase --onto <new parent tip> <lastParentHead> <branch>`.
|
|
12
|
+
//
|
|
13
|
+
// One writer per stack: every command that writes the file or touches the
|
|
14
|
+
// branches holds `<store>/stacks/<name>.json.lock` (a held lock answers
|
|
15
|
+
// `busy`; a lock left by a dead process is reclaimed).
|
|
16
|
+
//
|
|
17
|
+
// sync: after a parent merged (squash merges rewrite history, so a child must
|
|
18
|
+
// be rebased, design §0 #9) every remaining branch is restacked in order,
|
|
19
|
+
// pushed with a lease through the S11 push, and its PR retargeted. A rebase
|
|
20
|
+
// conflict stops with the rebase left in progress and the unblock command;
|
|
21
|
+
// unprocessed branches keep their recorded state. Each restack appends an
|
|
22
|
+
// observed `stack.restacked` row; when the change relative to its parent is
|
|
23
|
+
// identical (patch-id and exact diff hash), S13 carries the verdict through
|
|
24
|
+
// it. CI is never carried: it runs again on the new head.
|
|
25
|
+
//
|
|
26
|
+
// land: first a pre-flight (#739) judges every PR the run would land (open,
|
|
27
|
+
// based on trunk or its parent, READY (S10), verdict-accepted (S13), the
|
|
28
|
+
// merge grant, its author, the `--allow-failing` waiver on its head); any
|
|
29
|
+
// refusal stops the land with nothing merged and names every refused PR. Then
|
|
30
|
+
// it merges from the root, one at a time through the S11 `pr merge` gates
|
|
31
|
+
// (merge grant, head-SHA guard). After each merge the rest is restacked and
|
|
32
|
+
// retargeted and the next PR's CI is awaited before it is judged again; what
|
|
33
|
+
// only shows then (a head that moved, a forge race) stops the run there, and
|
|
34
|
+
// the outcome lists what landed. Descendants are never armed for auto-merge.
|
|
35
|
+
//
|
|
36
|
+
// Optional adapters (`gh stack`, git-spice) can later implement `StackAdapter`
|
|
37
|
+
// and replace the built-in restack/retarget; nothing else depends on how the
|
|
38
|
+
// chain is rewritten.
|
|
39
|
+
import { spawnSync } from "node:child_process";
|
|
40
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
import { requireGrant, type GrantDecision } from "./autonomy";
|
|
44
|
+
import { pushTarget } from "./core/remotes";
|
|
45
|
+
import { isProtectedTarget } from "./core/branch";
|
|
46
|
+
import { classifyLockOwner, localLockHost, processStartOf } from "./core/store-lock";
|
|
47
|
+
import { baseTarget, forgeLogin, mergePullRequest, otherAuthor, type Sleep } from "./forge/pr-ops";
|
|
48
|
+
import {
|
|
49
|
+
buildStatusDoc,
|
|
50
|
+
mergeGateForge,
|
|
51
|
+
pollDelay,
|
|
52
|
+
waitVerdict,
|
|
53
|
+
type CheckWaiver,
|
|
54
|
+
type WaivedCheck,
|
|
55
|
+
type ChecksState,
|
|
56
|
+
type NextAction,
|
|
57
|
+
type PrStatusDoc,
|
|
58
|
+
} from "./forge/report";
|
|
59
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
60
|
+
import type { ForgeResult, MergeMethod, PrState } from "./forge/types";
|
|
61
|
+
import { executePush, pushPreflight, recordedRemoteTip, type PushPlan } from "./git/ops";
|
|
62
|
+
import {
|
|
63
|
+
currentBranch,
|
|
64
|
+
fetchRefs,
|
|
65
|
+
GIT_TIMEOUTS,
|
|
66
|
+
hasCommit,
|
|
67
|
+
mergeBase,
|
|
68
|
+
patchId,
|
|
69
|
+
pushRemoteName,
|
|
70
|
+
remoteRefTip,
|
|
71
|
+
resolveRef,
|
|
72
|
+
sameOwnChange,
|
|
73
|
+
} from "./git/rev";
|
|
74
|
+
import {
|
|
75
|
+
appendObserved,
|
|
76
|
+
diffHash,
|
|
77
|
+
rangeKeys,
|
|
78
|
+
readLedger,
|
|
79
|
+
storeRoot,
|
|
80
|
+
type LedgerActor,
|
|
81
|
+
type ReadRow,
|
|
82
|
+
type VerdictBasis,
|
|
83
|
+
} from "./ledger";
|
|
84
|
+
import type { LaneNeed, ReviewLane } from "./core/policy/lane";
|
|
85
|
+
import { prLaneGate } from "./core/policy/pr-lane";
|
|
86
|
+
import { checkVerdictsCovered, type StackCover } from "./verdict-cover";
|
|
87
|
+
|
|
88
|
+
export const STACK_VERSION = 1;
|
|
89
|
+
|
|
90
|
+
export type StackEntry = {
|
|
91
|
+
branch: string;
|
|
92
|
+
/** The branch this one is built on (the trunk for the root). */
|
|
93
|
+
parent: string;
|
|
94
|
+
pr: number | null;
|
|
95
|
+
/** The branch tip when the stack last processed it. */
|
|
96
|
+
lastHead: string | null;
|
|
97
|
+
/** The parent tip it was last rebased onto (the `--onto` upstream for the next restack). */
|
|
98
|
+
lastParentHead: string | null;
|
|
99
|
+
/** patch-id of lastParentHead...lastHead. */
|
|
100
|
+
patchId: string | null;
|
|
101
|
+
/** Set once its PR merged (or its tip reached the trunk); skipped afterwards. */
|
|
102
|
+
merged: { pr: number | null; mergeSha: string | null; at: string } | null;
|
|
103
|
+
/**
|
|
104
|
+
* A restack begun but not yet verified and pushed: the change as it was
|
|
105
|
+
* (`from..tip`) and the base it is moving onto. Survives a conflict or a
|
|
106
|
+
* content_changed stop, so the next sync compares against the original.
|
|
107
|
+
*/
|
|
108
|
+
pendingRestack?: { from: string; tip: string; onto: string } | null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type StackFile = {
|
|
112
|
+
v: number;
|
|
113
|
+
name: string;
|
|
114
|
+
trunk: string;
|
|
115
|
+
forge: "github" | "gitlab" | null;
|
|
116
|
+
repo: string | null;
|
|
117
|
+
branches: StackEntry[];
|
|
118
|
+
updatedAt: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type StackError = {
|
|
122
|
+
ok: false;
|
|
123
|
+
code: "blocked" | "failed" | "unavailable" | "not_found" | "busy" | "invalid_input";
|
|
124
|
+
error: string;
|
|
125
|
+
unblock?: string;
|
|
126
|
+
data?: Record<string, unknown>;
|
|
127
|
+
};
|
|
128
|
+
export type StackResult<T> = { ok: true; data: T } | StackError;
|
|
129
|
+
|
|
130
|
+
const stackFail = (
|
|
131
|
+
code: StackError["code"],
|
|
132
|
+
error: string,
|
|
133
|
+
unblock?: string,
|
|
134
|
+
data?: Record<string, unknown>,
|
|
135
|
+
): StackError => ({
|
|
136
|
+
ok: false,
|
|
137
|
+
code,
|
|
138
|
+
error,
|
|
139
|
+
...(unblock ? { unblock } : {}),
|
|
140
|
+
...(data ? { data } : {}),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const fromForge = (result: Extract<ForgeResult<unknown>, { ok: false }>): StackError =>
|
|
144
|
+
stackFail(result.code, result.error, result.unblock);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Test and adapter seams. `pushPlan` decides where a branch is pushed (the
|
|
148
|
+
* S11 preflight by default); `adapter` is where an optional `gh stack` /
|
|
149
|
+
* git-spice adapter would plug in later. Only the built-in one ships.
|
|
150
|
+
*/
|
|
151
|
+
export type StackAdapter = {
|
|
152
|
+
name: string;
|
|
153
|
+
/** Rebase `branch` from `from` onto `onto`; the built-in one runs git rebase --onto. */
|
|
154
|
+
restack(cwd: string, input: { branch: string; onto: string; from: string }): RestackOutcome;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const stackDeps: {
|
|
158
|
+
pushPlan: (cwd: string, branch: string) => ForgeResult<PushPlan>;
|
|
159
|
+
adapter: StackAdapter;
|
|
160
|
+
/** Bound on one restack (hooks such as post-rewrite run inside it). */
|
|
161
|
+
rebaseTimeoutMs: number;
|
|
162
|
+
} = {
|
|
163
|
+
pushPlan: (cwd, branch) => pushPreflight(cwd, { branch }),
|
|
164
|
+
adapter: { name: "git", restack: (cwd, input) => gitRestack(cwd, input) },
|
|
165
|
+
rebaseTimeoutMs: Number(process.env.WORKIT_STACK_REBASE_TIMEOUT_MS) || 10 * 60_000,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// git plumbing
|
|
170
|
+
|
|
171
|
+
type GitRun = {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
stdout: string;
|
|
174
|
+
stderr: string;
|
|
175
|
+
timedOut: boolean;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
|
|
179
|
+
const run = spawnSync("git", args, {
|
|
180
|
+
cwd,
|
|
181
|
+
encoding: "utf8",
|
|
182
|
+
timeout: timeoutMs,
|
|
183
|
+
killSignal: "SIGKILL",
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
186
|
+
env: {
|
|
187
|
+
...process.env,
|
|
188
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
189
|
+
GIT_EDITOR: "true",
|
|
190
|
+
GIT_SEQUENCE_EDITOR: "true",
|
|
191
|
+
LC_ALL: "C",
|
|
192
|
+
LANGUAGE: "C",
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
const failure: NodeJS.ErrnoException | undefined = run.error;
|
|
196
|
+
return {
|
|
197
|
+
ok: run.status === 0,
|
|
198
|
+
stdout: run.stdout ?? "",
|
|
199
|
+
stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
|
|
200
|
+
timedOut: failure?.code === "ETIMEDOUT",
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
|
|
205
|
+
|
|
206
|
+
const isAncestor = (cwd: string, ancestor: string, descendant: string): boolean =>
|
|
207
|
+
safeRef(ancestor) &&
|
|
208
|
+
safeRef(descendant) &&
|
|
209
|
+
gitRun(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
210
|
+
|
|
211
|
+
const branchTip = (cwd: string, branch: string): string | null =>
|
|
212
|
+
resolveRef(cwd, `refs/heads/${branch}`);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The worktree that has `branch` checked out, if any, including one where a
|
|
216
|
+
* rebase of it is stopped (HEAD is detached then; the branch is named in
|
|
217
|
+
* rebase-merge/head-name).
|
|
218
|
+
*/
|
|
219
|
+
function worktreeOf(cwd: string, branch: string): string | null {
|
|
220
|
+
const list = gitRun(cwd, ["worktree", "list", "--porcelain"]);
|
|
221
|
+
if (!list.ok) return null;
|
|
222
|
+
let current: string | null = null;
|
|
223
|
+
for (const line of list.stdout.split("\n")) {
|
|
224
|
+
if (line.startsWith("worktree ")) current = line.slice("worktree ".length);
|
|
225
|
+
else if (line === `branch refs/heads/${branch}` && current) return current;
|
|
226
|
+
else if (line === "detached" && current && rebasingBranch(current) === `refs/heads/${branch}`)
|
|
227
|
+
return current;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function rebasingBranch(dir: string): string | null {
|
|
233
|
+
for (const name of ["rebase-merge/head-name", "rebase-apply/head-name"]) {
|
|
234
|
+
const found = gitRun(dir, ["rev-parse", "--path-format=absolute", "--git-path", name]);
|
|
235
|
+
if (!found.ok) continue;
|
|
236
|
+
try {
|
|
237
|
+
return fs.readFileSync(found.stdout.trim(), "utf8").trim();
|
|
238
|
+
} catch {
|
|
239
|
+
// not this one
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A rebase (or am) stopped in this worktree. */
|
|
246
|
+
function rebaseInProgress(dir: string): boolean {
|
|
247
|
+
for (const name of ["rebase-merge", "rebase-apply"]) {
|
|
248
|
+
const found = gitRun(dir, ["rev-parse", "--path-format=absolute", "--git-path", name]);
|
|
249
|
+
if (found.ok && fs.existsSync(found.stdout.trim())) return true;
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const trackedDirt = (dir: string): boolean => {
|
|
255
|
+
const status = gitRun(dir, ["status", "--porcelain", "--untracked-files=no"]);
|
|
256
|
+
return !status.ok || status.stdout.trim() !== "";
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export type RestackOutcome =
|
|
260
|
+
| { ok: true; head: string }
|
|
261
|
+
| {
|
|
262
|
+
ok: false;
|
|
263
|
+
conflict: boolean;
|
|
264
|
+
timedOut?: boolean;
|
|
265
|
+
worktree: string;
|
|
266
|
+
error: string;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Where sync checks out branches nobody has checked out (never the user's checkout). */
|
|
270
|
+
const scratchDir = (cwd: string): string | null => {
|
|
271
|
+
const dir = stacksDir(cwd);
|
|
272
|
+
return dir.ok ? path.join(dir.data, "worktrees") : null;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `git rebase --onto <onto> <from>` with the branch checked out: in the
|
|
277
|
+
* worktree that already has it, else in a temporary worktree under the store
|
|
278
|
+
* root (`git worktree add`), so the user's own checkout never moves. Never
|
|
279
|
+
* autostashes and never updates other refs (`rebase.updateRefs` would move
|
|
280
|
+
* sibling stack branches). A temporary worktree is removed on success and
|
|
281
|
+
* kept on a conflict, so the rebase can be continued there.
|
|
282
|
+
*/
|
|
283
|
+
function gitRestack(
|
|
284
|
+
cwd: string,
|
|
285
|
+
input: { branch: string; onto: string; from: string },
|
|
286
|
+
): RestackOutcome {
|
|
287
|
+
const owner = worktreeOf(cwd, input.branch);
|
|
288
|
+
let dir = owner;
|
|
289
|
+
if (!dir) {
|
|
290
|
+
const scratch = scratchDir(cwd);
|
|
291
|
+
if (!scratch)
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
conflict: false,
|
|
295
|
+
worktree: cwd,
|
|
296
|
+
error: "no store root",
|
|
297
|
+
};
|
|
298
|
+
fs.mkdirSync(scratch, { recursive: true });
|
|
299
|
+
dir = path.join(
|
|
300
|
+
scratch,
|
|
301
|
+
`${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes(4).toString("hex")}`,
|
|
302
|
+
);
|
|
303
|
+
const added = gitRun(cwd, ["worktree", "add", "-q", dir, input.branch], GIT_TIMEOUTS.worktree);
|
|
304
|
+
if (!added.ok)
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
conflict: false,
|
|
308
|
+
worktree: cwd,
|
|
309
|
+
error: firstLine(added),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const run = gitRun(
|
|
313
|
+
dir,
|
|
314
|
+
[
|
|
315
|
+
"-c",
|
|
316
|
+
"rebase.autoStash=false",
|
|
317
|
+
"-c",
|
|
318
|
+
"rebase.updateRefs=false",
|
|
319
|
+
"rebase",
|
|
320
|
+
"--no-autosquash",
|
|
321
|
+
"--onto",
|
|
322
|
+
input.onto,
|
|
323
|
+
input.from,
|
|
324
|
+
],
|
|
325
|
+
stackDeps.rebaseTimeoutMs,
|
|
326
|
+
);
|
|
327
|
+
if (!run.ok) {
|
|
328
|
+
// Killed at the bound: the rebase stopped mid-way, which is not a conflict.
|
|
329
|
+
if (run.timedOut)
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
conflict: false,
|
|
333
|
+
timedOut: true,
|
|
334
|
+
worktree: dir,
|
|
335
|
+
error: `git rebase did not finish within ${stackDeps.rebaseTimeoutMs} ms`,
|
|
336
|
+
};
|
|
337
|
+
if (rebaseInProgress(dir))
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
conflict: true,
|
|
341
|
+
worktree: dir,
|
|
342
|
+
error: firstLine(run),
|
|
343
|
+
};
|
|
344
|
+
if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
345
|
+
return { ok: false, conflict: false, worktree: dir, error: firstLine(run) };
|
|
346
|
+
}
|
|
347
|
+
if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
348
|
+
const head = branchTip(cwd, input.branch);
|
|
349
|
+
return head
|
|
350
|
+
? { ok: true, head }
|
|
351
|
+
: {
|
|
352
|
+
ok: false,
|
|
353
|
+
conflict: false,
|
|
354
|
+
worktree: dir,
|
|
355
|
+
error: `${input.branch} vanished`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Remove temporary sync worktrees that are clean and not mid-rebase. */
|
|
360
|
+
function pruneScratch(cwd: string): void {
|
|
361
|
+
const scratch = scratchDir(cwd);
|
|
362
|
+
if (!scratch || !fs.existsSync(scratch)) return;
|
|
363
|
+
for (const name of fs.readdirSync(scratch)) {
|
|
364
|
+
const dir = path.join(scratch, name);
|
|
365
|
+
if (!rebaseInProgress(dir) && !trackedDirt(dir))
|
|
366
|
+
gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
367
|
+
}
|
|
368
|
+
gitRun(cwd, ["worktree", "prune"]);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const firstLine = (run: GitRun): string =>
|
|
372
|
+
(`${run.stderr}\n${run.stdout}`.split("\n").find((line) => line.trim()) ?? "git failed")
|
|
373
|
+
.trim()
|
|
374
|
+
.slice(0, 300);
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// store
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Portable file name for a stack: a readable slug plus a hash of the exact
|
|
381
|
+
* name, so names differing only in case or in characters Windows rejects
|
|
382
|
+
* never collide, and no file is ever a reserved device name (CON, NUL, …).
|
|
383
|
+
*/
|
|
384
|
+
export const stackFileName = (name: string): string =>
|
|
385
|
+
`${name
|
|
386
|
+
.replace(/[^A-Za-z0-9._-]+/gu, "_")
|
|
387
|
+
.replace(/^[._]+/u, "")
|
|
388
|
+
.slice(0, 48)
|
|
389
|
+
.toLowerCase()}-${createHash("sha256").update(name).digest("hex").slice(0, 12)}.json`;
|
|
390
|
+
|
|
391
|
+
const fileFor = stackFileName;
|
|
392
|
+
|
|
393
|
+
/** A stack name must be printable and short. */
|
|
394
|
+
const validName = (name: string): boolean => {
|
|
395
|
+
if (name.length === 0 || name.length > 200) return false;
|
|
396
|
+
for (let index = 0; index < name.length; index += 1) {
|
|
397
|
+
const code = name.charCodeAt(index);
|
|
398
|
+
if (code < 0x20 || code === 0x7f) return false;
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export function stacksDir(cwd: string): StackResult<string> {
|
|
404
|
+
const root = storeRoot(cwd);
|
|
405
|
+
if (!root.ok) return stackFail(root.code, root.error, root.unblock);
|
|
406
|
+
return { ok: true, data: path.join(root.value.root, "stacks") };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
410
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
411
|
+
const str = (value: unknown): string | null => (typeof value === "string" && value ? value : null);
|
|
412
|
+
|
|
413
|
+
/** Tolerant read (D17): unknown keys are ignored, required keys are checked. */
|
|
414
|
+
function parseStack(raw: unknown): StackFile | null {
|
|
415
|
+
if (!isRecord(raw) || !Array.isArray(raw.branches)) return null;
|
|
416
|
+
const name = str(raw.name);
|
|
417
|
+
const trunk = str(raw.trunk);
|
|
418
|
+
if (!name || !trunk) return null;
|
|
419
|
+
const branches: StackEntry[] = [];
|
|
420
|
+
for (const item of raw.branches) {
|
|
421
|
+
if (!isRecord(item)) return null;
|
|
422
|
+
const branch = str(item.branch);
|
|
423
|
+
if (!branch) return null;
|
|
424
|
+
const merged = isRecord(item.merged)
|
|
425
|
+
? {
|
|
426
|
+
pr: typeof item.merged.pr === "number" ? item.merged.pr : null,
|
|
427
|
+
mergeSha: str(item.merged.mergeSha),
|
|
428
|
+
at: str(item.merged.at) ?? "",
|
|
429
|
+
}
|
|
430
|
+
: null;
|
|
431
|
+
branches.push({
|
|
432
|
+
branch,
|
|
433
|
+
parent: str(item.parent) ?? trunk,
|
|
434
|
+
pr: typeof item.pr === "number" ? item.pr : null,
|
|
435
|
+
lastHead: str(item.lastHead),
|
|
436
|
+
lastParentHead: str(item.lastParentHead),
|
|
437
|
+
patchId: str(item.patchId),
|
|
438
|
+
merged,
|
|
439
|
+
...(isRecord(item.pendingRestack) &&
|
|
440
|
+
str(item.pendingRestack.from) &&
|
|
441
|
+
str(item.pendingRestack.tip) &&
|
|
442
|
+
str(item.pendingRestack.onto)
|
|
443
|
+
? {
|
|
444
|
+
pendingRestack: {
|
|
445
|
+
from: str(item.pendingRestack.from) as string,
|
|
446
|
+
tip: str(item.pendingRestack.tip) as string,
|
|
447
|
+
onto: str(item.pendingRestack.onto) as string,
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
: {}),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
v: typeof raw.v === "number" ? raw.v : STACK_VERSION,
|
|
455
|
+
name,
|
|
456
|
+
trunk,
|
|
457
|
+
forge: raw.forge === "github" || raw.forge === "gitlab" ? raw.forge : null,
|
|
458
|
+
repo: str(raw.repo),
|
|
459
|
+
branches,
|
|
460
|
+
updatedAt: str(raw.updatedAt) ?? "",
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function readStack(cwd: string, name: string): StackResult<StackFile | null> {
|
|
465
|
+
const dir = stacksDir(cwd);
|
|
466
|
+
if (!dir.ok) return dir;
|
|
467
|
+
const file = path.join(dir.data, fileFor(name));
|
|
468
|
+
let text: string;
|
|
469
|
+
try {
|
|
470
|
+
text = fs.readFileSync(file, "utf8");
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, data: null };
|
|
473
|
+
return stackFail("unavailable", `cannot read ${file}: ${(error as Error).message}`);
|
|
474
|
+
}
|
|
475
|
+
let parsed: StackFile | null = null;
|
|
476
|
+
try {
|
|
477
|
+
parsed = parseStack(JSON.parse(text));
|
|
478
|
+
} catch {
|
|
479
|
+
parsed = null;
|
|
480
|
+
}
|
|
481
|
+
return parsed
|
|
482
|
+
? { ok: true, data: parsed }
|
|
483
|
+
: stackFail(
|
|
484
|
+
"failed",
|
|
485
|
+
`stack file ${file} is not a valid stack`,
|
|
486
|
+
`workit stack plan --name ${name} <branch…> # rebuild it`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Atomic write (temp file + rename) so a reader never sees half a stack. */
|
|
491
|
+
export function writeStack(
|
|
492
|
+
cwd: string,
|
|
493
|
+
stack: StackFile,
|
|
494
|
+
now: Date = new Date(),
|
|
495
|
+
): StackResult<void> {
|
|
496
|
+
const dir = stacksDir(cwd);
|
|
497
|
+
if (!dir.ok) return dir;
|
|
498
|
+
const file = path.join(dir.data, fileFor(stack.name));
|
|
499
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
500
|
+
try {
|
|
501
|
+
fs.mkdirSync(dir.data, { recursive: true });
|
|
502
|
+
stack.updatedAt = now.toISOString();
|
|
503
|
+
fs.writeFileSync(temp, `${JSON.stringify(stack, null, 2)}\n`);
|
|
504
|
+
fs.renameSync(temp, file);
|
|
505
|
+
return { ok: true, data: undefined };
|
|
506
|
+
} catch (error) {
|
|
507
|
+
fs.rmSync(temp, { force: true });
|
|
508
|
+
return stackFail("unavailable", `cannot write ${file}: ${(error as Error).message}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Every stack in the store, sorted by name (unreadable files are skipped). */
|
|
513
|
+
export function listStacks(cwd: string): StackResult<StackFile[]> {
|
|
514
|
+
const dir = stacksDir(cwd);
|
|
515
|
+
if (!dir.ok) return dir;
|
|
516
|
+
let names: string[] = [];
|
|
517
|
+
try {
|
|
518
|
+
names = fs.readdirSync(dir.data).filter((name) => name.endsWith(".json"));
|
|
519
|
+
} catch {
|
|
520
|
+
return { ok: true, data: [] };
|
|
521
|
+
}
|
|
522
|
+
const out: StackFile[] = [];
|
|
523
|
+
for (const name of names) {
|
|
524
|
+
let parsed: StackFile | null = null;
|
|
525
|
+
try {
|
|
526
|
+
parsed = parseStack(JSON.parse(fs.readFileSync(path.join(dir.data, name), "utf8")));
|
|
527
|
+
} catch {
|
|
528
|
+
parsed = null;
|
|
529
|
+
}
|
|
530
|
+
// Only files at the name's own path count (a renamed copy is ignored).
|
|
531
|
+
if (parsed && fileFor(parsed.name) === name) out.push(parsed);
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
ok: true,
|
|
535
|
+
data: out.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The stack `name`, else the one holding the current branch, else the only one. */
|
|
540
|
+
export function selectStack(cwd: string, name: string | null): StackResult<StackFile> {
|
|
541
|
+
if (name !== null && !validName(name))
|
|
542
|
+
return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
|
|
543
|
+
if (name) {
|
|
544
|
+
const read = readStack(cwd, name);
|
|
545
|
+
if (!read.ok) return read;
|
|
546
|
+
return read.data
|
|
547
|
+
? { ok: true, data: read.data }
|
|
548
|
+
: stackFail(
|
|
549
|
+
"not_found",
|
|
550
|
+
`no stack named ${name}`,
|
|
551
|
+
`workit stack plan --name ${name} <branch…>`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const all = listStacks(cwd);
|
|
555
|
+
if (!all.ok) return all;
|
|
556
|
+
const branch = currentBranch(cwd);
|
|
557
|
+
const holding = all.data.filter((stack) =>
|
|
558
|
+
stack.branches.some((entry) => entry.branch === branch),
|
|
559
|
+
);
|
|
560
|
+
if (holding.length === 1) return { ok: true, data: holding[0] };
|
|
561
|
+
if (holding.length === 0 && all.data.length === 1) return { ok: true, data: all.data[0] };
|
|
562
|
+
return stackFail(
|
|
563
|
+
"not_found",
|
|
564
|
+
holding.length > 1 || all.data.length > 1
|
|
565
|
+
? `several stacks match; pick one (${(holding.length > 1 ? holding : all.data).map((stack) => stack.name).join(", ")})`
|
|
566
|
+
: "no stack is planned here",
|
|
567
|
+
holding.length > 1 || all.data.length > 1
|
|
568
|
+
? "pass --name <stack>"
|
|
569
|
+
: "workit stack plan [<branch>…] # from the current branch chain by default",
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// single writer
|
|
575
|
+
|
|
576
|
+
const LOCK_WAIT_MS = 2_000;
|
|
577
|
+
/** The holder touches its owner file this often while it works (land waits for CI). */
|
|
578
|
+
const HEARTBEAT_MS = 20_000;
|
|
579
|
+
/** A lock whose owner has not touched it for this long is stale, whoever holds it. */
|
|
580
|
+
const HEARTBEAT_STALE_MS = 2 * 60_000;
|
|
581
|
+
/** A lock directory without a readable owner is debris after this (a holder writes it at once). */
|
|
582
|
+
const OWNERLESS_STALE_MS = 30_000;
|
|
583
|
+
|
|
584
|
+
const OWNER = "owner";
|
|
585
|
+
|
|
586
|
+
type LockOwner = {
|
|
587
|
+
pid: number;
|
|
588
|
+
processStart: string | null;
|
|
589
|
+
host: string;
|
|
590
|
+
nonce: string;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const readOwner = (lock: string): { raw: string | null; age: number | null } => {
|
|
594
|
+
let raw: string | null = null;
|
|
595
|
+
try {
|
|
596
|
+
raw = fs.readFileSync(path.join(lock, OWNER), "utf8");
|
|
597
|
+
} catch {
|
|
598
|
+
raw = null;
|
|
599
|
+
}
|
|
600
|
+
let age: number | null = null;
|
|
601
|
+
for (const target of [path.join(lock, OWNER), lock])
|
|
602
|
+
try {
|
|
603
|
+
age = Date.now() - fs.statSync(target).mtimeMs;
|
|
604
|
+
break;
|
|
605
|
+
} catch {
|
|
606
|
+
// next
|
|
607
|
+
}
|
|
608
|
+
return { raw, age };
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Is the lock's holder gone? Same machine (host + pid namespace + boot, the
|
|
613
|
+
* S1 store-lock identity): the pid is dead or reused. Any holder whose
|
|
614
|
+
* heartbeat is older than HEARTBEAT_STALE_MS. A lock with no readable owner
|
|
615
|
+
* once it is OWNERLESS_STALE_MS old.
|
|
616
|
+
*/
|
|
617
|
+
export function stackLockStale(raw: string | null, age: number | null): boolean {
|
|
618
|
+
let payload: unknown = null;
|
|
619
|
+
try {
|
|
620
|
+
payload = raw ? JSON.parse(raw) : null;
|
|
621
|
+
} catch {
|
|
622
|
+
payload = null;
|
|
623
|
+
}
|
|
624
|
+
if (payload === null) return age !== null && age > OWNERLESS_STALE_MS;
|
|
625
|
+
const owner = classifyLockOwner(payload, age);
|
|
626
|
+
// A verifiably live holder on this machine is never stale, however old its
|
|
627
|
+
// heartbeat (a long synchronous rebase cannot heartbeat).
|
|
628
|
+
if (owner.state === "live") return false;
|
|
629
|
+
if (owner.state === "stale") return true;
|
|
630
|
+
// Another host or pid namespace: only its heartbeat can tell.
|
|
631
|
+
return age !== null && age > HEARTBEAT_STALE_MS;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Touch the held stack lock's owner file (called between git/forge steps). */
|
|
635
|
+
let activeBeat: (() => void) | null = null;
|
|
636
|
+
export const touchStackLock = (): void => activeBeat?.();
|
|
637
|
+
|
|
638
|
+
function takeLock(lock: string): LockOwner | null {
|
|
639
|
+
try {
|
|
640
|
+
fs.mkdirSync(lock);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
const owner: LockOwner = {
|
|
646
|
+
pid: process.pid,
|
|
647
|
+
processStart: processStartOf(process.pid),
|
|
648
|
+
host: localLockHost(),
|
|
649
|
+
nonce: randomBytes(8).toString("hex"),
|
|
650
|
+
};
|
|
651
|
+
fs.writeFileSync(path.join(lock, OWNER), JSON.stringify(owner));
|
|
652
|
+
return owner;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Move a stale lock aside, only if it still holds what was judged stale. */
|
|
656
|
+
function reapLock(lock: string, observed: string | null): boolean {
|
|
657
|
+
const tomb = `${lock}.reap-${randomBytes(6).toString("hex")}`;
|
|
658
|
+
try {
|
|
659
|
+
fs.renameSync(lock, tomb);
|
|
660
|
+
} catch {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
if (readOwner(tomb).raw === observed) {
|
|
664
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
fs.renameSync(tomb, lock);
|
|
669
|
+
} catch {
|
|
670
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
671
|
+
}
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function releaseLock(lock: string, owner: LockOwner): void {
|
|
676
|
+
const { raw } = readOwner(lock);
|
|
677
|
+
if (raw !== JSON.stringify(owner)) return;
|
|
678
|
+
const tomb = `${lock}.release-${randomBytes(6).toString("hex")}`;
|
|
679
|
+
try {
|
|
680
|
+
fs.renameSync(lock, tomb);
|
|
681
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
682
|
+
} catch {
|
|
683
|
+
// someone reaped it
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Hold the stack's lock for `fn`. A lock held by a live process answers
|
|
689
|
+
* `busy` after LOCK_WAIT_MS (retryable). The holder heartbeats while it works,
|
|
690
|
+
* so a lock left by a dead process, a crashed host or an aborted mkdir is
|
|
691
|
+
* reclaimed (see stackLockStale) without ever stealing a working holder's.
|
|
692
|
+
*/
|
|
693
|
+
export async function withStackLock<T>(
|
|
694
|
+
cwd: string,
|
|
695
|
+
name: string,
|
|
696
|
+
fn: () => Promise<StackResult<T>>,
|
|
697
|
+
waitMs: number = LOCK_WAIT_MS,
|
|
698
|
+
): Promise<StackResult<T>> {
|
|
699
|
+
const dir = stacksDir(cwd);
|
|
700
|
+
if (!dir.ok) return dir;
|
|
701
|
+
if (!validName(name))
|
|
702
|
+
return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
|
|
703
|
+
const lock = path.join(dir.data, `${fileFor(name)}.lock`);
|
|
704
|
+
try {
|
|
705
|
+
fs.mkdirSync(dir.data, { recursive: true });
|
|
706
|
+
} catch (error) {
|
|
707
|
+
return stackFail("unavailable", `cannot create ${dir.data}: ${(error as Error).message}`);
|
|
708
|
+
}
|
|
709
|
+
const deadline = Date.now() + waitMs;
|
|
710
|
+
let owner: LockOwner | null = null;
|
|
711
|
+
for (;;) {
|
|
712
|
+
owner = takeLock(lock);
|
|
713
|
+
if (owner) break;
|
|
714
|
+
const seen = readOwner(lock);
|
|
715
|
+
if (stackLockStale(seen.raw, seen.age) && reapLock(lock, seen.raw)) continue;
|
|
716
|
+
if (Date.now() >= deadline) {
|
|
717
|
+
let holder: Partial<LockOwner> = {};
|
|
718
|
+
try {
|
|
719
|
+
holder = seen.raw ? (JSON.parse(seen.raw) as LockOwner) : {};
|
|
720
|
+
} catch {
|
|
721
|
+
holder = {};
|
|
722
|
+
}
|
|
723
|
+
return stackFail(
|
|
724
|
+
"busy",
|
|
725
|
+
`stack ${name} is busy: another workit stack command${holder.pid ? ` (pid ${holder.pid} on ${holder.host ?? "?"})` : ""} holds ${lock}`,
|
|
726
|
+
"retry when it finishes; a lock whose holder stopped heartbeating is reclaimed automatically",
|
|
727
|
+
{ lock, pid: holder.pid ?? null, host: holder.host ?? null },
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
731
|
+
}
|
|
732
|
+
const held = owner;
|
|
733
|
+
const touch = () => {
|
|
734
|
+
try {
|
|
735
|
+
const now = new Date();
|
|
736
|
+
fs.utimesSync(path.join(lock, OWNER), now, now);
|
|
737
|
+
} catch {
|
|
738
|
+
// lost the lock; the release below is a no-op then
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
const beat = setInterval(touch, HEARTBEAT_MS);
|
|
742
|
+
beat.unref?.();
|
|
743
|
+
const previous = activeBeat;
|
|
744
|
+
activeBeat = touch;
|
|
745
|
+
try {
|
|
746
|
+
return await fn();
|
|
747
|
+
} finally {
|
|
748
|
+
activeBeat = previous;
|
|
749
|
+
clearInterval(beat);
|
|
750
|
+
releaseLock(lock, held);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Hold the stack's lock for a synchronous `fn` without waiting: a live holder
|
|
756
|
+
* answers `busy` at once; a stale lock is reclaimed first.
|
|
757
|
+
*/
|
|
758
|
+
export function withStackLockSync<T>(
|
|
759
|
+
cwd: string,
|
|
760
|
+
name: string,
|
|
761
|
+
fn: () => StackResult<T>,
|
|
762
|
+
): StackResult<T> {
|
|
763
|
+
const dir = stacksDir(cwd);
|
|
764
|
+
if (!dir.ok) return dir;
|
|
765
|
+
if (!validName(name))
|
|
766
|
+
return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
|
|
767
|
+
const lock = path.join(dir.data, `${fileFor(name)}.lock`);
|
|
768
|
+
try {
|
|
769
|
+
fs.mkdirSync(dir.data, { recursive: true });
|
|
770
|
+
} catch (error) {
|
|
771
|
+
return stackFail("unavailable", `cannot create ${dir.data}: ${(error as Error).message}`);
|
|
772
|
+
}
|
|
773
|
+
let owner = takeLock(lock);
|
|
774
|
+
if (!owner) {
|
|
775
|
+
const seen = readOwner(lock);
|
|
776
|
+
if (stackLockStale(seen.raw, seen.age) && reapLock(lock, seen.raw)) owner = takeLock(lock);
|
|
777
|
+
}
|
|
778
|
+
if (!owner)
|
|
779
|
+
return stackFail(
|
|
780
|
+
"busy",
|
|
781
|
+
`stack ${name} is busy: another workit stack command holds ${lock}`,
|
|
782
|
+
"retry when it finishes",
|
|
783
|
+
{ lock },
|
|
784
|
+
);
|
|
785
|
+
try {
|
|
786
|
+
return fn();
|
|
787
|
+
} finally {
|
|
788
|
+
releaseLock(lock, owner);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// plan
|
|
794
|
+
|
|
795
|
+
export type PlanInput = {
|
|
796
|
+
name: string | null;
|
|
797
|
+
trunk: string;
|
|
798
|
+
/** Bottom (closest to trunk) first; empty: derive from the current branch. */
|
|
799
|
+
branches: string[];
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
export type PlanOutcome = {
|
|
803
|
+
stack: StackFile;
|
|
804
|
+
created: boolean;
|
|
805
|
+
/** Per branch: built on its parent's tip, and the PR base the forge reports. */
|
|
806
|
+
checks: Array<{
|
|
807
|
+
branch: string;
|
|
808
|
+
parent: string;
|
|
809
|
+
onParent: boolean;
|
|
810
|
+
pr: number | null;
|
|
811
|
+
prBase: string | null;
|
|
812
|
+
baseMatches: boolean | null;
|
|
813
|
+
}>;
|
|
814
|
+
notes: string[];
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const remoteFor = (cwd: string, resolved: ResolvedForge | null, trunk: string): string =>
|
|
818
|
+
resolved?.baseRemote ?? pushRemoteName(cwd, trunk) ?? "origin";
|
|
819
|
+
|
|
820
|
+
/** The trunk tip: the remote-tracking ref when present, else the local branch. */
|
|
821
|
+
const trunkTip = (cwd: string, remote: string, trunk: string): string | null =>
|
|
822
|
+
resolveRef(cwd, `refs/remotes/${remote}/${trunk}`) ?? branchTip(cwd, trunk);
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The current branch and the local branches it is stacked on: every local
|
|
826
|
+
* branch (not the trunk) whose tip is a proper ancestor of HEAD and not
|
|
827
|
+
* already on the trunk, ordered bottom-up.
|
|
828
|
+
*/
|
|
829
|
+
function deriveChain(cwd: string, trunk: string, base: string): StackResult<string[]> {
|
|
830
|
+
const branch = currentBranch(cwd);
|
|
831
|
+
if (!branch || branch === trunk)
|
|
832
|
+
return stackFail(
|
|
833
|
+
"invalid_input",
|
|
834
|
+
branch ? `you are on the trunk ${trunk}` : "HEAD is detached",
|
|
835
|
+
"switch to the top branch of the stack, or name the branches: workit stack plan <bottom> … <top>",
|
|
836
|
+
);
|
|
837
|
+
const refs = gitRun(cwd, [
|
|
838
|
+
"for-each-ref",
|
|
839
|
+
"--format=%(refname:short) %(objectname)",
|
|
840
|
+
"refs/heads",
|
|
841
|
+
]);
|
|
842
|
+
const head = branchTip(cwd, branch);
|
|
843
|
+
if (!refs.ok || !head) return stackFail("failed", "cannot list local branches");
|
|
844
|
+
const below: Array<{ branch: string; depth: number }> = [];
|
|
845
|
+
for (const line of refs.stdout.split("\n")) {
|
|
846
|
+
const [name, sha] = line.trim().split(" ");
|
|
847
|
+
if (!name || !sha || name === branch || name === trunk) continue;
|
|
848
|
+
if (sha === head || isAncestor(cwd, sha, base) || !isAncestor(cwd, sha, head)) continue;
|
|
849
|
+
const count = gitRun(cwd, ["rev-list", "--count", `${base}..${sha}`]);
|
|
850
|
+
below.push({ branch: name, depth: Number(count.stdout.trim()) || 0 });
|
|
851
|
+
}
|
|
852
|
+
// Branches sharing one tip are ambiguous; keep the order stable by name.
|
|
853
|
+
below.sort((a, b) => a.depth - b.depth || a.branch.localeCompare(b.branch));
|
|
854
|
+
return { ok: true, data: [...below.map((entry) => entry.branch), branch] };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export function planStack(
|
|
858
|
+
cwd: string,
|
|
859
|
+
resolved: ResolvedForge | null,
|
|
860
|
+
input: PlanInput,
|
|
861
|
+
existing: StackFile | null,
|
|
862
|
+
): StackResult<PlanOutcome> {
|
|
863
|
+
const remote = remoteFor(cwd, resolved, input.trunk);
|
|
864
|
+
const notes: string[] = [];
|
|
865
|
+
const trunkSha = trunkTip(cwd, remote, input.trunk);
|
|
866
|
+
if (!trunkSha)
|
|
867
|
+
return stackFail(
|
|
868
|
+
"not_found",
|
|
869
|
+
`trunk ${input.trunk} is not available locally`,
|
|
870
|
+
`git fetch ${remote} ${input.trunk} # or pass --trunk <branch>`,
|
|
871
|
+
);
|
|
872
|
+
let branches = input.branches;
|
|
873
|
+
if (branches.length === 0) {
|
|
874
|
+
if (existing) branches = existing.branches.map((entry) => entry.branch);
|
|
875
|
+
else {
|
|
876
|
+
const derived = deriveChain(cwd, input.trunk, trunkSha);
|
|
877
|
+
if (!derived.ok) return derived;
|
|
878
|
+
branches = derived.data;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (new Set(branches).size !== branches.length)
|
|
882
|
+
return stackFail("invalid_input", "a branch is listed twice");
|
|
883
|
+
if (branches.includes(input.trunk))
|
|
884
|
+
return stackFail("invalid_input", `the trunk ${input.trunk} cannot be in the stack`);
|
|
885
|
+
for (const branch of branches)
|
|
886
|
+
if (!safeRef(branch) || !branchTip(cwd, branch))
|
|
887
|
+
return stackFail(
|
|
888
|
+
"not_found",
|
|
889
|
+
`branch ${branch} does not exist locally`,
|
|
890
|
+
`git switch ${branch} # or git branch ${branch} ${remote}/${branch}`,
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const previous = new Map((existing?.branches ?? []).map((entry) => [entry.branch, entry]));
|
|
894
|
+
const entries: StackEntry[] = [];
|
|
895
|
+
const checks: PlanOutcome["checks"] = [];
|
|
896
|
+
let parent = input.trunk;
|
|
897
|
+
let parentSha = trunkSha;
|
|
898
|
+
for (const branch of branches) {
|
|
899
|
+
const tip = branchTip(cwd, branch) as string;
|
|
900
|
+
const before = previous.get(branch);
|
|
901
|
+
// A recorded merge is re-checked, never trusted: the PR must read merged,
|
|
902
|
+
// or (no PR) the branch must have its own commits and be on the trunk.
|
|
903
|
+
if (before?.merged) {
|
|
904
|
+
let still = false;
|
|
905
|
+
if (before.pr && resolved) {
|
|
906
|
+
const status = resolved.forge.prStatus(before.pr);
|
|
907
|
+
still = status.ok && status.data.state === "merged";
|
|
908
|
+
} else
|
|
909
|
+
still =
|
|
910
|
+
hasOwnCommits(cwd, tip, before.lastParentHead) &&
|
|
911
|
+
(isAncestor(cwd, tip, trunkSha) ||
|
|
912
|
+
squashedOnTrunk(cwd, before.lastParentHead as string, tip, trunkSha));
|
|
913
|
+
if (still) {
|
|
914
|
+
entries.push(before);
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
notes.push(`${branch} was recorded as merged but is not; it is back in the stack`);
|
|
918
|
+
}
|
|
919
|
+
const onParent = isAncestor(cwd, parentSha, tip);
|
|
920
|
+
let builtOn = parentSha;
|
|
921
|
+
if (!onParent) {
|
|
922
|
+
if (before?.lastParentHead && isAncestor(cwd, before.lastParentHead, tip))
|
|
923
|
+
// What it was last built on still is in its history: keep it, so sync
|
|
924
|
+
// replays only this branch's own commits.
|
|
925
|
+
builtOn = before.lastParentHead;
|
|
926
|
+
else {
|
|
927
|
+
const old =
|
|
928
|
+
oldParentVersion(cwd, parent, parentSha, tip) ?? oldParentLookalike(cwd, parentSha, tip);
|
|
929
|
+
if (old)
|
|
930
|
+
return stackFail(
|
|
931
|
+
"blocked",
|
|
932
|
+
`parent_rewritten: ${branch} is built on an older version of ${parent} (${old.slice(0, 12)}); replaying it from the merge base would bring the old ${parent} commits back`,
|
|
933
|
+
`git rebase --onto ${parent} ${old} ${branch} # moves only ${branch}'s own commits; then workit stack plan`,
|
|
934
|
+
{ branch, parent, oldParentTip: old },
|
|
935
|
+
);
|
|
936
|
+
builtOn = mergeBase(cwd, parentSha, tip) ?? parentSha;
|
|
937
|
+
notes.push(
|
|
938
|
+
`${branch} is not on ${parent} and has no recorded base; sync will replay every commit since their merge base ${builtOn.slice(0, 12)}`,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
let pr = before?.pr ?? null;
|
|
943
|
+
let prBase: string | null = null;
|
|
944
|
+
if (resolved) {
|
|
945
|
+
const found = resolved.forge.findPr(branch, {
|
|
946
|
+
owner: resolved.headRepo.split("/")[0] ?? null,
|
|
947
|
+
projectId: resolved.headProjectId,
|
|
948
|
+
sha: tip,
|
|
949
|
+
});
|
|
950
|
+
if (found.ok && found.data && found.data.state === "open") {
|
|
951
|
+
pr = found.data.number;
|
|
952
|
+
const status = resolved.forge.prStatus(pr);
|
|
953
|
+
if (status.ok) prBase = status.data.base;
|
|
954
|
+
} else if (!found.ok) notes.push(`${branch}: PR lookup failed (${found.error})`);
|
|
955
|
+
}
|
|
956
|
+
entries.push({
|
|
957
|
+
...(before?.pendingRestack ? { pendingRestack: before.pendingRestack } : {}),
|
|
958
|
+
branch,
|
|
959
|
+
parent,
|
|
960
|
+
pr,
|
|
961
|
+
lastHead: tip,
|
|
962
|
+
lastParentHead: builtOn,
|
|
963
|
+
patchId: patchId(cwd, builtOn, tip),
|
|
964
|
+
merged: null,
|
|
965
|
+
});
|
|
966
|
+
checks.push({
|
|
967
|
+
branch,
|
|
968
|
+
parent,
|
|
969
|
+
onParent,
|
|
970
|
+
pr,
|
|
971
|
+
prBase,
|
|
972
|
+
baseMatches: prBase === null ? null : prBase === parent,
|
|
973
|
+
});
|
|
974
|
+
if (!onParent) notes.push(`${branch} is not on ${parent}; workit stack sync rebases it`);
|
|
975
|
+
if (prBase !== null && prBase !== parent)
|
|
976
|
+
notes.push(
|
|
977
|
+
`${branch}: PR base is ${prBase}, the stack says ${parent}; workit stack sync retargets it`,
|
|
978
|
+
);
|
|
979
|
+
parent = branch;
|
|
980
|
+
parentSha = tip;
|
|
981
|
+
}
|
|
982
|
+
if (!resolved) notes.push("forge not resolved: PR numbers were not looked up");
|
|
983
|
+
const name = input.name ?? existing?.name ?? branches[0];
|
|
984
|
+
return {
|
|
985
|
+
ok: true,
|
|
986
|
+
data: {
|
|
987
|
+
stack: {
|
|
988
|
+
v: STACK_VERSION,
|
|
989
|
+
name,
|
|
990
|
+
trunk: input.trunk,
|
|
991
|
+
forge: resolved?.forge.kind ?? existing?.forge ?? null,
|
|
992
|
+
repo: resolved?.forge.repo ?? existing?.repo ?? null,
|
|
993
|
+
branches: entries,
|
|
994
|
+
updatedAt: "",
|
|
995
|
+
},
|
|
996
|
+
created: existing === null,
|
|
997
|
+
checks,
|
|
998
|
+
notes,
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Record (or refresh) stack `name` as `branches`, bottom first, without the
|
|
1005
|
+
* forge: `fanout worktree create` calls it when a slice starts on its parent
|
|
1006
|
+
* slice's branch, so `stack sync` can later replay only the child's commits.
|
|
1007
|
+
*/
|
|
1008
|
+
export function recordStack(
|
|
1009
|
+
cwd: string,
|
|
1010
|
+
input: { name: string; trunk: string; branches: string[] },
|
|
1011
|
+
): StackResult<{ stack: StackFile; created: boolean }> {
|
|
1012
|
+
return withStackLockSync(cwd, input.name, () => {
|
|
1013
|
+
const existing = readStack(cwd, input.name);
|
|
1014
|
+
if (!existing.ok) return existing;
|
|
1015
|
+
const planned = planStack(
|
|
1016
|
+
cwd,
|
|
1017
|
+
null,
|
|
1018
|
+
{ name: input.name, trunk: input.trunk, branches: input.branches },
|
|
1019
|
+
existing.data,
|
|
1020
|
+
);
|
|
1021
|
+
if (!planned.ok) return planned;
|
|
1022
|
+
const written = writeStack(cwd, planned.data.stack);
|
|
1023
|
+
if (!written.ok) return written;
|
|
1024
|
+
return { ok: true, data: { stack: planned.data.stack, created: planned.data.created } };
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** `tip` has commits of its own beyond `base` (an empty branch is not "merged"). */
|
|
1029
|
+
function hasOwnCommits(cwd: string, tip: string, base: string | null): boolean {
|
|
1030
|
+
if (!base) return false;
|
|
1031
|
+
return tip !== base && !isAncestor(cwd, tip, base);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* An earlier tip of `parent` (from its reflog) that `child` is built on but
|
|
1036
|
+
* the current parent no longer contains: the parent was rewritten (amend,
|
|
1037
|
+
* rebase) under the child.
|
|
1038
|
+
*/
|
|
1039
|
+
function oldParentVersion(
|
|
1040
|
+
cwd: string,
|
|
1041
|
+
parent: string,
|
|
1042
|
+
parentSha: string,
|
|
1043
|
+
child: string,
|
|
1044
|
+
): string | null {
|
|
1045
|
+
if (!safeRef(parent)) return null;
|
|
1046
|
+
const log = gitRun(cwd, ["reflog", "show", "--format=%H", `refs/heads/${parent}`, "--"]);
|
|
1047
|
+
if (!log.ok) return null;
|
|
1048
|
+
for (const sha of log.stdout.split("\n").map((line) => line.trim())) {
|
|
1049
|
+
if (!sha || sha === parentSha) continue;
|
|
1050
|
+
if (isAncestor(cwd, sha, child) && !isAncestor(cwd, sha, parentSha)) return sha;
|
|
1051
|
+
}
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
type CommitKey = { sha: string; subject: string; patch: string | null };
|
|
1056
|
+
|
|
1057
|
+
/** Non-merge commits of `range`, oldest first, with their stable patch-ids. */
|
|
1058
|
+
function commitKeys(cwd: string, range: string): CommitKey[] {
|
|
1059
|
+
const log = gitRun(cwd, ["log", "--reverse", "--no-merges", "--format=%H%x00%s", range]);
|
|
1060
|
+
if (!log.ok) return [];
|
|
1061
|
+
const patches = new Map<string, string>();
|
|
1062
|
+
const diff = gitRun(cwd, ["log", "--no-merges", "-p", "--format=commit %H", range]);
|
|
1063
|
+
if (diff.ok && diff.stdout.trim()) {
|
|
1064
|
+
const ids = spawnSync("git", ["patch-id", "--stable"], {
|
|
1065
|
+
cwd,
|
|
1066
|
+
input: diff.stdout,
|
|
1067
|
+
encoding: "utf8",
|
|
1068
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1069
|
+
});
|
|
1070
|
+
for (const line of (ids.stdout ?? "").split("\n")) {
|
|
1071
|
+
const [patch, sha] = line.trim().split(/\s+/u);
|
|
1072
|
+
if (patch && sha) patches.set(sha, patch);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return log.stdout
|
|
1076
|
+
.split("\n")
|
|
1077
|
+
.filter(Boolean)
|
|
1078
|
+
.map((line) => {
|
|
1079
|
+
const [sha, subject = ""] = line.split("\0");
|
|
1080
|
+
return { sha, subject, patch: patches.get(sha) ?? null };
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Without a reflog (expired, fresh clone): commits of `child` since its merge
|
|
1086
|
+
* base with the parent that look like the parent's own (same patch-id or same
|
|
1087
|
+
* subject as a commit the parent now has instead). The newest such commit is
|
|
1088
|
+
* where the old parent version ended.
|
|
1089
|
+
*/
|
|
1090
|
+
function oldParentLookalike(cwd: string, parentSha: string, child: string): string | null {
|
|
1091
|
+
const base = mergeBase(cwd, parentSha, child);
|
|
1092
|
+
if (!base) return null;
|
|
1093
|
+
const parentCommits = commitKeys(cwd, `${base}..${parentSha}`);
|
|
1094
|
+
if (parentCommits.length === 0) return null;
|
|
1095
|
+
const patches = new Set(parentCommits.map((commit) => commit.patch).filter(Boolean));
|
|
1096
|
+
const subjects = new Set(parentCommits.map((commit) => commit.subject).filter(Boolean));
|
|
1097
|
+
let last: string | null = null;
|
|
1098
|
+
for (const commit of commitKeys(cwd, `${base}..${child}`))
|
|
1099
|
+
if ((commit.patch && patches.has(commit.patch)) || subjects.has(commit.subject))
|
|
1100
|
+
last = commit.sha;
|
|
1101
|
+
return last;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
// status
|
|
1106
|
+
|
|
1107
|
+
export type StackVerdict = "READY" | "WAITING" | "ADVANCE" | "COMPLETE";
|
|
1108
|
+
|
|
1109
|
+
export type StopReason =
|
|
1110
|
+
| "no_pr"
|
|
1111
|
+
| "pr_mismatch"
|
|
1112
|
+
| "pr_closed"
|
|
1113
|
+
| "base_not_trunk"
|
|
1114
|
+
| "not_ready"
|
|
1115
|
+
| "no_verdict"
|
|
1116
|
+
| "needs_verdict"
|
|
1117
|
+
| "failed_verdict"
|
|
1118
|
+
| "grant_required"
|
|
1119
|
+
| "max_reached"
|
|
1120
|
+
| "merge_refused"
|
|
1121
|
+
| "waiver_refused"
|
|
1122
|
+
| "head_unverified";
|
|
1123
|
+
|
|
1124
|
+
export type BranchStatus = {
|
|
1125
|
+
branch: string;
|
|
1126
|
+
parent: string;
|
|
1127
|
+
pr: number | null;
|
|
1128
|
+
merged: boolean;
|
|
1129
|
+
head: string | null;
|
|
1130
|
+
/** The parent's (or trunk's) tip is in this branch's history. */
|
|
1131
|
+
onParent: boolean | null;
|
|
1132
|
+
prState: PrState | null;
|
|
1133
|
+
prBase: string | null;
|
|
1134
|
+
baseMatches: boolean | null;
|
|
1135
|
+
next: NextAction | null;
|
|
1136
|
+
checks: ChecksState | null;
|
|
1137
|
+
blockers: string[];
|
|
1138
|
+
/**
|
|
1139
|
+
* `accepted`: an accepted independent verdict; `satisfied`: what the land
|
|
1140
|
+
* gate reads, the verdict the PR's review lane asks for (an accepted
|
|
1141
|
+
* independent one outside `verification: proportional`).
|
|
1142
|
+
*/
|
|
1143
|
+
verdict: {
|
|
1144
|
+
accepted: boolean;
|
|
1145
|
+
satisfied: boolean;
|
|
1146
|
+
basis: VerdictBasis;
|
|
1147
|
+
reasons: string[];
|
|
1148
|
+
verdictId: string | null;
|
|
1149
|
+
lane: ReviewLane | null;
|
|
1150
|
+
need: LaneNeed;
|
|
1151
|
+
/** The verdict higher in the stack that covers this branch, when it has none of its own. */
|
|
1152
|
+
cover: StackCover | null;
|
|
1153
|
+
};
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
export type Qualification =
|
|
1157
|
+
| { ok: true }
|
|
1158
|
+
| {
|
|
1159
|
+
ok: false;
|
|
1160
|
+
reason: StopReason;
|
|
1161
|
+
detail: string;
|
|
1162
|
+
unblock: string;
|
|
1163
|
+
ready: boolean;
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
export type StatusOutcome = {
|
|
1167
|
+
name: string;
|
|
1168
|
+
trunk: string;
|
|
1169
|
+
verdict: StackVerdict;
|
|
1170
|
+
reason: string | null;
|
|
1171
|
+
next: string | null;
|
|
1172
|
+
branches: BranchStatus[];
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
type Ctx = {
|
|
1176
|
+
cwd: string;
|
|
1177
|
+
resolved: ResolvedForge;
|
|
1178
|
+
actor: LedgerActor;
|
|
1179
|
+
sleep: Sleep;
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
const noun = (resolved: ResolvedForge, pr: number): string =>
|
|
1183
|
+
`${resolved.forge.kind === "github" ? "PR #" : "MR !"}${pr}`;
|
|
1184
|
+
|
|
1185
|
+
function statusDoc(ctx: Ctx, pr: number): ForgeResult<PrStatusDoc> {
|
|
1186
|
+
const status = ctx.resolved.forge.prStatus(pr);
|
|
1187
|
+
if (!status.ok) return status;
|
|
1188
|
+
return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, {
|
|
1189
|
+
logLines: 0,
|
|
1190
|
+
behind: false,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* The recorded PR number must still be this branch's PR from our head
|
|
1196
|
+
* repository: the forge reports this branch as its head, and looking the
|
|
1197
|
+
* branch up (owner/project matched, as `pr create` does) finds this number.
|
|
1198
|
+
*/
|
|
1199
|
+
function prBinding(ctx: Pick<Ctx, "resolved">, entry: StackEntry, doc: PrStatusDoc): string | null {
|
|
1200
|
+
const label = noun(ctx.resolved, entry.pr as number);
|
|
1201
|
+
if (doc.head.branch !== entry.branch)
|
|
1202
|
+
return `${label} is for ${doc.head.branch}, not ${entry.branch}`;
|
|
1203
|
+
const found = ctx.resolved.forge.findPr(entry.branch, {
|
|
1204
|
+
owner: ctx.resolved.headRepo.split("/")[0] ?? null,
|
|
1205
|
+
projectId: ctx.resolved.headProjectId,
|
|
1206
|
+
sha: null,
|
|
1207
|
+
});
|
|
1208
|
+
if (!found.ok) return `cannot confirm ${label} belongs to ${entry.branch}: ${found.error}`;
|
|
1209
|
+
if (found.data?.number !== entry.pr)
|
|
1210
|
+
return `${entry.branch} from ${ctx.resolved.headRepo} is ${found.data ? noun(ctx.resolved, found.data.number) : "without a PR"}, not ${label}`;
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** The stack was planned against this forge repository. */
|
|
1215
|
+
export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): StackError | null {
|
|
1216
|
+
const { kind, repo } = ctx.resolved.forge;
|
|
1217
|
+
if ((stack.repo && stack.repo !== repo) || (stack.forge && stack.forge !== kind))
|
|
1218
|
+
return stackFail(
|
|
1219
|
+
"blocked",
|
|
1220
|
+
`repo_mismatch: stack ${stack.name} was planned on ${stack.forge ?? "?"} ${stack.repo ?? "?"}, but this checkout resolves to ${kind} ${repo}`,
|
|
1221
|
+
`workit stack plan --name ${stack.name} <branch…> # re-plan it here, or run from the right checkout`,
|
|
1222
|
+
{
|
|
1223
|
+
planned: { forge: stack.forge, repo: stack.repo },
|
|
1224
|
+
resolved: { forge: kind, repo },
|
|
1225
|
+
},
|
|
1226
|
+
);
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const verdictOf = (
|
|
1231
|
+
cwd: string,
|
|
1232
|
+
branch: string,
|
|
1233
|
+
rows: readonly ReadRow[],
|
|
1234
|
+
doc: PrStatusDoc | null,
|
|
1235
|
+
): BranchStatus["verdict"] => {
|
|
1236
|
+
// A lower branch may be covered by the verdict at the top of the stack (#334).
|
|
1237
|
+
const check = checkVerdictsCovered(cwd, branch, rows);
|
|
1238
|
+
// Without a PR there is no base to diff against: the gate stays independent.
|
|
1239
|
+
const gate = doc ? prLaneGate(cwd, check, { branch, base: doc.base, head: doc.head.sha }) : null;
|
|
1240
|
+
return {
|
|
1241
|
+
accepted: check.accepted.accepted,
|
|
1242
|
+
satisfied: gate ? gate.satisfied : check.accepted.accepted,
|
|
1243
|
+
basis: check.current.basis,
|
|
1244
|
+
reasons: check.accepted.reasons,
|
|
1245
|
+
verdictId: gate
|
|
1246
|
+
? gate.verdictId
|
|
1247
|
+
: typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
|
|
1248
|
+
? check.accepted.verdict.id
|
|
1249
|
+
: null,
|
|
1250
|
+
lane: gate?.lane.lane ?? null,
|
|
1251
|
+
need: gate?.need ?? "non-author",
|
|
1252
|
+
cover: check.cover,
|
|
1253
|
+
};
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Does the root-most open PR qualify to land? Order: a PR exists and is
|
|
1258
|
+
* open, it targets the trunk, the forge says READY, an accepted verdict
|
|
1259
|
+
* covers the head (unless `bypass`: `--unverified` under `merge: true`), and
|
|
1260
|
+
* the grant allows merging. `ready` says everything but the grant holds
|
|
1261
|
+
* ("verified, ready").
|
|
1262
|
+
*/
|
|
1263
|
+
export function qualify(
|
|
1264
|
+
ctx: Pick<Ctx, "cwd" | "resolved">,
|
|
1265
|
+
stack: StackFile,
|
|
1266
|
+
entry: StackEntry,
|
|
1267
|
+
doc: PrStatusDoc | null,
|
|
1268
|
+
verdict: BranchStatus["verdict"],
|
|
1269
|
+
grant: GrantDecision,
|
|
1270
|
+
/** Why the PR number no longer belongs to this branch (null: it does). */
|
|
1271
|
+
binding: string | null = null,
|
|
1272
|
+
/** `stack land --unverified`: skip the verdict where the grant allows it. */
|
|
1273
|
+
bypass = false,
|
|
1274
|
+
): Qualification {
|
|
1275
|
+
const label = entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch;
|
|
1276
|
+
const stop = (reason: StopReason, detail: string, unblock: string, ready = false) => ({
|
|
1277
|
+
ok: false as const,
|
|
1278
|
+
reason,
|
|
1279
|
+
detail,
|
|
1280
|
+
unblock,
|
|
1281
|
+
ready,
|
|
1282
|
+
});
|
|
1283
|
+
if (!entry.pr || !doc)
|
|
1284
|
+
return stop(
|
|
1285
|
+
"no_pr",
|
|
1286
|
+
`${entry.branch} has no open PR`,
|
|
1287
|
+
`git switch ${entry.branch} && workit pr create --fill --base ${entry.parent} # then workit stack plan`,
|
|
1288
|
+
);
|
|
1289
|
+
if (binding)
|
|
1290
|
+
return stop(
|
|
1291
|
+
"pr_mismatch",
|
|
1292
|
+
binding,
|
|
1293
|
+
`workit stack plan --name ${stack.name} # re-resolve the PRs`,
|
|
1294
|
+
);
|
|
1295
|
+
if (doc.state !== "open")
|
|
1296
|
+
return stop(
|
|
1297
|
+
"pr_closed",
|
|
1298
|
+
`${label} is ${doc.state}`,
|
|
1299
|
+
"reopen it, or drop the branch from the stack: workit stack plan <branches…>",
|
|
1300
|
+
);
|
|
1301
|
+
if (doc.base !== stack.trunk)
|
|
1302
|
+
return stop(
|
|
1303
|
+
"base_not_trunk",
|
|
1304
|
+
`${label} targets ${doc.base}, not ${stack.trunk}`,
|
|
1305
|
+
"workit stack sync",
|
|
1306
|
+
);
|
|
1307
|
+
if (doc.next !== "READY")
|
|
1308
|
+
return stop(
|
|
1309
|
+
"not_ready",
|
|
1310
|
+
`${label} is ${doc.next}${doc.blockers.length ? ` (${doc.blockers.join(", ")})` : ""}`,
|
|
1311
|
+
doc.next === "WAITING_CI"
|
|
1312
|
+
? `workit ci wait --pr ${entry.pr}`
|
|
1313
|
+
: doc.blockers.includes("mergeability_unknown")
|
|
1314
|
+
? `the forge is still computing mergeability; wait a minute, then workit stack land again (workit pr status --pr ${entry.pr} shows when it settles)`
|
|
1315
|
+
: `workit pr status --pr ${entry.pr}`,
|
|
1316
|
+
);
|
|
1317
|
+
const allowUnverified = grant.allowed && grant.allowUnverified;
|
|
1318
|
+
// The bypass covers a missing verdict, never a rejection.
|
|
1319
|
+
if (bypass && allowUnverified && verdict.reasons.includes("failing_verdict"))
|
|
1320
|
+
return stop(
|
|
1321
|
+
"failed_verdict",
|
|
1322
|
+
`${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
|
|
1323
|
+
"a new independent verdict on the head supersedes it",
|
|
1324
|
+
);
|
|
1325
|
+
if (!(bypass && allowUnverified) && !verdict.satisfied) {
|
|
1326
|
+
const bypassHint = allowUnverified
|
|
1327
|
+
? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"`
|
|
1328
|
+
: "";
|
|
1329
|
+
return stop(
|
|
1330
|
+
verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
|
|
1331
|
+
verdict.need === "self"
|
|
1332
|
+
? `${label} has no verdict for ${doc.head.sha.slice(0, 12)} (lane self: the author's own --self verdict is enough; ${verdict.reasons.join(", ") || "none"})`
|
|
1333
|
+
: `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)}${verdict.lane && verdict.lane !== "none" ? ` (lane ${verdict.lane})` : ""} (${verdict.reasons.join(", ") || "none"})`,
|
|
1334
|
+
verdict.need === "self"
|
|
1335
|
+
? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${entry.branch}${bypassHint}`
|
|
1336
|
+
: `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${bypassHint}`,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
if (!grant.allowed)
|
|
1340
|
+
return stop(
|
|
1341
|
+
"grant_required",
|
|
1342
|
+
`${label} is verified and ready, but ${grant.error}`,
|
|
1343
|
+
grant.unblock,
|
|
1344
|
+
true,
|
|
1345
|
+
);
|
|
1346
|
+
return { ok: true };
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutcome> {
|
|
1350
|
+
const mismatch = repoMismatch(ctx, stack);
|
|
1351
|
+
if (mismatch) return mismatch;
|
|
1352
|
+
const ledger = readLedger(ctx.cwd, { integrity: true });
|
|
1353
|
+
if (!ledger.ok) return stackFail(ledger.code, ledger.error, ledger.unblock);
|
|
1354
|
+
const remote = remoteFor(ctx.cwd, ctx.resolved, stack.trunk);
|
|
1355
|
+
const trunkSha = trunkTip(ctx.cwd, remote, stack.trunk);
|
|
1356
|
+
const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
|
|
1357
|
+
const branches: BranchStatus[] = [];
|
|
1358
|
+
let verdict: StackVerdict | null = null;
|
|
1359
|
+
let reason: string | null = null;
|
|
1360
|
+
let next: string | null = null;
|
|
1361
|
+
let parentName = stack.trunk;
|
|
1362
|
+
let parentSha = trunkSha;
|
|
1363
|
+
let mergedSeen = false;
|
|
1364
|
+
for (const entry of stack.branches) {
|
|
1365
|
+
let doc: PrStatusDoc | null = null;
|
|
1366
|
+
if (entry.pr) {
|
|
1367
|
+
const read = statusDoc(ctx, entry.pr);
|
|
1368
|
+
if (!read.ok) return fromForge(read);
|
|
1369
|
+
doc = read.data;
|
|
1370
|
+
}
|
|
1371
|
+
const merged = entry.merged !== null || doc?.state === "merged";
|
|
1372
|
+
const head = branchTip(ctx.cwd, entry.branch);
|
|
1373
|
+
const row: BranchStatus = {
|
|
1374
|
+
branch: entry.branch,
|
|
1375
|
+
parent: parentName,
|
|
1376
|
+
pr: entry.pr,
|
|
1377
|
+
merged,
|
|
1378
|
+
head,
|
|
1379
|
+
onParent: merged || !head || !parentSha ? null : isAncestor(ctx.cwd, parentSha, head),
|
|
1380
|
+
prState: doc?.state ?? null,
|
|
1381
|
+
prBase: doc?.base ?? null,
|
|
1382
|
+
baseMatches: doc && !merged ? doc.base === parentName : null,
|
|
1383
|
+
next: doc?.next ?? null,
|
|
1384
|
+
checks: doc?.checks.state ?? null,
|
|
1385
|
+
blockers: doc?.blockers ?? [],
|
|
1386
|
+
verdict: merged
|
|
1387
|
+
? {
|
|
1388
|
+
accepted: true,
|
|
1389
|
+
satisfied: true,
|
|
1390
|
+
basis: "none",
|
|
1391
|
+
reasons: [],
|
|
1392
|
+
verdictId: null,
|
|
1393
|
+
lane: null,
|
|
1394
|
+
need: "non-author",
|
|
1395
|
+
cover: null,
|
|
1396
|
+
}
|
|
1397
|
+
: verdictOf(ctx.cwd, entry.branch, ledger.value.rows, doc),
|
|
1398
|
+
};
|
|
1399
|
+
branches.push(row);
|
|
1400
|
+
if (merged) {
|
|
1401
|
+
mergedSeen = true;
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
if (verdict === null) {
|
|
1405
|
+
// The root-most open branch decides the stack's verdict.
|
|
1406
|
+
const stale = row.onParent === false || row.baseMatches === false;
|
|
1407
|
+
if (mergedSeen && stale) {
|
|
1408
|
+
verdict = "ADVANCE";
|
|
1409
|
+
reason = "a parent merged; the rest is not restacked or retargeted yet";
|
|
1410
|
+
next = "workit stack sync";
|
|
1411
|
+
} else {
|
|
1412
|
+
const q = qualify(
|
|
1413
|
+
ctx,
|
|
1414
|
+
stack,
|
|
1415
|
+
entry,
|
|
1416
|
+
doc,
|
|
1417
|
+
row.verdict,
|
|
1418
|
+
grant,
|
|
1419
|
+
doc ? prBinding(ctx, entry, doc) : null,
|
|
1420
|
+
);
|
|
1421
|
+
if (q.ok) {
|
|
1422
|
+
verdict = "READY";
|
|
1423
|
+
next = "workit stack land";
|
|
1424
|
+
} else {
|
|
1425
|
+
verdict = "WAITING";
|
|
1426
|
+
reason = `${q.reason}: ${q.detail}`;
|
|
1427
|
+
next = q.unblock;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
parentName = entry.branch;
|
|
1432
|
+
parentSha = head;
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
ok: true,
|
|
1436
|
+
data: {
|
|
1437
|
+
name: stack.name,
|
|
1438
|
+
trunk: stack.trunk,
|
|
1439
|
+
verdict: verdict ?? "COMPLETE",
|
|
1440
|
+
reason,
|
|
1441
|
+
next,
|
|
1442
|
+
branches,
|
|
1443
|
+
},
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// ---------------------------------------------------------------------------
|
|
1448
|
+
// sync
|
|
1449
|
+
|
|
1450
|
+
export type SyncOptions = {
|
|
1451
|
+
/** Push and retarget (false: only rebase locally). */
|
|
1452
|
+
publish: boolean;
|
|
1453
|
+
dryRun: boolean;
|
|
1454
|
+
/** Process only the first N unmerged branches (land moves just the new root). */
|
|
1455
|
+
limit?: number;
|
|
1456
|
+
/** Branches whose changed restack may be pushed anyway; never used by land. */
|
|
1457
|
+
force?: ReadonlySet<string>;
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
export type SyncStep = {
|
|
1461
|
+
branch: string;
|
|
1462
|
+
pr: number | null;
|
|
1463
|
+
parent: string;
|
|
1464
|
+
/** null: already on its parent. */
|
|
1465
|
+
restack: {
|
|
1466
|
+
from: string;
|
|
1467
|
+
onto: string;
|
|
1468
|
+
head: string | null;
|
|
1469
|
+
carried: boolean | null;
|
|
1470
|
+
} | null;
|
|
1471
|
+
push: { pushed: boolean; forced: boolean; sha: string } | null;
|
|
1472
|
+
retarget: { from: string; to: string } | null;
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
export type SyncOutcome = {
|
|
1476
|
+
name: string;
|
|
1477
|
+
dryRun: boolean;
|
|
1478
|
+
merged: Array<{ branch: string; pr: number | null }>;
|
|
1479
|
+
steps: SyncStep[];
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
type SyncState = {
|
|
1483
|
+
/** PR status docs read during this run, by PR number. */
|
|
1484
|
+
docs: Map<number, PrStatusDoc>;
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
/** Mark branches whose PR merged (or whose tip is on the trunk) as merged. */
|
|
1488
|
+
function detectMerged(
|
|
1489
|
+
ctx: Ctx | null,
|
|
1490
|
+
cwd: string,
|
|
1491
|
+
stack: StackFile,
|
|
1492
|
+
trunkSha: string,
|
|
1493
|
+
state: SyncState,
|
|
1494
|
+
): StackResult<Array<{ branch: string; pr: number | null }>> {
|
|
1495
|
+
const newly: Array<{ branch: string; pr: number | null }> = [];
|
|
1496
|
+
for (const entry of stack.branches) {
|
|
1497
|
+
if (entry.merged) continue;
|
|
1498
|
+
let merged = false;
|
|
1499
|
+
if (entry.pr && ctx) {
|
|
1500
|
+
const read = statusDoc(ctx, entry.pr);
|
|
1501
|
+
if (!read.ok) return fromForge(read);
|
|
1502
|
+
if (read.data.head.branch !== entry.branch)
|
|
1503
|
+
return stackFail(
|
|
1504
|
+
"blocked",
|
|
1505
|
+
`pr_mismatch: ${noun(ctx.resolved, entry.pr)} is for ${read.data.head.branch}, not ${entry.branch}`,
|
|
1506
|
+
`workit stack plan --name ${stack.name} # re-resolve the PRs`,
|
|
1507
|
+
{ branch: entry.branch, pr: entry.pr },
|
|
1508
|
+
);
|
|
1509
|
+
state.docs.set(entry.pr, read.data);
|
|
1510
|
+
merged = read.data.state === "merged";
|
|
1511
|
+
} else {
|
|
1512
|
+
// Without a PR only git can tell: the tip is on the trunk, or its own
|
|
1513
|
+
// change is one trunk commit (a squash merge). A branch with no commits
|
|
1514
|
+
// of its own is empty, not merged.
|
|
1515
|
+
const tip = branchTip(cwd, entry.branch);
|
|
1516
|
+
merged =
|
|
1517
|
+
tip !== null &&
|
|
1518
|
+
hasOwnCommits(cwd, tip, entry.lastParentHead) &&
|
|
1519
|
+
(isAncestor(cwd, tip, trunkSha) ||
|
|
1520
|
+
squashedOnTrunk(cwd, entry.lastParentHead as string, tip, trunkSha));
|
|
1521
|
+
}
|
|
1522
|
+
if (merged) {
|
|
1523
|
+
entry.merged = {
|
|
1524
|
+
pr: entry.pr,
|
|
1525
|
+
mergeSha: null,
|
|
1526
|
+
at: new Date().toISOString(),
|
|
1527
|
+
};
|
|
1528
|
+
newly.push({ branch: entry.branch, pr: entry.pr });
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return { ok: true, data: newly };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** Trunk commits scanned for a squash merge at most. */
|
|
1535
|
+
const SQUASH_SCAN = 500;
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Whether `base..tip` landed on the trunk as one squash commit: its patch-id
|
|
1539
|
+
* equals one trunk commit's since they forked (the trunk not touching the
|
|
1540
|
+
* same lines in between). Squash merges never make the tip an ancestor.
|
|
1541
|
+
*/
|
|
1542
|
+
function squashedOnTrunk(cwd: string, base: string, tip: string, trunkSha: string): boolean {
|
|
1543
|
+
const own = patchId(cwd, base, tip);
|
|
1544
|
+
const fork = mergeBase(cwd, tip, trunkSha);
|
|
1545
|
+
if (!own || !fork || fork === trunkSha) return false;
|
|
1546
|
+
const log = gitRun(cwd, [
|
|
1547
|
+
"log",
|
|
1548
|
+
"--no-merges",
|
|
1549
|
+
`--max-count=${SQUASH_SCAN}`,
|
|
1550
|
+
"-p",
|
|
1551
|
+
"--format=commit %H",
|
|
1552
|
+
"-U3",
|
|
1553
|
+
"--diff-algorithm=myers",
|
|
1554
|
+
"--indent-heuristic",
|
|
1555
|
+
"--no-color",
|
|
1556
|
+
"--no-ext-diff",
|
|
1557
|
+
"--no-textconv",
|
|
1558
|
+
"--no-renames",
|
|
1559
|
+
"--full-index",
|
|
1560
|
+
"--src-prefix=a/",
|
|
1561
|
+
"--dst-prefix=b/",
|
|
1562
|
+
`${fork}..${trunkSha}`,
|
|
1563
|
+
"--",
|
|
1564
|
+
]);
|
|
1565
|
+
if (!log.ok || !log.stdout.trim()) return false;
|
|
1566
|
+
const ids = spawnSync("git", ["patch-id", "--stable"], {
|
|
1567
|
+
cwd,
|
|
1568
|
+
input: log.stdout,
|
|
1569
|
+
encoding: "utf8",
|
|
1570
|
+
timeout: GIT_TIMEOUTS.local,
|
|
1571
|
+
windowsHide: true,
|
|
1572
|
+
});
|
|
1573
|
+
if (ids.status !== 0) return false;
|
|
1574
|
+
return ids.stdout.split("\n").some((line) => line.split(" ")[0] === own);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Push `branch` through the S11 push: the grant, the protected-branch check
|
|
1579
|
+
* and the lease. A rewrite is forced only with a lease on the tip the stack
|
|
1580
|
+
* last saw (or workit's own recorded push), and only when that tip is in the
|
|
1581
|
+
* branch's history or reflog (S11 `--force-if-includes` semantics).
|
|
1582
|
+
*/
|
|
1583
|
+
function pushBranch(
|
|
1584
|
+
cwd: string,
|
|
1585
|
+
actor: LedgerActor,
|
|
1586
|
+
branch: string,
|
|
1587
|
+
priorTip: string | null,
|
|
1588
|
+
): StackResult<{ pushed: boolean; forced: boolean; sha: string }> {
|
|
1589
|
+
const plan = stackDeps.pushPlan(cwd, branch);
|
|
1590
|
+
if (!plan.ok) return fromForge(plan);
|
|
1591
|
+
const remote = remoteRefTip(cwd, plan.data.rawUrl, `refs/heads/${branch}`);
|
|
1592
|
+
if (!remote.ok) return stackFail(remote.code, remote.error);
|
|
1593
|
+
if (remote.sha === plan.data.sha)
|
|
1594
|
+
return {
|
|
1595
|
+
ok: true,
|
|
1596
|
+
data: { pushed: false, forced: false, sha: plan.data.sha },
|
|
1597
|
+
};
|
|
1598
|
+
const grant = requireGrant(cwd, "push", {
|
|
1599
|
+
forge: !plan.data.local,
|
|
1600
|
+
target: () => pushTarget(cwd, plan.data.remote),
|
|
1601
|
+
});
|
|
1602
|
+
if (!grant.allowed)
|
|
1603
|
+
return stackFail("blocked", grant.error, grant.unblock, {
|
|
1604
|
+
reason: grant.reason,
|
|
1605
|
+
});
|
|
1606
|
+
const force = remote.sha !== null && !isAncestor(cwd, remote.sha, plan.data.sha);
|
|
1607
|
+
const expect = !force
|
|
1608
|
+
? null
|
|
1609
|
+
: remote.sha === priorTip
|
|
1610
|
+
? priorTip
|
|
1611
|
+
: (recordedRemoteTip(cwd, plan.data) ?? null);
|
|
1612
|
+
const pushed = executePush(cwd, plan.data, {
|
|
1613
|
+
forceWithLease: force,
|
|
1614
|
+
expect,
|
|
1615
|
+
actor,
|
|
1616
|
+
});
|
|
1617
|
+
if (!pushed.ok) return { ...fromForge(pushed), data: { branch } };
|
|
1618
|
+
return {
|
|
1619
|
+
ok: true,
|
|
1620
|
+
data: {
|
|
1621
|
+
pushed: pushed.data.pushed,
|
|
1622
|
+
forced: pushed.data.forced,
|
|
1623
|
+
sha: plan.data.sha,
|
|
1624
|
+
},
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Bring every unmerged branch onto its effective parent (the nearest unmerged
|
|
1630
|
+
* branch below it, else the trunk), push it and retarget its PR, in order.
|
|
1631
|
+
* Writes the stack file after every branch, so a stop leaves the processed
|
|
1632
|
+
* branches recorded and the rest untouched.
|
|
1633
|
+
*/
|
|
1634
|
+
export function syncStack(
|
|
1635
|
+
cwd: string,
|
|
1636
|
+
ctx: Ctx | null,
|
|
1637
|
+
stack: StackFile,
|
|
1638
|
+
options: SyncOptions,
|
|
1639
|
+
actor: LedgerActor,
|
|
1640
|
+
): StackResult<SyncOutcome> {
|
|
1641
|
+
const remote = remoteFor(cwd, ctx?.resolved ?? null, stack.trunk);
|
|
1642
|
+
let observedTrunk: string | null = null;
|
|
1643
|
+
if (!options.dryRun && safeRef(stack.trunk)) {
|
|
1644
|
+
const fetched = fetchRefs(cwd, remote, [
|
|
1645
|
+
`+refs/heads/${stack.trunk}:refs/remotes/${remote}/${stack.trunk}`,
|
|
1646
|
+
]);
|
|
1647
|
+
if (!fetched.ok)
|
|
1648
|
+
return stackFail(fetched.code, fetched.error, `git fetch ${remote} ${stack.trunk}`);
|
|
1649
|
+
} else if (safeRef(stack.trunk)) {
|
|
1650
|
+
// Dry run: read the remote trunk without moving any ref (objects by id only).
|
|
1651
|
+
const remoteTrunk = remoteRefTip(cwd, remote, `refs/heads/${stack.trunk}`);
|
|
1652
|
+
if (remoteTrunk.ok && remoteTrunk.sha) {
|
|
1653
|
+
if (!hasCommit(cwd, remoteTrunk.sha)) fetchRefs(cwd, remote, [remoteTrunk.sha]);
|
|
1654
|
+
if (hasCommit(cwd, remoteTrunk.sha)) observedTrunk = remoteTrunk.sha;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
const trunkSha = observedTrunk ?? trunkTip(cwd, remote, stack.trunk);
|
|
1658
|
+
if (!trunkSha)
|
|
1659
|
+
return stackFail(
|
|
1660
|
+
"not_found",
|
|
1661
|
+
`trunk ${stack.trunk} is not available`,
|
|
1662
|
+
`git fetch ${remote} ${stack.trunk}`,
|
|
1663
|
+
);
|
|
1664
|
+
if (ctx) {
|
|
1665
|
+
const mismatch = repoMismatch(ctx, stack);
|
|
1666
|
+
if (mismatch) return mismatch;
|
|
1667
|
+
}
|
|
1668
|
+
const state: SyncState = { docs: new Map() };
|
|
1669
|
+
const merged = detectMerged(ctx, cwd, stack, trunkSha, state);
|
|
1670
|
+
if (!merged.ok) return merged;
|
|
1671
|
+
|
|
1672
|
+
const outcome: SyncOutcome = {
|
|
1673
|
+
name: stack.name,
|
|
1674
|
+
dryRun: options.dryRun,
|
|
1675
|
+
merged: merged.data,
|
|
1676
|
+
steps: [],
|
|
1677
|
+
};
|
|
1678
|
+
pruneScratch(cwd);
|
|
1679
|
+
|
|
1680
|
+
let parent = { name: stack.trunk, tip: trunkSha as string | null };
|
|
1681
|
+
let processed = 0;
|
|
1682
|
+
for (const entry of stack.branches) {
|
|
1683
|
+
if (entry.merged) continue;
|
|
1684
|
+
if (options.limit !== undefined && processed >= options.limit) break;
|
|
1685
|
+
processed += 1;
|
|
1686
|
+
touchStackLock();
|
|
1687
|
+
const tip = branchTip(cwd, entry.branch);
|
|
1688
|
+
if (!tip)
|
|
1689
|
+
return stackFail(
|
|
1690
|
+
"not_found",
|
|
1691
|
+
`stack branch ${entry.branch} does not exist locally`,
|
|
1692
|
+
`git branch ${entry.branch} ${remote}/${entry.branch} # then workit stack sync`,
|
|
1693
|
+
{ progress: outcome },
|
|
1694
|
+
);
|
|
1695
|
+
const step: SyncStep = {
|
|
1696
|
+
branch: entry.branch,
|
|
1697
|
+
pr: entry.pr,
|
|
1698
|
+
parent: parent.name,
|
|
1699
|
+
restack: null,
|
|
1700
|
+
push: null,
|
|
1701
|
+
retarget: null,
|
|
1702
|
+
};
|
|
1703
|
+
let newTip: string | null = tip;
|
|
1704
|
+
const parentTip = parent.tip;
|
|
1705
|
+
if (parentTip === null) {
|
|
1706
|
+
// Dry run: the parent would be rewritten first, so this one moves too.
|
|
1707
|
+
step.restack = {
|
|
1708
|
+
from: entry.lastParentHead ?? "?",
|
|
1709
|
+
onto: `${parent.name} (restacked)`,
|
|
1710
|
+
head: null,
|
|
1711
|
+
carried: null,
|
|
1712
|
+
};
|
|
1713
|
+
newTip = null;
|
|
1714
|
+
} else if (!isAncestor(cwd, parentTip, tip)) {
|
|
1715
|
+
const pending = entry.pendingRestack ?? null;
|
|
1716
|
+
const from =
|
|
1717
|
+
pending && isAncestor(cwd, pending.onto, tip)
|
|
1718
|
+
? pending.onto
|
|
1719
|
+
: entry.lastParentHead && isAncestor(cwd, entry.lastParentHead, tip)
|
|
1720
|
+
? entry.lastParentHead
|
|
1721
|
+
: (mergeBase(cwd, entry.lastParentHead ?? parentTip, tip) ?? parentTip);
|
|
1722
|
+
if (options.dryRun) {
|
|
1723
|
+
step.restack = { from, onto: parentTip, head: null, carried: null };
|
|
1724
|
+
newTip = null;
|
|
1725
|
+
} else {
|
|
1726
|
+
touchStackLock();
|
|
1727
|
+
// A plain rebase linearizes merges and drops their resolutions.
|
|
1728
|
+
const merges = gitRun(cwd, ["rev-list", "--merges", `${from}..${tip}`]);
|
|
1729
|
+
if (!merges.ok || merges.stdout.trim())
|
|
1730
|
+
return stackFail(
|
|
1731
|
+
"blocked",
|
|
1732
|
+
`merge_commits: ${entry.branch} has merge commits since ${from.slice(0, 12)}; restacking would drop what they resolved`,
|
|
1733
|
+
`git rebase --rebase-merges --onto ${parentTip} ${from} ${entry.branch} # check the result, then: workit stack sync`,
|
|
1734
|
+
{
|
|
1735
|
+
branch: entry.branch,
|
|
1736
|
+
merges: merges.stdout.trim().split("\n").filter(Boolean),
|
|
1737
|
+
progress: outcome,
|
|
1738
|
+
},
|
|
1739
|
+
);
|
|
1740
|
+
const owner = worktreeOf(cwd, entry.branch);
|
|
1741
|
+
if (owner && rebaseInProgress(owner))
|
|
1742
|
+
return stackFail(
|
|
1743
|
+
"blocked",
|
|
1744
|
+
`a rebase is in progress in ${owner}`,
|
|
1745
|
+
`git -C ${owner} rebase --continue # or --abort; then workit stack --cwd ${owner} sync`,
|
|
1746
|
+
{ conflict: entry.branch, worktree: owner, progress: outcome },
|
|
1747
|
+
);
|
|
1748
|
+
if (owner && trackedDirt(owner))
|
|
1749
|
+
return stackFail(
|
|
1750
|
+
"blocked",
|
|
1751
|
+
`dirty_worktree: ${owner} has ${entry.branch} checked out with uncommitted changes; restacking needs it clean`,
|
|
1752
|
+
`commit or stash the changes in ${owner}, then workit stack sync`,
|
|
1753
|
+
{ branch: entry.branch, worktree: owner, progress: outcome },
|
|
1754
|
+
);
|
|
1755
|
+
// Record what is being moved before moving it: a conflict or a
|
|
1756
|
+
// timeout leaves this in the stack file for the next sync to check.
|
|
1757
|
+
entry.pendingRestack = pending
|
|
1758
|
+
? { ...pending, onto: parentTip }
|
|
1759
|
+
: { from, tip, onto: parentTip };
|
|
1760
|
+
const recorded = writeStack(cwd, stack);
|
|
1761
|
+
if (!recorded.ok) return recorded;
|
|
1762
|
+
const rebased = stackDeps.adapter.restack(cwd, {
|
|
1763
|
+
branch: entry.branch,
|
|
1764
|
+
onto: parentTip,
|
|
1765
|
+
from,
|
|
1766
|
+
});
|
|
1767
|
+
touchStackLock();
|
|
1768
|
+
if (!rebased.ok) {
|
|
1769
|
+
outcome.steps.push(step);
|
|
1770
|
+
if (rebased.timedOut)
|
|
1771
|
+
return stackFail(
|
|
1772
|
+
"failed",
|
|
1773
|
+
`timed_out: restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
|
|
1774
|
+
`finish or abort the rebase in ${rebased.worktree} (git rebase --continue | --abort), then workit stack sync # slow hooks: raise WORKIT_STACK_REBASE_TIMEOUT_MS`,
|
|
1775
|
+
{
|
|
1776
|
+
reason: "timed_out",
|
|
1777
|
+
branch: entry.branch,
|
|
1778
|
+
worktree: rebased.worktree,
|
|
1779
|
+
progress: outcome,
|
|
1780
|
+
},
|
|
1781
|
+
);
|
|
1782
|
+
return rebased.conflict
|
|
1783
|
+
? stackFail(
|
|
1784
|
+
"blocked",
|
|
1785
|
+
`conflict restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
|
|
1786
|
+
`resolve the conflicts in ${rebased.worktree}, git rebase --continue, then workit stack sync # or git rebase --abort`,
|
|
1787
|
+
{
|
|
1788
|
+
conflict: entry.branch,
|
|
1789
|
+
worktree: rebased.worktree,
|
|
1790
|
+
progress: outcome,
|
|
1791
|
+
},
|
|
1792
|
+
)
|
|
1793
|
+
: stackFail(
|
|
1794
|
+
"failed",
|
|
1795
|
+
`restacking ${entry.branch} failed: ${rebased.error}`,
|
|
1796
|
+
undefined,
|
|
1797
|
+
{
|
|
1798
|
+
branch: entry.branch,
|
|
1799
|
+
progress: outcome,
|
|
1800
|
+
},
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
newTip = rebased.head;
|
|
1804
|
+
step.restack = { from, onto: parentTip, head: newTip, carried: null };
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
if (!options.dryRun && newTip) {
|
|
1808
|
+
// Content safety: what was moved must arrive as the same change. The
|
|
1809
|
+
// comparison is between the commits actually rebased (`from..tip`, as
|
|
1810
|
+
// recorded before the rebase, even across a conflict) and the result
|
|
1811
|
+
// on the new base: the exact diff, and no commit dropped as empty or
|
|
1812
|
+
// already applied. Otherwise nothing is pushed and the record stays.
|
|
1813
|
+
const check =
|
|
1814
|
+
entry.pendingRestack && parentTip && isAncestor(cwd, parentTip, newTip)
|
|
1815
|
+
? entry.pendingRestack
|
|
1816
|
+
: null;
|
|
1817
|
+
let forced = false;
|
|
1818
|
+
if (check && parentTip) {
|
|
1819
|
+
const before = {
|
|
1820
|
+
d: diffHash(cwd, check.from, check.tip),
|
|
1821
|
+
commits: commitCount(cwd, check.from, check.tip),
|
|
1822
|
+
};
|
|
1823
|
+
const after = {
|
|
1824
|
+
d: diffHash(cwd, parentTip, newTip),
|
|
1825
|
+
commits: commitCount(cwd, parentTip, newTip),
|
|
1826
|
+
};
|
|
1827
|
+
const sameContent = before.d !== null && before.d === after.d;
|
|
1828
|
+
const dropped = Math.max(0, before.commits - after.commits);
|
|
1829
|
+
if (!sameContent || dropped > 0) {
|
|
1830
|
+
if (!options.force?.has(entry.branch)) {
|
|
1831
|
+
outcome.steps.push(step);
|
|
1832
|
+
const written = writeStack(cwd, stack);
|
|
1833
|
+
if (!written.ok) return written;
|
|
1834
|
+
return stackFail(
|
|
1835
|
+
"blocked",
|
|
1836
|
+
`content_changed: ${entry.branch} on ${parent.name} is not the same change it was on ${check.from.slice(0, 12)}${dropped ? ` (${dropped} commit${dropped === 1 ? "" : "s"} dropped as empty or already applied)` : ""}; nothing was pushed`,
|
|
1837
|
+
`inspect: git range-diff ${check.from}..${check.tip} ${parentTip}..${newTip} # if intended: workit stack sync --force ${entry.branch}; to undo: git branch -f ${entry.branch} ${check.tip}`,
|
|
1838
|
+
{
|
|
1839
|
+
reason: "content_changed",
|
|
1840
|
+
branch: entry.branch,
|
|
1841
|
+
before: {
|
|
1842
|
+
base: check.from,
|
|
1843
|
+
head: check.tip,
|
|
1844
|
+
commits: before.commits,
|
|
1845
|
+
stat: shortStat(cwd, check.from, check.tip),
|
|
1846
|
+
},
|
|
1847
|
+
after: {
|
|
1848
|
+
base: parentTip,
|
|
1849
|
+
head: newTip,
|
|
1850
|
+
commits: after.commits,
|
|
1851
|
+
stat: shortStat(cwd, parentTip, newTip),
|
|
1852
|
+
},
|
|
1853
|
+
dropped,
|
|
1854
|
+
progress: outcome,
|
|
1855
|
+
},
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
forced = true;
|
|
1859
|
+
}
|
|
1860
|
+
entry.pendingRestack = null;
|
|
1861
|
+
}
|
|
1862
|
+
// Verdict carry (S13): the last verified head vs the new one, each
|
|
1863
|
+
// against its own base. Equal only for the same patch and exact diff.
|
|
1864
|
+
const moved =
|
|
1865
|
+
entry.lastHead !== null &&
|
|
1866
|
+
entry.lastHead !== newTip &&
|
|
1867
|
+
entry.lastParentHead !== null &&
|
|
1868
|
+
parentTip !== null &&
|
|
1869
|
+
parentTip !== entry.lastParentHead;
|
|
1870
|
+
if (moved && parentTip) {
|
|
1871
|
+
const fromBase = entry.lastParentHead as string;
|
|
1872
|
+
const fromHead = entry.lastHead as string;
|
|
1873
|
+
const keysBefore = rangeKeys(cwd, fromBase, fromHead);
|
|
1874
|
+
const keysAfter = rangeKeys(cwd, parentTip, newTip);
|
|
1875
|
+
const before = { p: keysBefore.patchId, d: keysBefore.diffHash };
|
|
1876
|
+
const after = { p: keysAfter.patchId, d: keysAfter.diffHash };
|
|
1877
|
+
const equal =
|
|
1878
|
+
before.d !== null &&
|
|
1879
|
+
before.d === after.d &&
|
|
1880
|
+
before.p !== null &&
|
|
1881
|
+
before.p === after.p &&
|
|
1882
|
+
sameOwnChange(cwd, {
|
|
1883
|
+
from: fromHead,
|
|
1884
|
+
to: newTip,
|
|
1885
|
+
fromBase,
|
|
1886
|
+
toBase: parentTip,
|
|
1887
|
+
});
|
|
1888
|
+
appendObserved(cwd, {
|
|
1889
|
+
type: "stack.restacked",
|
|
1890
|
+
actor,
|
|
1891
|
+
stack: stack.name,
|
|
1892
|
+
branch: entry.branch,
|
|
1893
|
+
head: newTip,
|
|
1894
|
+
fromHead,
|
|
1895
|
+
fromBase,
|
|
1896
|
+
toBase: parentTip,
|
|
1897
|
+
patchId: after.p,
|
|
1898
|
+
diffHash: after.d,
|
|
1899
|
+
patchEqual: equal,
|
|
1900
|
+
forced,
|
|
1901
|
+
});
|
|
1902
|
+
if (step.restack) step.restack.carried = equal;
|
|
1903
|
+
else
|
|
1904
|
+
step.restack = {
|
|
1905
|
+
from: fromBase,
|
|
1906
|
+
onto: parentTip,
|
|
1907
|
+
head: newTip,
|
|
1908
|
+
carried: equal,
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
if (options.publish) {
|
|
1912
|
+
touchStackLock();
|
|
1913
|
+
const pushed = pushBranch(cwd, actor, entry.branch, entry.lastHead ?? tip);
|
|
1914
|
+
touchStackLock();
|
|
1915
|
+
if (!pushed.ok) {
|
|
1916
|
+
outcome.steps.push(step);
|
|
1917
|
+
return { ...pushed, data: { ...pushed.data, progress: outcome } };
|
|
1918
|
+
}
|
|
1919
|
+
step.push = pushed.data;
|
|
1920
|
+
}
|
|
1921
|
+
entry.lastHead = newTip;
|
|
1922
|
+
entry.lastParentHead = parentTip;
|
|
1923
|
+
entry.patchId = parentTip ? patchId(cwd, parentTip, newTip) : null;
|
|
1924
|
+
}
|
|
1925
|
+
if (options.publish && ctx && entry.pr) {
|
|
1926
|
+
const doc = state.docs.get(entry.pr);
|
|
1927
|
+
if (doc && doc.state === "open" && doc.base !== parent.name) {
|
|
1928
|
+
step.retarget = { from: doc.base, to: parent.name };
|
|
1929
|
+
if (!options.dryRun) {
|
|
1930
|
+
const grant = requireGrant(cwd, "pr", { target: baseTarget(ctx.resolved) });
|
|
1931
|
+
if (!grant.allowed)
|
|
1932
|
+
return stackFail("blocked", grant.error, grant.unblock, {
|
|
1933
|
+
reason: grant.reason,
|
|
1934
|
+
progress: outcome,
|
|
1935
|
+
});
|
|
1936
|
+
const updated = ctx.resolved.forge.updateBase(entry.pr, parent.name);
|
|
1937
|
+
if (!updated.ok)
|
|
1938
|
+
return {
|
|
1939
|
+
...fromForge(updated),
|
|
1940
|
+
data: { branch: entry.branch, progress: outcome },
|
|
1941
|
+
};
|
|
1942
|
+
appendObserved(cwd, {
|
|
1943
|
+
type: "pr.retargeted",
|
|
1944
|
+
actor,
|
|
1945
|
+
stack: stack.name,
|
|
1946
|
+
branch: entry.branch,
|
|
1947
|
+
head: newTip,
|
|
1948
|
+
pr: entry.pr,
|
|
1949
|
+
base: parent.name,
|
|
1950
|
+
previousBase: doc.base,
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
entry.parent = parent.name;
|
|
1956
|
+
outcome.steps.push(step);
|
|
1957
|
+
if (!options.dryRun) {
|
|
1958
|
+
const written = writeStack(cwd, stack);
|
|
1959
|
+
if (!written.ok) return written;
|
|
1960
|
+
}
|
|
1961
|
+
parent = { name: entry.branch, tip: newTip };
|
|
1962
|
+
}
|
|
1963
|
+
if (!options.dryRun) {
|
|
1964
|
+
const written = writeStack(cwd, stack);
|
|
1965
|
+
if (!written.ok) return written;
|
|
1966
|
+
}
|
|
1967
|
+
return { ok: true, data: outcome };
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
export type PruneOutcome = {
|
|
1971
|
+
/** Merged stack branches deleted locally (or that would be, on a dry run), with the tip to restore. */
|
|
1972
|
+
deleted: Array<{ branch: string; sha: string }>;
|
|
1973
|
+
/** Merged stack branches left in place, with why. */
|
|
1974
|
+
kept: Array<{ branch: string; reason: string }>;
|
|
1975
|
+
};
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Delete the local branches of merged stack entries (#506). Only branches
|
|
1979
|
+
* this stack recorded, never the trunk or a protected name, never one a
|
|
1980
|
+
* worktree has checked out, only while the local tip is still the head the
|
|
1981
|
+
* stack last recorded, and only when no commit can be lost: the tip is on
|
|
1982
|
+
* the trunk, or is the head the PR merged at, or is contained in the
|
|
1983
|
+
* branch's current remote tip. Each deletion names its sha (restore with
|
|
1984
|
+
* `git branch <name> <sha>`) and is recorded in the ledger. Remote branches
|
|
1985
|
+
* are left to the forge's delete-on-merge.
|
|
1986
|
+
*/
|
|
1987
|
+
export function pruneMergedBranches(
|
|
1988
|
+
cwd: string,
|
|
1989
|
+
ctx: Ctx | null,
|
|
1990
|
+
stack: StackFile,
|
|
1991
|
+
options: { dryRun: boolean; actor: LedgerActor },
|
|
1992
|
+
): PruneOutcome {
|
|
1993
|
+
const outcome: PruneOutcome = { deleted: [], kept: [] };
|
|
1994
|
+
const remote = remoteFor(cwd, ctx?.resolved ?? null, stack.trunk);
|
|
1995
|
+
const trunkSha = trunkTip(cwd, remote, stack.trunk);
|
|
1996
|
+
const keep = (branch: string, reason: string) => outcome.kept.push({ branch, reason });
|
|
1997
|
+
for (const entry of stack.branches) {
|
|
1998
|
+
if (!entry.merged || !safeRef(entry.branch) || entry.branch === stack.trunk) continue;
|
|
1999
|
+
const tip = branchTip(cwd, entry.branch);
|
|
2000
|
+
if (!tip) continue;
|
|
2001
|
+
if (isProtectedTarget(cwd, entry.branch)) {
|
|
2002
|
+
keep(entry.branch, "a protected branch under the workspace branch policy; never deleted");
|
|
2003
|
+
continue;
|
|
2004
|
+
}
|
|
2005
|
+
if (entry.lastHead === null || tip !== entry.lastHead) {
|
|
2006
|
+
keep(
|
|
2007
|
+
entry.branch,
|
|
2008
|
+
`its tip ${tip.slice(0, 12)} is not recorded by the stack (last ${entry.lastHead?.slice(0, 12) ?? "none"}); commits made after it merged stay`,
|
|
2009
|
+
);
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
const owner = worktreeOf(cwd, entry.branch);
|
|
2013
|
+
if (owner) {
|
|
2014
|
+
keep(
|
|
2015
|
+
entry.branch,
|
|
2016
|
+
`checked out in ${owner}; switch it away, then git branch -D ${entry.branch}`,
|
|
2017
|
+
);
|
|
2018
|
+
continue;
|
|
2019
|
+
}
|
|
2020
|
+
const safe = safeToDelete(cwd, ctx, remote, trunkSha, entry, tip);
|
|
2021
|
+
if (safe === null) {
|
|
2022
|
+
keep(
|
|
2023
|
+
entry.branch,
|
|
2024
|
+
`${tip.slice(0, 12)} is not on ${stack.trunk}, not the head its PR merged at and not on ${remote}/${entry.branch}: deleting it could lose commits (push them, or delete it yourself)`,
|
|
2025
|
+
);
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
if (!options.dryRun) {
|
|
2029
|
+
const removed = gitRun(cwd, ["branch", "-D", "--", entry.branch]);
|
|
2030
|
+
if (!removed.ok) {
|
|
2031
|
+
keep(entry.branch, `git branch -D failed: ${removed.stderr.trim().split("\n")[0] ?? ""}`);
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
appendObserved(cwd, {
|
|
2035
|
+
type: "stack.branch.deleted",
|
|
2036
|
+
actor: options.actor,
|
|
2037
|
+
stack: stack.name,
|
|
2038
|
+
branch: entry.branch,
|
|
2039
|
+
head: tip,
|
|
2040
|
+
pr: entry.pr,
|
|
2041
|
+
because: safe,
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
outcome.deleted.push({ branch: entry.branch, sha: tip });
|
|
2045
|
+
}
|
|
2046
|
+
return outcome;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/** Why deleting `branch` at `tip` loses nothing, or null when it might. */
|
|
2050
|
+
function safeToDelete(
|
|
2051
|
+
cwd: string,
|
|
2052
|
+
ctx: Ctx | null,
|
|
2053
|
+
remote: string,
|
|
2054
|
+
trunkSha: string | null,
|
|
2055
|
+
entry: StackEntry,
|
|
2056
|
+
tip: string,
|
|
2057
|
+
): "on_trunk" | "merged_head" | "on_remote" | null {
|
|
2058
|
+
if (trunkSha && isAncestor(cwd, tip, trunkSha)) return "on_trunk";
|
|
2059
|
+
if (ctx && entry.pr) {
|
|
2060
|
+
const read = statusDoc(ctx, entry.pr);
|
|
2061
|
+
if (read.ok && read.data.state === "merged" && read.data.head.sha === tip) return "merged_head";
|
|
2062
|
+
}
|
|
2063
|
+
const own = pushRemoteName(cwd, entry.branch) ?? remote;
|
|
2064
|
+
const remoteTip = remoteRefTip(cwd, own, `refs/heads/${entry.branch}`);
|
|
2065
|
+
if (remoteTip.ok && remoteTip.sha) {
|
|
2066
|
+
if (!hasCommit(cwd, remoteTip.sha)) fetchRefs(cwd, own, [remoteTip.sha]);
|
|
2067
|
+
if (hasCommit(cwd, remoteTip.sha) && isAncestor(cwd, tip, remoteTip.sha)) return "on_remote";
|
|
2068
|
+
}
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const commitCount = (cwd: string, base: string, head: string): number =>
|
|
2073
|
+
Number(gitRun(cwd, ["rev-list", "--count", "--no-merges", `${base}..${head}`]).stdout.trim()) ||
|
|
2074
|
+
0;
|
|
2075
|
+
|
|
2076
|
+
const shortStat = (cwd: string, base: string, head: string): string =>
|
|
2077
|
+
gitRun(cwd, ["diff", "--shortstat", `${base}...${head}`]).stdout.trim();
|
|
2078
|
+
|
|
2079
|
+
// ---------------------------------------------------------------------------
|
|
2080
|
+
// land
|
|
2081
|
+
|
|
2082
|
+
export type LandOptions = {
|
|
2083
|
+
dryRun: boolean;
|
|
2084
|
+
max: number | null;
|
|
2085
|
+
method: MergeMethod;
|
|
2086
|
+
/** Land without verdicts (`merge: true` only); each bypass is recorded. */
|
|
2087
|
+
unverified?: { reason: string } | null;
|
|
2088
|
+
/** Merge onto a red base (`--base-red-ok`); each merge records the reason. */
|
|
2089
|
+
baseRedOk?: { reason: string } | null;
|
|
2090
|
+
/** Merge PRs another author opened (`--others-pr`, as `pr merge`); each merge records the reason. */
|
|
2091
|
+
othersPr?: { reason: string } | null;
|
|
2092
|
+
/**
|
|
2093
|
+
* Pass these failing optional checks (`--allow-failing`, as `pr merge`):
|
|
2094
|
+
* never a required one; each PR it lets through records its own
|
|
2095
|
+
* `merge.waived` rows.
|
|
2096
|
+
*/
|
|
2097
|
+
allowFailing?: CheckWaiver | null;
|
|
2098
|
+
/** How long to wait for CI on a PR that was just restacked. */
|
|
2099
|
+
timeoutMs: number;
|
|
2100
|
+
intervalMs: number;
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2103
|
+
export type LandStop = {
|
|
2104
|
+
pr: number | null;
|
|
2105
|
+
branch: string;
|
|
2106
|
+
reason: StopReason;
|
|
2107
|
+
detail: string;
|
|
2108
|
+
unblock: string;
|
|
2109
|
+
/** Everything but the merge grant holds: "verified, ready". */
|
|
2110
|
+
ready: boolean;
|
|
2111
|
+
/** The forge, not the PR, must move first (mergeability still UNKNOWN after the re-reads). */
|
|
2112
|
+
babysit?: "wait-forge";
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* One PR in the pre-flight: `refused` is why it would not land (null: it
|
|
2117
|
+
* passes); `waives` names the failing optional checks `--allow-failing`
|
|
2118
|
+
* passes on its head.
|
|
2119
|
+
*/
|
|
2120
|
+
export type PreflightEntry = {
|
|
2121
|
+
pr: number | null;
|
|
2122
|
+
branch: string;
|
|
2123
|
+
waives: string[];
|
|
2124
|
+
refused: LandStop | null;
|
|
2125
|
+
};
|
|
2126
|
+
|
|
2127
|
+
export type LandOutcome = {
|
|
2128
|
+
name: string;
|
|
2129
|
+
dryRun: boolean;
|
|
2130
|
+
/**
|
|
2131
|
+
* `unverified`: the ledger row of an `--unverified` bypass, else null;
|
|
2132
|
+
* `waived`: the failing optional checks `--allow-failing` passed on this PR.
|
|
2133
|
+
*/
|
|
2134
|
+
landed: Array<{
|
|
2135
|
+
pr: number;
|
|
2136
|
+
branch: string;
|
|
2137
|
+
mergeSha: string | null;
|
|
2138
|
+
unverified: string | null;
|
|
2139
|
+
waived: WaivedCheck[];
|
|
2140
|
+
}>;
|
|
2141
|
+
/** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
|
|
2142
|
+
wouldLand: Array<{ pr: number; branch: string }>;
|
|
2143
|
+
/**
|
|
2144
|
+
* Every PR this run would land, judged before anything merges (#739), in
|
|
2145
|
+
* order; any refusal stops the land with nothing merged. A dry run prints
|
|
2146
|
+
* the same.
|
|
2147
|
+
*/
|
|
2148
|
+
preflight: PreflightEntry[];
|
|
2149
|
+
stoppedAt: LandStop | null;
|
|
2150
|
+
retargeted: number[];
|
|
2151
|
+
restacked: string[];
|
|
2152
|
+
complete: boolean;
|
|
2153
|
+
};
|
|
2154
|
+
|
|
2155
|
+
/** Poll the PR until its checks settle or the wait budget is spent (sleep-time based). */
|
|
2156
|
+
async function awaitChecks(
|
|
2157
|
+
ctx: Ctx,
|
|
2158
|
+
pr: number,
|
|
2159
|
+
head: string,
|
|
2160
|
+
options: LandOptions,
|
|
2161
|
+
): Promise<ForgeResult<PrStatusDoc>> {
|
|
2162
|
+
let waited = 0;
|
|
2163
|
+
for (let poll = 0; ; poll += 1) {
|
|
2164
|
+
touchStackLock();
|
|
2165
|
+
const doc = statusDoc(ctx, pr);
|
|
2166
|
+
if (!doc.ok) return doc;
|
|
2167
|
+
const verdict = waitVerdict(doc.data, { head, elapsedMs: waited });
|
|
2168
|
+
if (verdict.state !== "waiting" || waited >= options.timeoutMs) return doc;
|
|
2169
|
+
const delay = Math.min(
|
|
2170
|
+
pollDelay(options.intervalMs, poll),
|
|
2171
|
+
Math.max(1, options.timeoutMs - waited),
|
|
2172
|
+
);
|
|
2173
|
+
await ctx.sleep(delay);
|
|
2174
|
+
waited += delay;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
export async function landStack(
|
|
2179
|
+
ctx: Ctx,
|
|
2180
|
+
stack: StackFile,
|
|
2181
|
+
options: LandOptions,
|
|
2182
|
+
): Promise<StackResult<LandOutcome>> {
|
|
2183
|
+
const outcome: LandOutcome = {
|
|
2184
|
+
name: stack.name,
|
|
2185
|
+
dryRun: options.dryRun,
|
|
2186
|
+
landed: [],
|
|
2187
|
+
wouldLand: [],
|
|
2188
|
+
preflight: [],
|
|
2189
|
+
stoppedAt: null,
|
|
2190
|
+
retargeted: [],
|
|
2191
|
+
restacked: [],
|
|
2192
|
+
complete: false,
|
|
2193
|
+
};
|
|
2194
|
+
const absorb = (sync: SyncOutcome) => {
|
|
2195
|
+
for (const step of sync.steps) {
|
|
2196
|
+
if (step.retarget && step.pr && !outcome.retargeted.includes(step.pr))
|
|
2197
|
+
outcome.retargeted.push(step.pr);
|
|
2198
|
+
if (step.restack?.head && !outcome.restacked.includes(step.branch))
|
|
2199
|
+
outcome.restacked.push(step.branch);
|
|
2200
|
+
}
|
|
2201
|
+
};
|
|
2202
|
+
// A stop after a merge names what already landed.
|
|
2203
|
+
const failWith = (error: StackError): StackError => ({
|
|
2204
|
+
...error,
|
|
2205
|
+
error: outcome.landed.length
|
|
2206
|
+
? `${error.error}; landed before the stop: ${outcome.landed.map((item) => `${noun(ctx.resolved, item.pr)} (${item.branch})`).join(", ")}`
|
|
2207
|
+
: error.error,
|
|
2208
|
+
data: { ...error.data, land: outcome },
|
|
2209
|
+
});
|
|
2210
|
+
const mismatch = repoMismatch(ctx, stack);
|
|
2211
|
+
if (mismatch) return failWith(mismatch);
|
|
2212
|
+
const bypass = options.unverified ?? null;
|
|
2213
|
+
if (bypass) {
|
|
2214
|
+
const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
|
|
2215
|
+
if (!grant.allowed)
|
|
2216
|
+
return failWith(
|
|
2217
|
+
stackFail("blocked", grant.error, grant.unblock, {
|
|
2218
|
+
reason: "grant_required",
|
|
2219
|
+
}),
|
|
2220
|
+
);
|
|
2221
|
+
if (!grant.allowUnverified)
|
|
2222
|
+
return failWith(
|
|
2223
|
+
stackFail(
|
|
2224
|
+
"blocked",
|
|
2225
|
+
`unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never lands without an accepted independent verdict`,
|
|
2226
|
+
"a non-author session verifies each head (workit-review), then land without --unverified",
|
|
2227
|
+
{ reason: "unverified_refused" },
|
|
2228
|
+
),
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Catch up first: a parent merged elsewhere leaves the rest to restack.
|
|
2233
|
+
if (!options.dryRun) {
|
|
2234
|
+
const synced = syncStack(
|
|
2235
|
+
ctx.cwd,
|
|
2236
|
+
ctx,
|
|
2237
|
+
stack,
|
|
2238
|
+
{ publish: true, dryRun: false, limit: 1 },
|
|
2239
|
+
ctx.actor,
|
|
2240
|
+
);
|
|
2241
|
+
if (!synced.ok) return failWith(synced);
|
|
2242
|
+
absorb(synced.data);
|
|
2243
|
+
}
|
|
2244
|
+
const ledgerRows = () => {
|
|
2245
|
+
const ledger = readLedger(ctx.cwd, { integrity: true });
|
|
2246
|
+
return ledger.ok ? ledger.value.rows : [];
|
|
2247
|
+
};
|
|
2248
|
+
let justRestacked = new Set(outcome.restacked);
|
|
2249
|
+
|
|
2250
|
+
// Judge every PR first: a refusal known now lands nothing (#739).
|
|
2251
|
+
const pre = preflightLand(ctx, stack, options, justRestacked, ledgerRows());
|
|
2252
|
+
if (!pre.ok) return failWith(pre);
|
|
2253
|
+
outcome.preflight = pre.data.entries;
|
|
2254
|
+
const refused = outcome.preflight.flatMap((entry) => (entry.refused ? [entry.refused] : []));
|
|
2255
|
+
if (refused.length) {
|
|
2256
|
+
outcome.stoppedAt = refused[0];
|
|
2257
|
+
if (refused.some((stop) => stop.reason === "waiver_refused"))
|
|
2258
|
+
return failWith(
|
|
2259
|
+
stackFail(
|
|
2260
|
+
"blocked",
|
|
2261
|
+
`waiver_refused: nothing landed; the pre-flight refused ${refused.map((stop) => `${stop.pr ? noun(ctx.resolved, stop.pr) : stop.branch} (${stop.branch}) ${stop.detail}`).join("; ")}`,
|
|
2262
|
+
refused.find((stop) => stop.reason === "waiver_refused")?.unblock,
|
|
2263
|
+
{ reason: "waiver_refused" },
|
|
2264
|
+
),
|
|
2265
|
+
);
|
|
2266
|
+
return { ok: true, data: outcome };
|
|
2267
|
+
}
|
|
2268
|
+
if (options.dryRun) {
|
|
2269
|
+
outcome.wouldLand = outcome.preflight.map((entry) => ({
|
|
2270
|
+
pr: entry.pr as number,
|
|
2271
|
+
branch: entry.branch,
|
|
2272
|
+
}));
|
|
2273
|
+
outcome.stoppedAt = pre.data.cut;
|
|
2274
|
+
outcome.complete = pre.data.cut === null;
|
|
2275
|
+
return { ok: true, data: outcome };
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// Merge bottom-up, re-judging each PR at its turn: what only the merge can
|
|
2279
|
+
// show (a head that moved, a forge race) stops here, after the PRs below it.
|
|
2280
|
+
for (;;) {
|
|
2281
|
+
touchStackLock();
|
|
2282
|
+
const entry = stack.branches.find((candidate) => !candidate.merged);
|
|
2283
|
+
if (!entry) {
|
|
2284
|
+
outcome.complete = true;
|
|
2285
|
+
break;
|
|
2286
|
+
}
|
|
2287
|
+
if (options.max !== null && outcome.landed.length >= options.max) {
|
|
2288
|
+
outcome.stoppedAt = maxReached(entry, options.max);
|
|
2289
|
+
break;
|
|
2290
|
+
}
|
|
2291
|
+
// `pr merge`'s gate, one per PR: UNKNOWN mergeability is re-read with
|
|
2292
|
+
// backoff, and an `--allow-failing` waiver passes the PR's failing
|
|
2293
|
+
// optional checks, recorded only once the forge accepts its merge (a PR
|
|
2294
|
+
// that stops records none). Sync keeps the raw forge: it lands nothing.
|
|
2295
|
+
const gate = landGate(ctx, options);
|
|
2296
|
+
const gated: Ctx = { ...ctx, resolved: { ...ctx.resolved, forge: gate } };
|
|
2297
|
+
let doc: PrStatusDoc | null = null;
|
|
2298
|
+
if (entry.pr) {
|
|
2299
|
+
const head = branchTip(ctx.cwd, entry.branch);
|
|
2300
|
+
const read =
|
|
2301
|
+
justRestacked.has(entry.branch) && head
|
|
2302
|
+
? await awaitChecks(gated, entry.pr, head, options)
|
|
2303
|
+
: statusDoc(gated, entry.pr);
|
|
2304
|
+
if (!read.ok) return failWith(fromForge(read));
|
|
2305
|
+
doc = read.data;
|
|
2306
|
+
}
|
|
2307
|
+
const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
|
|
2308
|
+
const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows(), doc);
|
|
2309
|
+
const q = qualify(
|
|
2310
|
+
ctx,
|
|
2311
|
+
stack,
|
|
2312
|
+
entry,
|
|
2313
|
+
doc,
|
|
2314
|
+
verdict,
|
|
2315
|
+
grant,
|
|
2316
|
+
doc ? prBinding(ctx, entry, doc) : null,
|
|
2317
|
+
bypass !== null,
|
|
2318
|
+
);
|
|
2319
|
+
if (!q.ok) {
|
|
2320
|
+
outcome.stoppedAt = landStop(entry, q, doc);
|
|
2321
|
+
break;
|
|
2322
|
+
}
|
|
2323
|
+
const pr = entry.pr as number;
|
|
2324
|
+
const merged = await mergePullRequest(
|
|
2325
|
+
ctx.cwd,
|
|
2326
|
+
gated.resolved,
|
|
2327
|
+
{
|
|
2328
|
+
pr,
|
|
2329
|
+
method: options.method,
|
|
2330
|
+
deleteBranch: false,
|
|
2331
|
+
actor: ctx.actor,
|
|
2332
|
+
expect: { base: stack.trunk, branch: entry.branch },
|
|
2333
|
+
...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
|
|
2334
|
+
...(options.baseRedOk ? { baseRedOk: options.baseRedOk } : {}),
|
|
2335
|
+
...(options.othersPr ? { othersPr: options.othersPr } : {}),
|
|
2336
|
+
},
|
|
2337
|
+
ctx.sleep,
|
|
2338
|
+
);
|
|
2339
|
+
if (!merged.ok) {
|
|
2340
|
+
outcome.stoppedAt = {
|
|
2341
|
+
pr,
|
|
2342
|
+
branch: entry.branch,
|
|
2343
|
+
reason: "merge_refused",
|
|
2344
|
+
detail: merged.error,
|
|
2345
|
+
unblock: merged.unblock ?? `workit pr status --pr ${pr}`,
|
|
2346
|
+
ready: false,
|
|
2347
|
+
};
|
|
2348
|
+
if (merged.code === "blocked" && "refusal" in merged && merged.refusal) break;
|
|
2349
|
+
return failWith(stackFail(merged.code, merged.error, merged.unblock));
|
|
2350
|
+
}
|
|
2351
|
+
outcome.landed.push({
|
|
2352
|
+
pr,
|
|
2353
|
+
branch: entry.branch,
|
|
2354
|
+
mergeSha: merged.data.mergeSha,
|
|
2355
|
+
unverified: merged.data.unverified?.recorded ?? null,
|
|
2356
|
+
waived: [...gate.waived],
|
|
2357
|
+
});
|
|
2358
|
+
entry.merged = {
|
|
2359
|
+
pr,
|
|
2360
|
+
mergeSha: merged.data.mergeSha,
|
|
2361
|
+
at: new Date().toISOString(),
|
|
2362
|
+
};
|
|
2363
|
+
const written = writeStack(ctx.cwd, stack);
|
|
2364
|
+
if (!written.ok) return failWith(written);
|
|
2365
|
+
// Move the next PR onto the trunk: restack, lease push, retarget. The
|
|
2366
|
+
// ones above it wait (restacking them now would only spend CI that the
|
|
2367
|
+
// next merge restarts); `workit stack sync` moves them all.
|
|
2368
|
+
const before = new Set(outcome.restacked);
|
|
2369
|
+
const synced = syncStack(
|
|
2370
|
+
ctx.cwd,
|
|
2371
|
+
ctx,
|
|
2372
|
+
stack,
|
|
2373
|
+
{ publish: true, dryRun: false, limit: 1 },
|
|
2374
|
+
ctx.actor,
|
|
2375
|
+
);
|
|
2376
|
+
if (!synced.ok) return failWith(synced);
|
|
2377
|
+
absorb(synced.data);
|
|
2378
|
+
justRestacked = new Set(outcome.restacked.filter((branch) => !before.has(branch)));
|
|
2379
|
+
for (const step of synced.data.steps) if (step.restack?.head) justRestacked.add(step.branch);
|
|
2380
|
+
}
|
|
2381
|
+
return { ok: true, data: outcome };
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/**
|
|
2385
|
+
* `pr merge`'s gate for one land read. The `--allow-failing` waiver applies
|
|
2386
|
+
* per PR: a PR whose head does not run a named check is not refused for it.
|
|
2387
|
+
*/
|
|
2388
|
+
const landGate = (ctx: Ctx, options: LandOptions) =>
|
|
2389
|
+
mergeGateForge(ctx.resolved.forge, ctx.cwd, {
|
|
2390
|
+
waiver: options.allowFailing ?? null,
|
|
2391
|
+
actor: ctx.actor,
|
|
2392
|
+
perPr: true,
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
const maxReached = (entry: StackEntry, max: number): LandStop => ({
|
|
2396
|
+
pr: entry.pr,
|
|
2397
|
+
branch: entry.branch,
|
|
2398
|
+
reason: "max_reached",
|
|
2399
|
+
detail: `--max ${max} reached`,
|
|
2400
|
+
unblock: "workit stack land",
|
|
2401
|
+
ready: false,
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
const landStop = (
|
|
2405
|
+
entry: StackEntry,
|
|
2406
|
+
q: Extract<Qualification, { ok: false }>,
|
|
2407
|
+
doc: PrStatusDoc | null,
|
|
2408
|
+
): LandStop => ({
|
|
2409
|
+
pr: entry.pr,
|
|
2410
|
+
branch: entry.branch,
|
|
2411
|
+
reason: q.reason,
|
|
2412
|
+
detail: q.detail,
|
|
2413
|
+
unblock: q.unblock,
|
|
2414
|
+
ready: q.ready,
|
|
2415
|
+
...(q.reason === "not_ready" && doc?.babysit === "wait-forge"
|
|
2416
|
+
? { babysit: "wait-forge" as const }
|
|
2417
|
+
: {}),
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
/** Blockers the land clears itself on a PR it restacks before judging it again. */
|
|
2421
|
+
const SETTLED_BY_RESTACK: ReadonlySet<string> = new Set(["checks_pending", "behind_base_required"]);
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* The PR as it will stand at its turn. With a PR landing below it, its base
|
|
2425
|
+
* is its parent until the land retargets it to the trunk; restacked (or about
|
|
2426
|
+
* to be), its CI runs again and the land awaits it.
|
|
2427
|
+
*/
|
|
2428
|
+
function atItsTurn(
|
|
2429
|
+
doc: PrStatusDoc,
|
|
2430
|
+
stack: StackFile,
|
|
2431
|
+
entry: StackEntry,
|
|
2432
|
+
below: boolean,
|
|
2433
|
+
restacked: boolean,
|
|
2434
|
+
): PrStatusDoc {
|
|
2435
|
+
let view = doc;
|
|
2436
|
+
if (below && (doc.base === entry.parent || doc.base === stack.trunk))
|
|
2437
|
+
view = { ...view, base: stack.trunk };
|
|
2438
|
+
if (
|
|
2439
|
+
restacked &&
|
|
2440
|
+
doc.next !== "READY" &&
|
|
2441
|
+
doc.blockers.length > 0 &&
|
|
2442
|
+
doc.blockers.every((blocker) => SETTLED_BY_RESTACK.has(blocker))
|
|
2443
|
+
)
|
|
2444
|
+
view = { ...view, next: "READY", blockers: [] };
|
|
2445
|
+
return view;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* `pr merge`'s others_pr gate, read before anything merges: the refusal for
|
|
2450
|
+
* a PR whose author is not the forge login (or cannot be shown to be), else
|
|
2451
|
+
* null. The login is read once.
|
|
2452
|
+
*/
|
|
2453
|
+
function othersPrGate(
|
|
2454
|
+
resolved: ResolvedForge,
|
|
2455
|
+
): (pr: number) => { detail: string; unblock: string } | null {
|
|
2456
|
+
let login: string | null | undefined;
|
|
2457
|
+
return (pr) => {
|
|
2458
|
+
const label = noun(resolved, pr);
|
|
2459
|
+
if (login === undefined) login = forgeLogin(resolved);
|
|
2460
|
+
const other = otherAuthor(resolved, pr, login);
|
|
2461
|
+
if (other === null) return null;
|
|
2462
|
+
if (!other.ok)
|
|
2463
|
+
return {
|
|
2464
|
+
detail: `identity_unknown: ${other.error}, so ${label} cannot be shown to be yours`,
|
|
2465
|
+
unblock:
|
|
2466
|
+
"fix: gh auth status / gh auth login (or glab auth login), then workit stack land again",
|
|
2467
|
+
};
|
|
2468
|
+
return {
|
|
2469
|
+
detail: `others_pr: ${label} is by ${other.author}, not ${other.login} (the forge login); workit never merges another author's PR on its own`,
|
|
2470
|
+
unblock: `only if the user asks you to land it: workit stack land --others-pr --reason "<why>"`,
|
|
2471
|
+
};
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/**
|
|
2476
|
+
* The pre-flight (#739): every PR this run would land (each open one, or the
|
|
2477
|
+
* first `--max`) is judged before anything merges, by the checks the merge
|
|
2478
|
+
* loop runs: `qualify` (PR, base, READY after the mergeability re-read,
|
|
2479
|
+
* verdict, grant), `pr merge`'s others_pr gate and the `--allow-failing`
|
|
2480
|
+
* waiver per PR (a required check named is `waiver_refused`). A PR above the
|
|
2481
|
+
* first is judged as it will stand at its turn (`atItsTurn`). A name no PR
|
|
2482
|
+
* runs is `unknown_check`. `cut` is the `--max` stop.
|
|
2483
|
+
*/
|
|
2484
|
+
function preflightLand(
|
|
2485
|
+
ctx: Ctx,
|
|
2486
|
+
stack: StackFile,
|
|
2487
|
+
options: LandOptions,
|
|
2488
|
+
justRestacked: ReadonlySet<string>,
|
|
2489
|
+
rows: readonly ReadRow[],
|
|
2490
|
+
): StackResult<{ entries: PreflightEntry[]; cut: LandStop | null }> {
|
|
2491
|
+
const gate = landGate(ctx, options);
|
|
2492
|
+
const gated: Ctx = { ...ctx, resolved: { ...ctx.resolved, forge: gate } };
|
|
2493
|
+
const grant = requireGrant(ctx.cwd, "merge", { target: baseTarget(ctx.resolved) });
|
|
2494
|
+
const others = options.othersPr ? null : othersPrGate(ctx.resolved);
|
|
2495
|
+
const entries: PreflightEntry[] = [];
|
|
2496
|
+
// A PR below this one lands first (or, in a dry run, merged elsewhere).
|
|
2497
|
+
let below = false;
|
|
2498
|
+
for (const entry of stack.branches) {
|
|
2499
|
+
if (entry.merged) continue;
|
|
2500
|
+
touchStackLock();
|
|
2501
|
+
if (options.max !== null && entries.length >= options.max)
|
|
2502
|
+
return {
|
|
2503
|
+
ok: true,
|
|
2504
|
+
data: { entries, cut: maxReached(entry, options.max) },
|
|
2505
|
+
};
|
|
2506
|
+
const judged = (refused: LandStop | null, waives: string[] = []) => {
|
|
2507
|
+
entries.push({ pr: entry.pr, branch: entry.branch, waives, refused });
|
|
2508
|
+
below = true;
|
|
2509
|
+
};
|
|
2510
|
+
let doc: PrStatusDoc | null = null;
|
|
2511
|
+
if (entry.pr) {
|
|
2512
|
+
const read = statusDoc(gated, entry.pr);
|
|
2513
|
+
if (!read.ok && read.code === "blocked" && read.error.startsWith("waiver_refused:")) {
|
|
2514
|
+
judged({
|
|
2515
|
+
pr: entry.pr,
|
|
2516
|
+
branch: entry.branch,
|
|
2517
|
+
reason: "waiver_refused",
|
|
2518
|
+
detail: read.error,
|
|
2519
|
+
unblock: read.unblock ?? `workit pr status --pr ${entry.pr}`,
|
|
2520
|
+
ready: false,
|
|
2521
|
+
});
|
|
2522
|
+
continue;
|
|
2523
|
+
}
|
|
2524
|
+
if (!read.ok) return fromForge(read);
|
|
2525
|
+
doc = read.data;
|
|
2526
|
+
if (options.dryRun && doc.state === "merged") {
|
|
2527
|
+
// Merged elsewhere; the real run would restack past it.
|
|
2528
|
+
below = true;
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
const q = qualify(
|
|
2533
|
+
ctx,
|
|
2534
|
+
stack,
|
|
2535
|
+
entry,
|
|
2536
|
+
doc && atItsTurn(doc, stack, entry, below, below || justRestacked.has(entry.branch)),
|
|
2537
|
+
verdictOf(ctx.cwd, entry.branch, rows, doc),
|
|
2538
|
+
grant,
|
|
2539
|
+
doc ? prBinding(ctx, entry, doc) : null,
|
|
2540
|
+
Boolean(options.unverified),
|
|
2541
|
+
);
|
|
2542
|
+
if (!q.ok) {
|
|
2543
|
+
judged(landStop(entry, q, doc));
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
const pr = entry.pr as number;
|
|
2547
|
+
const foreign = others?.(pr) ?? null;
|
|
2548
|
+
judged(
|
|
2549
|
+
foreign
|
|
2550
|
+
? {
|
|
2551
|
+
pr,
|
|
2552
|
+
branch: entry.branch,
|
|
2553
|
+
reason: "merge_refused",
|
|
2554
|
+
...foreign,
|
|
2555
|
+
ready: false,
|
|
2556
|
+
}
|
|
2557
|
+
: null,
|
|
2558
|
+
gate.waives(pr),
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
const unknown = options.allowFailing?.checks.filter((name) => !gate.matched.has(name)) ?? [];
|
|
2562
|
+
if (unknown.length)
|
|
2563
|
+
return stackFail(
|
|
2564
|
+
"invalid_input",
|
|
2565
|
+
`unknown_check: --allow-failing ${unknown.map((name) => JSON.stringify(name)).join(", ")} matches no check on any PR this land would merge (${entries.map((entry) => (entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch)).join(", ") || "none"})`,
|
|
2566
|
+
"workit pr status --pr <n> # copy the check name exactly",
|
|
2567
|
+
{ reason: "unknown_check" },
|
|
2568
|
+
);
|
|
2569
|
+
return { ok: true, data: { entries, cut: null } };
|
|
2570
|
+
}
|