@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.
- package/README.md +42 -13
- package/docs/pr-routing.svg +137 -88
- package/extensions/pr-ci.ts +49 -14
- package/extensions/pr-command.ts +76 -24
- package/extensions/pr-comment-sweep.ts +172 -34
- package/extensions/pr-create.ts +6 -0
- package/extensions/pr-github.ts +10 -3
- package/extensions/pr-merge.ts +4 -1
- package/extensions/pr-publish-work.ts +37 -18
- package/extensions/pr-routing.ts +20 -22
- package/extensions/pr-run.ts +107 -0
- package/extensions/pr-sync.ts +92 -0
- package/extensions/pr-ui.ts +9 -1
- package/extensions/pr-update-branch.ts +84 -23
- package/extensions/pr.ts +132 -56
- package/package.json +4 -4
- package/skills/pi-pr-comment-sweep/SKILL.md +6 -4
- package/skills/pi-pr-comment-sweep/references/recovery.md +7 -1
- package/skills/pi-pr-fix-ci/SKILL.md +13 -2
- package/skills/pi-pr-publish-work/SKILL.md +4 -2
- package/skills/pi-pr-update-branch/SKILL.md +4 -4
|
@@ -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
|
-
/**
|
|
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"
|
|
24
|
-
throw new Error("Local publication requires a branch
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 });
|
package/extensions/pr-routing.ts
CHANGED
|
@@ -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 =
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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.
|
|
78
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/extensions/pr-ui.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
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 };
|