@henryqw/pi-pr 6.2.13 → 9.0.2

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.
@@ -1,11 +1,16 @@
1
+ import type { PrRun } from "./pr-run.ts";
1
2
  import { spawnBounded, type Exec } from "@henryqw/pi-process";
2
3
  import { cloneCurrentPullRequest, loadCurrentPullRequest, readValidatedRemoteAuthority, samePullRequestSnapshot, type CurrentPullRequest } from "./pr-github.ts";
3
4
  import { extensionExecApi, inspectWorktree, inspectWorktreeState, isAncestor, parseNulPaths, parseStatusSnapshot, readHead, readRemoteOid, requiredText, runChecked, validatePaths, withWorktreeLock } from "./pr-execution.ts";
4
5
 
5
6
  type Check = { command: string; args: string[] };
6
- type Options = { cwd: string; authority: CurrentPullRequest; signal?: AbortSignal; agentDir?: string; exec?: Exec; loadCurrentPullRequest?: typeof loadCurrentPullRequest };
7
+ type Options = { run?: PrRun; cwd: string; authority: CurrentPullRequest; signal?: AbortSignal; agentDir?: string; exec?: Exec; loadCurrentPullRequest?: typeof loadCurrentPullRequest };
7
8
 
8
- /** One local publication attempt. An uncertain push consumes the run, even when a later read recovers. */
9
+ /**
10
+ * One local publication attempt. An uncertain push consumes the run, even when a later read recovers.
11
+ * A dirty branch that diverged from the PR head may inspect and commit; validation and publication
12
+ * still require a descendant HEAD, so the active /pr syncs the committed branch first.
13
+ */
9
14
  export class PullRequestWorkPublisher {
10
15
  private readonly authority: CurrentPullRequest;
11
16
  private readonly exec: Exec;
@@ -20,8 +25,8 @@ export class PullRequestWorkPublisher {
20
25
  if (options.authority.lifecycle !== "open" || options.authority.target.provenance !== "configured") {
21
26
  throw new Error("Local publication requires a configured open pull request");
22
27
  }
23
- if (options.authority.local.head === "behind" || options.authority.local.head === "diverged") {
24
- throw new Error("Local publication requires a branch descended from the published PR head");
28
+ if (options.authority.local.head === "behind") {
29
+ throw new Error("Local publication requires a branch that is not behind the published PR head");
25
30
  }
26
31
  this.options = options;
27
32
  this.authority = cloneCurrentPullRequest(options.authority);
@@ -49,24 +54,25 @@ export class PullRequestWorkPublisher {
49
54
  if (branch !== this.authority.target.branch) throw new Error("Local publication cancelled: branch changed");
50
55
  }
51
56
 
52
- async inspect(): Promise<{ paths: string[]; head: string }> {
53
- if (this.initialHead) throw new Error("Pending changes already inspected");
57
+ async inspect(): Promise<{ paths: string[]; head: string; originalHead: string; diverged: boolean }> {
58
+ if (this.consumed) throw new Error("Commit or publication run is consumed; inspect its outcome in a fresh /pr");
59
+ if (this.status !== undefined) throw new Error("Pending changes already inspected");
54
60
  return await withWorktreeLock(this.options.cwd, async () => {
55
61
  await this.authorityCheck();
56
62
  if (await inspectWorktreeState(this.exec, this.execOptions()) === "operation") throw new Error("Git operation in progress");
57
63
  const head = await readHead(this.exec, this.execOptions());
58
- if (!(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head))) {
59
- throw new Error("Local HEAD is not a descendant of the published PR head");
60
- }
64
+ const diverged = !(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head));
61
65
  const status = (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions())).stdout;
62
66
  const paths = validatePaths([...parseStatusSnapshot(status).keys()], "Pending paths");
67
+ // Clean divergence belongs to local sync; only pending work justifies committing on a diverged HEAD.
68
+ if (diverged && !paths.length) throw new Error("Local HEAD is not a descendant of the published PR head");
63
69
  this.initialHead = head;
64
70
  this.status = status;
65
- return { paths, head };
71
+ return { paths, head, originalHead: this.authority.head.oid, diverged };
66
72
  }, { agentDir: this.options.agentDir, signal: this.options.signal });
67
73
  }
68
74
 
69
- async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
75
+ async commit(pathsInput: string[], message: string): Promise<{ head: string; diverged: boolean }> {
70
76
  if (!this.initialHead || this.status === undefined || this.consumed) throw new Error("Pending changes must be inspected first");
71
77
  const paths = validatePaths(pathsInput, "Commit paths");
72
78
  if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.status!).has(path))) {
@@ -86,12 +92,17 @@ export class PullRequestWorkPublisher {
86
92
  this.consumed = true;
87
93
  await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "-A", "--", ...paths], this.execOptions());
88
94
  await runChecked(this.exec, "git", ["commit", "-m", message], this.execOptions());
89
- return { head: await readHead(this.exec, this.execOptions()) };
95
+ const head = await readHead(this.exec, this.execOptions());
96
+ this.initialHead = head;
97
+ this.status = undefined;
98
+ this.validatedHead = undefined;
99
+ this.consumed = false;
100
+ return { head, diverged: !(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head)) };
90
101
  }, { agentDir: this.options.agentDir, signal: this.options.signal });
91
102
  }
92
103
 
93
104
  async validate(checks: Check[]): Promise<{ head: string; checks: number }> {
94
- if (!this.initialHead || this.validatedHead) throw new Error("Local publication validation is unavailable");
105
+ if (!this.initialHead || this.validatedHead || this.consumed) throw new Error("Local publication validation is unavailable");
95
106
  return await withWorktreeLock(this.options.cwd, async () => {
96
107
  await this.authorityCheck();
97
108
  if (await inspectWorktree(this.exec, this.execOptions()) !== "clean") throw new Error("Unrelated pending changes remain; ask about ownership before publishing");
@@ -99,10 +110,16 @@ export class PullRequestWorkPublisher {
99
110
  if (!(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head))) {
100
111
  throw new Error("Local HEAD is not a descendant of the published PR head");
101
112
  }
102
- await runChecked(this.exec, "git", ["diff", "--check", this.authority.head.oid, head], this.execOptions());
103
- for (const check of checks) await runChecked(this.exec, check.command, check.args, this.execOptions());
104
- if (await readHead(this.exec, this.execOptions()) !== head || await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
105
- throw new Error("Validation changed local HEAD or worktree");
113
+ this.options.run?.beginChecks("publish-work", head, checks);
114
+ try {
115
+ await runChecked(this.exec, "git", ["diff", "--check", this.authority.head.oid, head], this.execOptions());
116
+ for (const check of checks) await runChecked(this.exec, check.command, check.args, this.execOptions());
117
+ await this.authorityCheck();
118
+ if (await readHead(this.exec, this.execOptions()) !== head || await inspectWorktree(this.exec, this.execOptions()) !== "clean") throw new Error("Validation changed local HEAD or worktree");
119
+ } catch (error) {
120
+ this.status = undefined;
121
+ this.options.run?.checksFailed();
122
+ throw error;
106
123
  }
107
124
  this.validatedHead = head;
108
125
  return { head, checks: checks.length + 1 };
@@ -120,6 +137,8 @@ export class PullRequestWorkPublisher {
120
137
  }
121
138
  const original = this.authority.head.oid;
122
139
  if (head === original) return { kind: "published", head };
140
+ this.options.run?.beforePush(original, head);
141
+ this.consumed = true; // An uncertain push cannot reopen inspect, commit, or validation.
123
142
  let error: unknown;
124
143
  try {
125
144
  await runChecked(this.exec, "git", ["push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
@@ -129,7 +148,7 @@ export class PullRequestWorkPublisher {
129
148
  let remote: string | null;
130
149
  try { remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref); }
131
150
  catch { throw new Error("Local publication outcome unknown; do not retry"); }
132
- if (remote === head) return { kind: "published", head };
151
+ if (remote === head) { this.options.run?.observeRemote(head); return { kind: "published", head }; }
133
152
  if (remote === original) throw new Error(`Local publication not applied${error ? `: ${String(error)}` : ""}`);
134
153
  throw new Error("Local publication outcome unknown; do not retry");
135
154
  }, { agentDir: this.options.agentDir, signal: this.options.signal });
@@ -2,6 +2,8 @@ export type PullRequestLifecycle = "open" | "merged" | "closed";
2
2
  export type CiStatus = "none" | "running" | "success" | "failure" | "failure-blocked";
3
3
  export type ReviewReadiness = "ready" | "pending";
4
4
  type PolicyReadiness = "ready" | "pending";
5
+ /** GitHub computes mergeability asynchronously; `pending` is transient, not a blocker. */
6
+ type Mergeability = "known" | "pending";
5
7
  type LocalWorktree = "clean" | "dirty";
6
8
  type LocalHeadRelation = "equal" | "behind" | "ahead" | "diverged";
7
9
 
@@ -14,6 +16,7 @@ export type PullRequestConditions = {
14
16
  ci: CiStatus;
15
17
  review: ReviewReadiness;
16
18
  policy: PolicyReadiness;
19
+ mergeability: Mergeability;
17
20
  };
18
21
 
19
22
  export type LocalMergeSafety = {
@@ -60,34 +63,29 @@ export type PullRequestDiscovery<T extends PullRequest = PullRequest> =
60
63
  | { kind: "blocked"; issue: DiscoveryIssue }
61
64
  | { kind: "inactive" };
62
65
 
63
- export type NextStep = "create" | "link-branch" | "blocked" | "none" | "publish-work" | "update-branch" | "sweep" | "fix-ci" | "merge";
64
- function localMutationSafe(local: LocalMergeSafety): boolean {
65
- return local.worktree === "clean" && local.head === "equal";
66
- }
67
-
68
- function localMergeSafe(local: LocalMergeSafety): boolean {
69
- return local.worktree === "clean" && (local.head === "equal" || local.head === "behind");
70
- }
66
+ export type NextStep =
67
+ | "create" | "link-branch" | "blocked" | "none"
68
+ | "sync-local" | "publish-work" | "refresh" | "update-branch" | "sweep" | "fix-ci" | "wait-ci" | "merge";
71
69
 
70
+ /**
71
+ * Priority ladder for an open pull request. Local state is normalized first
72
+ * (sync, then publish), so every later route sees a clean HEAD equal to the PR head.
73
+ */
72
74
  export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<NextStep, "create" | "link-branch" | "blocked"> {
73
75
  const { lifecycle, conditions, local } = pullRequest;
74
76
  if (lifecycle !== "open" || conditions.draft) return "none";
75
- if (local.worktree === "dirty" && (local.head === "behind" || local.head === "diverged")) return "none";
77
+ if (local.head === "behind") return "sync-local";
78
+ // Dirty diverged work is committed by publish-work first; the committed branch then syncs.
79
+ if (local.head === "diverged") return local.worktree === "clean" ? "sync-local" : "publish-work";
76
80
  if (local.worktree === "dirty" || local.head === "ahead") return "publish-work";
77
- if (conditions.conflict) {
78
- return localMutationSafe(local) ? "update-branch" : "none";
79
- }
80
- if (conditions.ci === "failure") return localMutationSafe(local) ? "fix-ci" : "none";
81
+ if (conditions.mergeability === "pending") return "refresh";
82
+ if (conditions.conflict || conditions.baseUpdateRequired) return "update-branch";
83
+ if (conditions.ci === "failure") return "fix-ci";
81
84
  if (conditions.ci === "failure-blocked") return "none";
82
- if (conditions.changesRequested || conditions.unresolvedThreads > 0) {
83
- return localMutationSafe(local) ? "sweep" : "none";
84
- }
85
- if (
86
- conditions.review === "pending" ||
87
- conditions.policy === "pending" ||
88
- !localMergeSafe(local) ||
89
- conditions.ci === "running"
90
- ) return "none";
85
+ if (conditions.changesRequested || conditions.unresolvedThreads > 0) return "sweep";
86
+ // Running checks usually leave merge policy BLOCKED, so the wait decision comes before the policy gate.
87
+ if (conditions.ci === "running") return conditions.review === "pending" ? "none" : "wait-ci";
88
+ if (conditions.review === "pending" || conditions.policy === "pending") return "none";
91
89
  return "merge";
92
90
  }
93
91
 
@@ -0,0 +1,107 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { createConfigStore } from "@henryqw/pi-config-store";
3
+ import { isRecord } from "./pr-execution.ts";
4
+
5
+ export type PrCheck = { command: string; args: string[] };
6
+ export type MergeMethod = "squash" | "merge" | "rebase";
7
+ export const MERGE_METHODS: readonly MergeMethod[] = ["squash", "merge", "rebase"];
8
+ export type PrPolicy = { maxPublicationCycles: number; maxRepairAttempts: number; ciPollSeconds: number; ciWaitMinutes: number; mergeMethod: MergeMethod };
9
+ export const DEFAULT_PR_POLICY: PrPolicy = { maxPublicationCycles: 3, maxRepairAttempts: 3, ciPollSeconds: 30, ciWaitMinutes: 10, mergeMethod: "squash" };
10
+ type NumericPolicyKey = Exclude<keyof PrPolicy, "mergeMethod">;
11
+ const NUMERIC_KEYS: readonly NumericPolicyKey[] = ["maxPublicationCycles", "maxRepairAttempts", "ciPollSeconds", "ciWaitMinutes"];
12
+ /** `ciWaitMinutes: 0` disables waiting for running CI; every other limit must be positive. */
13
+ const ZERO_ALLOWED: ReadonlySet<NumericPolicyKey> = new Set(["ciWaitMinutes"]);
14
+ /** Node replaces a timer delay above 2^31-1 ms with 1 ms, which would turn a long poll into a tight loop. */
15
+ const MAX_CI_POLL_SECONDS = 2_147_483;
16
+
17
+ export function loadPrPolicy(agentDir?: string): PrPolicy {
18
+ const config = createConfigStore({
19
+ extensionId: "pi-pr", agentDir, defaults: () => ({ ...DEFAULT_PR_POLICY }),
20
+ parse(value: unknown): PrPolicy {
21
+ if (!isRecord(value) || Object.keys(value).some((key) => !Object.hasOwn(DEFAULT_PR_POLICY, key))) throw new Error(`Expected only ${Object.keys(DEFAULT_PR_POLICY).join(", ")}`);
22
+ const policy = { ...DEFAULT_PR_POLICY };
23
+ if (value.mergeMethod !== undefined) {
24
+ if (!MERGE_METHODS.includes(value.mergeMethod as MergeMethod)) throw new Error(`mergeMethod must be one of ${MERGE_METHODS.join(", ")}`);
25
+ policy.mergeMethod = value.mergeMethod as MergeMethod;
26
+ }
27
+ for (const key of NUMERIC_KEYS) {
28
+ if (value[key] !== undefined) {
29
+ const minimum = ZERO_ALLOWED.has(key) ? 0 : 1;
30
+ if (!Number.isSafeInteger(value[key]) || (value[key] as number) < minimum) throw new Error(`${key} must be an integer of at least ${minimum}`);
31
+ if (key === "ciPollSeconds" && (value[key] as number) > MAX_CI_POLL_SECONDS) throw new Error(`ciPollSeconds must be at most ${MAX_CI_POLL_SECONDS}`);
32
+ policy[key] = value[key] as number;
33
+ }
34
+ }
35
+ return policy;
36
+ },
37
+ });
38
+ try { return config.loadSync().value; }
39
+ catch (error) { throw new Error(`PR configuration is preserved at ${config.path}: ${error instanceof Error ? error.message : String(error)}`); }
40
+ }
41
+
42
+ /** One explicit /pr invocation; helper resumes never replenish these limits. */
43
+ export class PrRun {
44
+ private readonly completed = new Map<string, Set<string | null>>();
45
+ private readonly checks = new Map<string, PrCheck[]>();
46
+ private readonly executed = new Map<string, PrCheck[][]>();
47
+ private lastRemote: string | null | undefined;
48
+ private publications = 0;
49
+ private repairs = 0;
50
+ private ciWaitedMs = 0;
51
+
52
+ readonly policy: PrPolicy;
53
+
54
+ constructor(policy: Partial<PrPolicy> = {}) {
55
+ this.policy = { ...DEFAULT_PR_POLICY, ...policy };
56
+ }
57
+
58
+ /** Reserve one CI poll interval from the invocation's wait budget; null once the budget is spent. */
59
+ consumeCiWait(): number | null {
60
+ const remaining = this.policy.ciWaitMinutes * 60_000 - this.ciWaitedMs;
61
+ if (remaining <= 0) return null;
62
+ const interval = Math.min(this.policy.ciPollSeconds * 1_000, remaining);
63
+ this.ciWaitedMs += interval;
64
+ return interval;
65
+ }
66
+
67
+ observeRemote(head: string | null): void {
68
+ if (this.lastRemote !== undefined && this.lastRemote !== head) this.publications += 1;
69
+ this.lastRemote = head;
70
+ }
71
+
72
+ beforePush(original: string | null, head: string): void {
73
+ this.observeRemote(original);
74
+ if (original !== head && this.publications >= this.policy.maxPublicationCycles) throw new Error(`Publication budget stop: limit ${this.policy.maxPublicationCycles} reached; run /pr again`);
75
+ }
76
+
77
+ complete(route: string, entryHead: string | null): void {
78
+ const heads = this.completed.get(route) ?? new Set<string | null>();
79
+ heads.add(entryHead);
80
+ this.completed.set(route, heads);
81
+ }
82
+
83
+ hasCompleted(route: string, head: string | null): boolean {
84
+ return this.completed.get(route)?.has(head) ?? false;
85
+ }
86
+
87
+ requireFreshChecks(route: string, head: string, checks: PrCheck[]): void {
88
+ if (this.repairs >= this.policy.maxRepairAttempts) throw new Error(`Repair budget stop: limit ${this.policy.maxRepairAttempts} reached; run /pr again`);
89
+ const frozen = this.checks.get(route);
90
+ if (frozen && !isDeepStrictEqual(frozen, checks)) throw new Error("Validation checks are frozen for this /pr; failing checks cannot be dropped");
91
+ if (this.hasExecuted(head, checks)) throw new Error("No progress: checks already executed on this HEAD in this /pr; repair the code or run /pr again");
92
+ }
93
+
94
+ beginChecks(route: string, head: string, checks: PrCheck[]): void {
95
+ this.requireFreshChecks(route, head, checks);
96
+ this.checks.set(route, structuredClone(checks));
97
+ const sets = this.executed.get(head) ?? [];
98
+ sets.push(structuredClone(checks));
99
+ this.executed.set(head, sets);
100
+ }
101
+
102
+ hasExecuted(head: string, checks: PrCheck[]): boolean {
103
+ return this.executed.get(head)?.some((set) => isDeepStrictEqual(set, checks)) ?? false;
104
+ }
105
+
106
+ checksFailed(): void { this.repairs += 1; }
107
+ }
@@ -0,0 +1,92 @@
1
+ import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
2
+ import { cloneCurrentPullRequest, loadCurrentPullRequest, samePullRequestSnapshot, type CurrentPullRequest } from "./pr-github.ts";
3
+ import {
4
+ extensionExecApi,
5
+ inspectWorktreeState,
6
+ isAncestor,
7
+ parseSingleOutputLine,
8
+ readHead,
9
+ requiredOid,
10
+ runChecked,
11
+ withWorktreeLock,
12
+ } from "./pr-execution.ts";
13
+
14
+ export type SyncLocalHeadOptions = {
15
+ cwd: string;
16
+ authority: CurrentPullRequest;
17
+ signal?: AbortSignal;
18
+ agentDir?: string;
19
+ exec?: Exec;
20
+ loadCurrentPullRequest?: typeof loadCurrentPullRequest;
21
+ };
22
+
23
+ export type SyncLocalHeadResult = { kind: "fast-forwarded" | "rebased" | "unchanged"; head: string };
24
+
25
+ /**
26
+ * Bring the local branch up to the published PR head without pushing.
27
+ * Behind: fast-forward (Git refuses when local changes overlap). Diverged and
28
+ * clean: rebase local commits onto the PR head; a conflict aborts back to the
29
+ * original HEAD. Never stashes, resets, or rewrites the remote.
30
+ */
31
+ export async function syncLocalHead(options: SyncLocalHeadOptions): Promise<SyncLocalHeadResult> {
32
+ const authority = cloneCurrentPullRequest(options.authority);
33
+ if (authority.lifecycle !== "open" || authority.target.provenance !== "configured") {
34
+ throw new Error("Local sync requires a configured open pull request");
35
+ }
36
+ const exec = options.exec ?? spawnBounded;
37
+ const load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
38
+ const execOptions: ExecOptions = { cwd: options.cwd, signal: options.signal };
39
+ const target = authority.head.oid;
40
+ return await withWorktreeLock(options.cwd, async () => {
41
+ const discovery = await load(extensionExecApi(exec, options.cwd, options.signal), { cwd: options.cwd, signal: options.signal });
42
+ if (discovery.kind !== "current" || !samePullRequestSnapshot(authority, discovery.pullRequest)) {
43
+ throw new Error("Local sync cancelled: PR identity or remote head changed");
44
+ }
45
+ const assertLocalContext = async (expectedHead?: string): Promise<string> => {
46
+ const branch = parseSingleOutputLine((await runChecked(exec, "git", ["branch", "--show-current"], execOptions)).stdout, "current branch");
47
+ if (branch !== authority.target.branch) throw new Error("Local sync cancelled: current branch changed");
48
+ const head = await readHead(exec, execOptions);
49
+ if (expectedHead !== undefined && head !== expectedHead) throw new Error("Local sync cancelled: local HEAD moved during the fetch");
50
+ return head;
51
+ };
52
+ const head = await assertLocalContext();
53
+ if (await inspectWorktreeState(exec, execOptions) === "operation") throw new Error("Local sync cancelled: a Git operation is in progress");
54
+ await runChecked(exec, "git", [
55
+ "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", authority.headFetchSource, target,
56
+ ], execOptions);
57
+ await runChecked(exec, "git", ["cat-file", "-e", `${target}^{commit}`], execOptions);
58
+ // The fetch is the only slow step; branch, HEAD, and worktree are re-read right before any mutation.
59
+ await assertLocalContext(head);
60
+ const worktree = await inspectWorktreeState(exec, execOptions);
61
+ if (worktree === "operation") throw new Error("Local sync cancelled: a Git operation is in progress");
62
+ if (head === target || await isAncestor(exec, execOptions, target, head)) return { kind: "unchanged", head };
63
+ if (await isAncestor(exec, execOptions, head, target)) {
64
+ const merge = await exec("git", ["merge", "--ff-only", "--no-autostash", target], execOptions);
65
+ if (merge.killed) throw new Error("git merge --ff-only was killed; its outcome is unknown");
66
+ if (merge.code !== 0) {
67
+ throw new Error(`Local changes overlap the PR head update; commit or move them, then run /pr: ${merge.stderr.trim() || merge.stdout.trim()}`);
68
+ }
69
+ return { kind: "fast-forwarded", head: await verifySynced(exec, execOptions, target) };
70
+ }
71
+ if (worktree !== "clean") throw new Error("Local sync cancelled: diverged local commits need a clean worktree");
72
+ const mergeBase = requiredOid(parseSingleOutputLine((await runChecked(exec, "git", ["merge-base", target, head], execOptions)).stdout, "sync fork point"), "sync fork point");
73
+ const mergeCommits = (await runChecked(exec, "git", ["rev-list", "--max-count=1", "--min-parents=2", `${mergeBase}..${head}`], execOptions)).stdout.trim();
74
+ if (mergeCommits) throw new Error("Local sync cannot rebase local merge commits onto the PR head; rebase manually");
75
+ const rebase = await exec("git", ["-c", "core.editor=true", "-c", "rebase.updateRefs=false", "rebase", "--no-autostash", target], execOptions);
76
+ if (rebase.killed) throw new Error("git rebase was killed; its outcome is unknown");
77
+ if (rebase.code !== 0) {
78
+ await runChecked(exec, "git", ["rebase", "--abort"], execOptions);
79
+ if (await readHead(exec, execOptions) !== head) throw new Error("Local sync aborted a conflicting rebase but HEAD did not return to the original commit");
80
+ throw new Error(`Local commits conflict with the PR head; rebase manually: ${rebase.stderr.trim() || rebase.stdout.trim()}`);
81
+ }
82
+ return { kind: "rebased", head: await verifySynced(exec, execOptions, target) };
83
+ }, { agentDir: options.agentDir, signal: options.signal });
84
+ }
85
+
86
+ async function verifySynced(exec: Exec, execOptions: ExecOptions, target: string): Promise<string> {
87
+ const head = await readHead(exec, execOptions);
88
+ if (!(await isAncestor(exec, execOptions, target, head)) || await inspectWorktreeState(exec, execOptions) === "operation") {
89
+ throw new Error("Local sync did not leave a branch descended from the PR head");
90
+ }
91
+ return head;
92
+ }
@@ -87,6 +87,8 @@ function footerStatus(input: PrDisplayInput): Pick<PrFooter, "text" | "color"> {
87
87
  if (conditions.unresolvedThreads > 0) return { text: `${conditions.unresolvedThreads} unresolved`, color: "warning" };
88
88
  if (conditions.changesRequested) return { text: "changes requested", color: "error" };
89
89
  if (conditions.ci === "running") return { text: "CI running", color: "warning" };
90
+ if (conditions.mergeability === "pending") return { text: "mergeability pending", color: "warning" };
91
+ if (input.local.head === "behind" || input.local.head === "diverged") return { text: `local ${input.local.head}`, color: "warning" };
90
92
  if (derivePullRequestNextStep(input) === "merge") return { text: "merge-ready", color: "success" };
91
93
  if (input.approved) return { text: "approved", color: "success" };
92
94
  return { text: "open", color: "accent" };
@@ -94,14 +96,20 @@ function footerStatus(input: PrDisplayInput): Pick<PrFooter, "text" | "color"> {
94
96
 
95
97
  function widgetText(nextStep: NextStep): string | undefined {
96
98
  switch (nextStep) {
99
+ case "sync-local":
100
+ return "Run /pr to sync the local branch with the PR head";
97
101
  case "publish-work":
98
102
  return "Run /pr to publish local work";
103
+ case "refresh":
104
+ return "Run /pr to re-read GitHub mergeability";
99
105
  case "update-branch":
100
- return "Run /pr to resolve merge conflict";
106
+ return "Run /pr to update the branch";
101
107
  case "sweep":
102
108
  return "Run /pr to address review feedback";
103
109
  case "fix-ci":
104
110
  return "Run /pr to fix CI";
111
+ case "wait-ci":
112
+ return "Run /pr to wait for CI, then continue";
105
113
  case "merge":
106
114
  return "Run /pr to merge pull request";
107
115
  case "link-branch":
@@ -1,3 +1,4 @@
1
+ import type { PrRun } from "./pr-run.ts";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { createHash } from "node:crypto";
3
4
  import { join, resolve } from "node:path";
@@ -39,11 +40,22 @@ type UpdateBranchState = {
39
40
  type UpdateBranchResult =
40
41
  | { kind: "verified"; head: string; fastForward: boolean }
41
42
  | { kind: "conflict"; paths: string[] }
42
- | { kind: "published"; head: string };
43
+ | { kind: "published"; head: string }
44
+ | { kind: "stale"; reason: string; authority: CurrentPullRequest };
45
+
46
+ // Only pre-mutation preflight can authorize a fresh routing snapshot, never a replay.
47
+ class StaleRebaseRoute extends Error {
48
+ readonly authority: CurrentPullRequest;
49
+ constructor(reason: string, authority: CurrentPullRequest) {
50
+ super(reason);
51
+ this.authority = authority;
52
+ }
53
+ }
43
54
 
44
55
  type Load = typeof loadCurrentPullRequest;
45
56
 
46
57
  export type UpdateBranchOptions = {
58
+ run?: PrRun;
47
59
  cwd: string;
48
60
  authority: CurrentPullRequest;
49
61
  signal?: AbortSignal;
@@ -54,7 +66,7 @@ export type UpdateBranchOptions = {
54
66
 
55
67
  function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): boolean {
56
68
  return samePullRequestSnapshot(frozen, fresh) && frozen.base.oid === fresh.base.oid &&
57
- fresh.lifecycle === "open" && fresh.conditions.conflict;
69
+ fresh.lifecycle === "open" && (fresh.conditions.conflict || fresh.conditions.baseUpdateRequired);
58
70
  }
59
71
 
60
72
  type RebaseRecovery = { version: 1; phase: "pending" | "verified" | "published"; identity: string;
@@ -112,6 +124,7 @@ export async function inspectVerifiedRebaseRecovery(pr: CurrentPullRequest, opti
112
124
  export class PullRequestBranchUpdater {
113
125
  readonly state: UpdateBranchState = { phase: "ready" };
114
126
 
127
+ private readonly run?: PrRun;
115
128
  private readonly cwd: string;
116
129
  private readonly authority: CurrentPullRequest;
117
130
  private readonly signal?: AbortSignal;
@@ -126,6 +139,7 @@ export class PullRequestBranchUpdater {
126
139
  throw new TypeError("Branch update requires a configured open pull request");
127
140
  }
128
141
  this.cwd = options.cwd;
142
+ this.run = options.run;
129
143
  this.authority = cloneCurrentPullRequest(options.authority);
130
144
  this.signal = options.signal;
131
145
  this.agentDir = options.agentDir;
@@ -145,18 +159,18 @@ export class PullRequestBranchUpdater {
145
159
  return { cwd: this.cwd, signal: this.signal };
146
160
  }
147
161
 
148
- private async freshAuthority(expectedHead: string, requireClean: boolean, published = false): Promise<CurrentPullRequest> {
162
+ private async freshAuthority(expectedHead: string, requireClean: boolean, published = false, beforeRebase = false): Promise<CurrentPullRequest> {
149
163
  const discovery = await this.load(this.pi(), this.context());
150
- const comparison = published ? {
164
+ if (discovery.kind !== "current") throw new Error("Branch update cancelled: pull request is no longer current");
165
+ const fresh = discovery.pullRequest;
166
+ const comparison = beforeRebase || published ? {
151
167
  ...this.authority,
152
- head: { ...this.authority.head, oid: expectedHead },
153
- target: { ...this.authority.target, remoteOid: expectedHead },
168
+ head: { ...this.authority.head, oid: beforeRebase ? fresh.head.oid : expectedHead },
169
+ target: { ...this.authority.target, remoteOid: beforeRebase ? fresh.target.remoteOid : expectedHead },
154
170
  } : this.authority;
155
- if (discovery.kind !== "current" || (published
156
- ? !samePullRequestSnapshot(comparison, discovery.pullRequest) ||
157
- this.authority.base.oid !== discovery.pullRequest.base.oid || discovery.pullRequest.lifecycle !== "open"
158
- : !sameAuthority(this.authority, discovery.pullRequest))) {
159
- throw new Error("Branch update cancelled: frozen pull request authority changed");
171
+ if (!samePullRequestSnapshot(comparison, fresh) || fresh.lifecycle !== "open" ||
172
+ (!beforeRebase && (this.authority.base.oid !== fresh.base.oid || !published && !(fresh.conditions.conflict || fresh.conditions.baseUpdateRequired)))) {
173
+ throw new Error("Branch update cancelled: frozen pull request authority changed (identity, lifecycle, destination, or post-rebase authority)");
160
174
  }
161
175
  const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout, "current branch");
162
176
  if (branch !== this.authority.target.branch) throw new Error("Branch update cancelled: current branch changed");
@@ -164,8 +178,39 @@ export class PullRequestBranchUpdater {
164
178
  throw new Error("Branch update cancelled: worktree is dirty or a Git operation is in progress");
165
179
  }
166
180
  const head = await readHead(this.exec, this.execOptions());
167
- if (head !== expectedHead) throw new Error("Branch update cancelled: local HEAD changed");
168
- return discovery.pullRequest;
181
+ const reasons: string[] = [];
182
+ if (beforeRebase) {
183
+ if (fresh.target.remoteOid !== this.authority.target.remoteOid || fresh.head.oid !== expectedHead) {
184
+ if (this.authority.target.remoteOid !== expectedHead || fresh.target.remoteOid !== fresh.head.oid ||
185
+ head !== fresh.head.oid || !(await isAncestor(this.exec, this.execOptions(), expectedHead, head))) {
186
+ throw new Error("Branch update cancelled: frozen pull request authority changed; head/remote drift is not a clean, already-published fast-forward");
187
+ }
188
+ reasons.push("Published PR head advanced by fast-forward");
189
+ } else if (head !== expectedHead && await isAncestor(this.exec, this.execOptions(), expectedHead, head)) {
190
+ reasons.push("Local HEAD is ahead of the frozen PR head");
191
+ } else if (head !== expectedHead) {
192
+ throw new Error("Branch update cancelled: local HEAD does not match the expected head");
193
+ }
194
+ if (fresh.base.oid !== this.authority.base.oid) reasons.push("Base OID changed");
195
+ if (!fresh.conditions.conflict && !fresh.conditions.baseUpdateRequired) {
196
+ reasons.push(this.authority.conditions.conflict ? "PR conflict cleared" : "Required base update cleared");
197
+ }
198
+ } else if (head !== expectedHead) {
199
+ throw new Error("Branch update cancelled: local HEAD does not match the expected head");
200
+ }
201
+ if (reasons.length) {
202
+ if (fresh.target.remoteOid !== fresh.head.oid) {
203
+ throw new Error("Branch update cancelled: PR head and remote lease disagree");
204
+ }
205
+ const path = recoveryPath(this.cwd, this.authority, this.agentDir);
206
+ const recovery = await readRecovery(path, this.signal);
207
+ if (recovery && recovery.phase !== "published") {
208
+ throw new Error(`Branch update recovery requires reconciliation; record preserved at ${path}; do not replan or replay`);
209
+ }
210
+ this.signal?.throwIfAborted();
211
+ throw new StaleRebaseRoute(`${reasons.join("; ")}; cancelled before rebase or publication`, cloneCurrentPullRequest(fresh));
212
+ }
213
+ return fresh;
169
214
  }
170
215
 
171
216
  private async writeRecovery(phase: RebaseRecovery["phase"], verified: string | null): Promise<void> {
@@ -234,7 +279,7 @@ export class PullRequestBranchUpdater {
234
279
  }
235
280
  if (this.state.phase !== "ready") throw new Error("Branch conflict rebase action was already consumed");
236
281
  return await withWorktreeLock(this.cwd, async () => {
237
- await this.freshAuthority(this.authority.head.oid, true);
282
+ await this.freshAuthority(this.authority.head.oid, true, false, true);
238
283
  const source = await resolveRepositoryFetchSource(this.exec, this.execOptions(), {
239
284
  host: this.authority.host,
240
285
  repository: this.authority.base.repository,
@@ -244,7 +289,7 @@ export class PullRequestBranchUpdater {
244
289
  "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", source, this.authority.base.oid,
245
290
  ], this.execOptions());
246
291
  await runChecked(this.exec, "git", ["cat-file", "-e", `${this.authority.base.oid}^{commit}`], this.execOptions());
247
- await this.freshAuthority(this.authority.head.oid, true);
292
+ await this.freshAuthority(this.authority.head.oid, true, false, true);
248
293
  if (await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, this.authority.head.oid)) {
249
294
  return await this.verifyRebase();
250
295
  }
@@ -255,7 +300,7 @@ export class PullRequestBranchUpdater {
255
300
  "rev-list", "--max-count=1", "--min-parents=2", `${mergeBase}..${this.authority.head.oid}`,
256
301
  ], this.execOptions())).stdout.trim();
257
302
  if (mergeCommits) throw new Error("Branch update cannot rebase a branch with merge commits; preserve its resolutions manually");
258
- await this.freshAuthority(this.authority.head.oid, true);
303
+ await this.freshAuthority(this.authority.head.oid, true, false, true);
259
304
  await this.writeRecovery("pending", null);
260
305
  const result = await this.exec("git", ["-c", "core.editor=true", "-c", "rebase.backend=merge", "-c", "rebase.updateRefs=false", "rebase", "--no-autostash", "--onto", this.authority.base.oid, mergeBase], this.execOptions());
261
306
  if (result.killed) throw new Error("git rebase was killed; its outcome is unknown");
@@ -265,7 +310,12 @@ export class PullRequestBranchUpdater {
265
310
  } catch (error) {
266
311
  throw new Error(`git rebase failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}; ${error instanceof Error ? error.message : String(error)}`);
267
312
  }
268
- }, { agentDir: this.agentDir, signal: this.signal });
313
+ }, { agentDir: this.agentDir, signal: this.signal }).catch((error: unknown) => {
314
+ if (!(error instanceof StaleRebaseRoute)) throw error;
315
+ this.signal?.throwIfAborted();
316
+ this.state.phase = "blocked";
317
+ return { kind: "stale" as const, reason: error.message, authority: error.authority };
318
+ });
269
319
  }
270
320
 
271
321
  async continue(resolvedPaths: readonly string[]): Promise<UpdateBranchResult> {
@@ -277,17 +327,25 @@ export class PullRequestBranchUpdater {
277
327
  throw new Error("Resolved paths must include every original conflict path");
278
328
  }
279
329
  return await withWorktreeLock(this.cwd, async () => {
280
- const discovery = await this.load(this.pi(), this.context());
330
+ const assertContext = async () => {
331
+ const path = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--git-path", "rebase-merge/head-name"], this.execOptions())).stdout, "rebase branch marker");
332
+ const marker = await readFile(resolve(this.cwd, path), "utf8").catch((error) => {
333
+ if (error.code === "ENOENT") throw new Error("Branch rebase context changed", { cause: error });
334
+ throw error;
335
+ });
336
+ if (marker.trim() !== `refs/heads/${this.authority.target.branch}` ||
337
+ await readHead(this.exec, this.execOptions()) !== this.state.conflict!.head) {
338
+ throw new Error("Branch rebase context changed");
339
+ }
340
+ };
341
+ await assertContext();
342
+ const discovery = await this.load(this.pi(), { ...this.context(), rebaseBranch: this.authority.target.branch });
281
343
  if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
282
344
  throw new Error("Branch rebase authority changed");
283
345
  }
284
- const path = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--git-path", "rebase-merge/head-name"], this.execOptions())).stdout, "rebase branch marker");
285
- if ((await readFile(resolve(this.cwd, path), "utf8")).trim() !== `refs/heads/${this.authority.target.branch}` ||
286
- await readHead(this.exec, this.execOptions()) !== this.state.conflict!.head) {
287
- throw new Error("Branch rebase context changed");
288
- }
289
346
  const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
290
347
  assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
348
+ await assertContext();
291
349
  this.state.phase = "blocked";
292
350
  await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "--", ...paths], this.execOptions());
293
351
  const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
@@ -314,11 +372,13 @@ export class PullRequestBranchUpdater {
314
372
  throw new Error("Verified branch no longer contains the frozen base");
315
373
  }
316
374
  if (remoteBefore === head) {
375
+ this.run?.observeRemote(head);
317
376
  await this.writeRecovery("published", head);
318
377
  this.state.phase = "published";
319
378
  return { kind: "published", head };
320
379
  }
321
380
  await this.freshAuthority(head, true);
381
+ this.run?.beforePush(original, head);
322
382
  this.state.phase = "blocked";
323
383
  let pushError: unknown;
324
384
  try {
@@ -337,6 +397,7 @@ export class PullRequestBranchUpdater {
337
397
  throw new Error("Rebase push outcome is unknown; do not retry");
338
398
  }
339
399
  if (remote === head) {
400
+ this.run?.observeRemote(head);
340
401
  await this.writeRecovery("published", head);
341
402
  this.state.phase = "published";
342
403
  return { kind: "published", head };