@henryqw/pi-pr 6.0.8 → 6.2.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.
@@ -1,3 +1,7 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { join, resolve } from "node:path";
4
+ import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
1
5
  import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
2
6
  import {
3
7
  cloneCurrentPullRequest,
@@ -13,6 +17,8 @@ import {
13
17
  isAncestor,
14
18
  parseNulPaths,
15
19
  parseSingleOutputLine,
20
+ inspectGitOperation,
21
+ isRecord,
16
22
  readHead,
17
23
  readRemoteOid,
18
24
  requiredOid,
@@ -27,7 +33,7 @@ type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "publ
27
33
  type UpdateBranchState = {
28
34
  phase: UpdateBranchPhase;
29
35
  verifiedHead?: string;
30
- conflict?: { paths: string[]; statusBaseline: string };
36
+ conflict?: { paths: string[]; statusBaseline: string; head: string };
31
37
  };
32
38
 
33
39
  type UpdateBranchResult =
@@ -48,7 +54,59 @@ export type UpdateBranchOptions = {
48
54
 
49
55
  function sameAuthority(frozen: CurrentPullRequest, fresh: CurrentPullRequest): boolean {
50
56
  return samePullRequestSnapshot(frozen, fresh) && frozen.base.oid === fresh.base.oid &&
51
- fresh.lifecycle === "open" && (fresh.conditions.baseUpdateRequired || fresh.conditions.conflict);
57
+ fresh.lifecycle === "open" && fresh.conditions.conflict;
58
+ }
59
+
60
+ type RebaseRecovery = { version: 1; phase: "pending" | "verified" | "published"; identity: string;
61
+ original: string; remote: string; verified: string | null };
62
+
63
+ function recoveryIdentity(pr: CurrentPullRequest): string {
64
+ return JSON.stringify([pr.id, pr.number, pr.url.href, pr.host, pr.base.repository, pr.base.ref, pr.base.oid,
65
+ pr.head.repository, pr.head.ref, pr.target.provenance, pr.target.branch, pr.target.remote,
66
+ pr.target.ref, pr.target.repository, pr.target.host, pr.target.fetchSource]);
67
+ }
68
+
69
+ function recoveryPath(cwd: string, pr: CurrentPullRequest, agentDir?: string): string {
70
+ const key = createHash("sha256").update(`${resolve(cwd)}\n${pr.url.href}`).digest("hex");
71
+ return join(extensionConfigDir("pi-pr", agentDir), "update-branch", `${key}.json`);
72
+ }
73
+
74
+ async function readRecovery(path: string, signal?: AbortSignal): Promise<RebaseRecovery | null> {
75
+ let raw: string;
76
+ try { raw = await readTextFileBounded(path, 4096, { signal }); }
77
+ catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
79
+ throw error;
80
+ }
81
+ let record: unknown;
82
+ try { record = JSON.parse(raw); } catch { throw new Error(`Invalid branch update recovery is preserved at ${path}`); }
83
+ if (!isRecord(record) || Object.keys(record).sort().join(",") !== "identity,original,phase,remote,verified,version" ||
84
+ record.version !== 1 || !["pending", "verified", "published"].includes(record.phase as string) ||
85
+ typeof record.identity !== "string" || record.identity.length > 2048 ||
86
+ typeof record.original !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.original) ||
87
+ typeof record.remote !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.remote) ||
88
+ (record.verified !== null && (typeof record.verified !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(record.verified))) ||
89
+ (record.phase === "pending" && record.verified !== null) ||
90
+ (record.phase !== "pending" && record.verified === null)) {
91
+ throw new Error(`Invalid branch update recovery is preserved at ${path}`);
92
+ }
93
+ return record as RebaseRecovery;
94
+ }
95
+
96
+ function assertVerifiedRecovery(record: RebaseRecovery, pr: CurrentPullRequest, path: string): void {
97
+ if (record.identity !== recoveryIdentity(pr) || record.remote !== record.original ||
98
+ ![record.remote, record.verified].includes(pr.head.oid) || pr.target.remoteOid !== pr.head.oid) {
99
+ throw new Error(`Branch update recovery authority changed; record preserved at ${path}`);
100
+ }
101
+ if (record.phase === "pending") throw new Error(`Branch rebase outcome is unverified; recover manually from ${path}; do not replay it`);
102
+ }
103
+
104
+ export async function inspectVerifiedRebaseRecovery(pr: CurrentPullRequest, options: { cwd: string; agentDir?: string; signal?: AbortSignal }): Promise<boolean> {
105
+ const path = recoveryPath(options.cwd, pr, options.agentDir);
106
+ const record = await readRecovery(path, options.signal);
107
+ if (!record || record.phase === "published") return false;
108
+ assertVerifiedRecovery(record, pr, path);
109
+ return true;
52
110
  }
53
111
 
54
112
  export class PullRequestBranchUpdater {
@@ -60,6 +118,8 @@ export class PullRequestBranchUpdater {
60
118
  private readonly agentDir?: string;
61
119
  private readonly exec: Exec;
62
120
  private readonly load: Load;
121
+ private recovered = false;
122
+ private recoveredRecord?: RebaseRecovery;
63
123
 
64
124
  constructor(options: UpdateBranchOptions) {
65
125
  if (!options.authority || options.authority.target.provenance !== "configured") {
@@ -85,9 +145,17 @@ export class PullRequestBranchUpdater {
85
145
  return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
86
146
  }
87
147
 
88
- private async freshAuthority(expectedHead: string, requireClean: boolean): Promise<CurrentPullRequest> {
148
+ private async freshAuthority(expectedHead: string, requireClean: boolean, published = false): Promise<CurrentPullRequest> {
89
149
  const discovery = await this.load(this.pi(), this.context());
90
- if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
150
+ const comparison = published ? {
151
+ ...this.authority,
152
+ head: { ...this.authority.head, oid: expectedHead },
153
+ target: { ...this.authority.target, remoteOid: expectedHead },
154
+ } : 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))) {
91
159
  throw new Error("Branch update cancelled: frozen pull request authority changed");
92
160
  }
93
161
  const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout, "current branch");
@@ -100,39 +168,71 @@ export class PullRequestBranchUpdater {
100
168
  return discovery.pullRequest;
101
169
  }
102
170
 
103
- private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
104
- const head = await readHead(this.exec, this.execOptions());
105
- const parentsOutput = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.execOptions())).stdout, "merge parents");
106
- const commits = parentsOutput.split(" ").map((value, index) => requiredOid(value, index === 0 ? "merged HEAD" : "merge parent"));
107
- if (commits[0] !== head) throw new Error("Branch update merge verification returned a different HEAD");
108
- let fastForward = false;
109
- if (head === this.authority.base.oid && commits.length >= 2 && await isAncestor(this.exec, this.execOptions(), originalHead, head)) {
110
- fastForward = true;
111
- } else if (commits.length !== 3 || commits[1] !== originalHead || commits[2] !== this.authority.base.oid) {
112
- throw new Error("Branch update did not produce the exact configured fast-forward or two-parent merge");
171
+ private async writeRecovery(phase: RebaseRecovery["phase"], verified: string | null): Promise<void> {
172
+ const path = recoveryPath(this.cwd, this.authority, this.agentDir);
173
+ const existing = await readRecovery(path, this.signal);
174
+ const identity = recoveryIdentity(this.authority);
175
+ const original = this.recoveredRecord?.original ?? this.authority.head.oid;
176
+ const remote = this.recoveredRecord?.remote ?? this.authority.target.remoteOid;
177
+ if (!remote) throw new Error("Branch update remote ref is absent");
178
+ if (existing && existing.phase !== "published" &&
179
+ (existing.identity !== identity || existing.original !== original || existing.remote !== remote || phase === "pending")) {
180
+ throw new Error(`Branch update recovery must be inspected before replacement: ${path}`);
113
181
  }
114
- if (await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
115
- throw new Error("Branch update merge left a dirty worktree or Git operation in progress");
182
+ await writePrivateTextFileAtomically(path, `${JSON.stringify({ version: 1, phase, identity,
183
+ original, remote, verified })}\n`, { signal: this.signal });
184
+ }
185
+
186
+ async recoveryLaunchAction(): Promise<"rebase"> {
187
+ const path = recoveryPath(this.cwd, this.authority, this.agentDir);
188
+ const recovery = await readRecovery(path, this.signal);
189
+ if (!recovery || recovery.phase === "published") return "rebase";
190
+ assertVerifiedRecovery(recovery, this.authority, path);
191
+ const head = recovery.verified!;
192
+ const branch = parseSingleOutputLine((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout, "current branch");
193
+ if (branch !== this.authority.target.branch || await inspectWorktree(this.exec, this.execOptions()) !== "clean" ||
194
+ await readHead(this.exec, this.execOptions()) !== head ||
195
+ !(await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, head))) {
196
+ throw new Error("Branch update recovery does not match a clean verified branch; do not replay the rebase");
197
+ }
198
+ this.state.phase = "verified";
199
+ this.state.verifiedHead = head;
200
+ this.recoveredRecord = recovery;
201
+ this.recovered = true;
202
+ return "rebase"; // The prompted action returns the verified result without replaying Git.
203
+ }
204
+
205
+ private async verifyRebase(): Promise<UpdateBranchResult> {
206
+ const head = await readHead(this.exec, this.execOptions());
207
+ if (await inspectWorktree(this.exec, this.execOptions()) !== "clean" ||
208
+ !(await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, head))) {
209
+ throw new Error("Rebase did not leave a clean branch based on the frozen base");
116
210
  }
211
+ await this.writeRecovery("verified", head);
117
212
  this.state.phase = "verified";
118
213
  this.state.verifiedHead = head;
119
214
  delete this.state.conflict;
120
- return { head, fastForward };
215
+ return { kind: "verified", head, fastForward: false };
121
216
  }
122
217
 
123
- private async captureConflict(): Promise<string[]> {
124
- const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
125
- if (mergeHead !== this.authority.base.oid) throw new Error("Failed merge did not retain the frozen base");
218
+ private async captureConflict(): Promise<UpdateBranchResult> {
219
+ if (await inspectGitOperation(this.exec, this.execOptions()) === null) {
220
+ throw new Error("Failed rebase did not retain an in-progress Git operation");
221
+ }
126
222
  const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
127
- if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
223
+ if (!paths.length) throw new Error("git rebase failed without bounded unmerged paths");
128
224
  const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
129
225
  this.state.phase = "conflict-awaiting-user";
130
- this.state.conflict = { paths, statusBaseline: status.stdout };
131
- return paths;
226
+ this.state.conflict = { paths, statusBaseline: status.stdout, head: await readHead(this.exec, this.execOptions()) };
227
+ return { kind: "conflict", paths };
132
228
  }
133
229
 
134
- async merge(): Promise<UpdateBranchResult> {
135
- if (this.state.phase !== "ready") throw new Error("Branch update merge action was already consumed");
230
+ async rebase(): Promise<UpdateBranchResult> {
231
+ if (this.recovered && this.state.phase === "verified" && this.state.verifiedHead) {
232
+ this.recovered = false;
233
+ return { kind: "verified", head: this.state.verifiedHead, fastForward: false };
234
+ }
235
+ if (this.state.phase !== "ready") throw new Error("Branch conflict rebase action was already consumed");
136
236
  return await withWorktreeLock(this.cwd, async () => {
137
237
  await this.freshAuthority(this.authority.head.oid, true);
138
238
  const source = await resolveRepositoryFetchSource(this.exec, this.execOptions(), {
@@ -146,47 +246,53 @@ export class PullRequestBranchUpdater {
146
246
  await runChecked(this.exec, "git", ["cat-file", "-e", `${this.authority.base.oid}^{commit}`], this.execOptions());
147
247
  await this.freshAuthority(this.authority.head.oid, true);
148
248
  if (await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, this.authority.head.oid)) {
149
- this.state.phase = "verified";
150
- this.state.verifiedHead = this.authority.head.oid;
151
- return { kind: "verified", head: this.authority.head.oid, fastForward: false };
152
- }
153
-
154
- const result = await this.exec("git", ["merge", "--no-edit", this.authority.base.oid], this.execOptions());
155
- if (result.killed) throw new Error("git merge was killed; its outcome is unknown");
156
- if (result.code === 0) {
157
- const verified = await this.verifyMerge(this.authority.head.oid);
158
- return { kind: "verified", ...verified };
249
+ return await this.verifyRebase();
159
250
  }
251
+ const mergeBase = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", [
252
+ "merge-base", this.authority.base.oid, this.authority.head.oid,
253
+ ], this.execOptions())).stdout, "rebase fork point"), "rebase fork point");
254
+ const mergeCommits = (await runChecked(this.exec, "git", [
255
+ "rev-list", "--max-count=1", "--min-parents=2", `${mergeBase}..${this.authority.head.oid}`,
256
+ ], this.execOptions())).stdout.trim();
257
+ 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);
259
+ await this.writeRecovery("pending", null);
260
+ const result = await this.exec("git", ["-c", "core.editor=true", "-c", "rebase.backend=merge", "rebase", "--no-autostash", "--onto", this.authority.base.oid, mergeBase], this.execOptions());
261
+ if (result.killed) throw new Error("git rebase was killed; its outcome is unknown");
262
+ if (result.code === 0) return await this.verifyRebase();
160
263
  try {
161
- const paths = await this.captureConflict();
162
- return { kind: "conflict", paths: [...paths] };
264
+ return await this.captureConflict();
163
265
  } catch (error) {
164
- const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
165
- throw new Error(`git merge failed: ${detail}; ${error instanceof Error ? error.message : String(error)}`);
266
+ throw new Error(`git rebase failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}; ${error instanceof Error ? error.message : String(error)}`);
166
267
  }
167
268
  }, { agentDir: this.agentDir, signal: this.signal });
168
269
  }
169
270
 
170
271
  async continue(resolvedPaths: readonly string[]): Promise<UpdateBranchResult> {
171
272
  if (this.state.phase !== "conflict-awaiting-user" || !this.state.conflict) {
172
- throw new Error("Branch update has no conflict awaiting continuation");
273
+ throw new Error("Branch rebase has no conflict awaiting continuation");
173
274
  }
174
275
  const paths = validateResolvedConflictPaths(resolvedPaths, this.state.conflict.paths);
175
276
  return await withWorktreeLock(this.cwd, async () => {
176
- await this.freshAuthority(this.authority.head.oid, false);
177
- const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.execOptions())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
178
- if (mergeHead !== this.authority.base.oid) throw new Error("Branch update merge context changed");
277
+ const discovery = await this.load(this.pi(), this.context());
278
+ if (discovery.kind !== "current" || !sameAuthority(this.authority, discovery.pullRequest)) {
279
+ throw new Error("Branch rebase authority changed");
280
+ }
281
+ const path = parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--git-path", "rebase-merge/head-name"], this.execOptions())).stdout, "rebase branch marker");
282
+ if ((await readFile(resolve(this.cwd, path), "utf8")).trim() !== `refs/heads/${this.authority.target.branch}` ||
283
+ await readHead(this.exec, this.execOptions()) !== this.state.conflict!.head) {
284
+ throw new Error("Branch rebase context changed");
285
+ }
179
286
  const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
180
287
  assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
181
-
182
288
  this.state.phase = "blocked";
183
- await runChecked(this.exec, "git", ["add", "--", ...paths], this.execOptions());
289
+ await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "--", ...paths], this.execOptions());
184
290
  const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
185
291
  if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
186
-
187
- await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.execOptions());
188
- const verified = await this.verifyMerge(this.authority.head.oid);
189
- return { kind: "verified", ...verified };
292
+ const result = await this.exec("git", ["-c", "core.editor=true", "rebase", "--continue"], this.execOptions());
293
+ if (result.killed) throw new Error("git rebase continuation was killed; its outcome is unknown");
294
+ if (result.code === 0) return await this.verifyRebase();
295
+ return await this.captureConflict();
190
296
  }, { agentDir: this.agentDir, signal: this.signal });
191
297
  }
192
298
 
@@ -196,26 +302,44 @@ export class PullRequestBranchUpdater {
196
302
  }
197
303
  const head = this.state.verifiedHead;
198
304
  return await withWorktreeLock(this.cwd, async () => {
199
- await this.freshAuthority(head, true);
305
+ const original = this.recoveredRecord?.remote ?? this.authority.target.remoteOid;
306
+ if (original === null) throw new Error("Current pull request remote ref is absent");
307
+ const remoteBefore = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
308
+ if (remoteBefore !== original && remoteBefore !== head) throw new Error("Rebase push outcome is unknown; remote ref has an unexpected OID; do not retry");
309
+ await this.freshAuthority(head, true, remoteBefore === head && head !== original);
200
310
  if (!(await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, head))) {
201
311
  throw new Error("Verified branch no longer contains the frozen base");
202
312
  }
203
- const original = this.authority.target.remoteOid;
204
- if (original === null) throw new Error("Current pull request remote ref is absent");
205
- if (!(await isAncestor(this.exec, this.execOptions(), original, head))) {
206
- throw new Error("Published branch would not be a fast-forward of the frozen remote OID");
313
+ if (remoteBefore === head) {
314
+ await this.writeRecovery("published", head);
315
+ this.state.phase = "published";
316
+ return { kind: "published", head };
207
317
  }
208
318
  await this.freshAuthority(head, true);
209
319
  this.state.phase = "blocked";
210
- await runChecked(this.exec, "git", [
211
- "push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
212
- "--recurse-submodules=no", "--", this.authority.target.fetchSource,
213
- `${head}:refs/heads/${this.authority.target.ref}`,
214
- ], this.execOptions());
215
- const remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
216
- if (remote !== head) throw new Error("Published remote ref did not match verified HEAD");
217
- this.state.phase = "published";
218
- return { kind: "published", head };
320
+ let pushError: unknown;
321
+ try {
322
+ await runChecked(this.exec, "git", [
323
+ "push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
324
+ "--recurse-submodules=no", "--", this.authority.target.fetchSource,
325
+ `${head}:refs/heads/${this.authority.target.ref}`,
326
+ ], this.execOptions());
327
+ } catch (error) {
328
+ pushError = error;
329
+ }
330
+ let remote: string | null;
331
+ try {
332
+ remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
333
+ } catch {
334
+ throw new Error("Rebase push outcome is unknown; do not retry");
335
+ }
336
+ if (remote === head) {
337
+ await this.writeRecovery("published", head);
338
+ this.state.phase = "published";
339
+ return { kind: "published", head };
340
+ }
341
+ if (remote === original) throw new Error(`Rebase push was not applied${pushError ? ": " + String(pushError) : ""}`);
342
+ throw new Error("Rebase push outcome is unknown; remote ref has an unexpected OID; do not retry");
219
343
  }, { agentDir: this.agentDir, signal: this.signal });
220
344
  }
221
345
  }