@henryqw/pi-pr 4.0.2 → 4.0.4

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,12 +1,14 @@
1
- import { inspectWorktree, type Exec, type ExecResult } from "./pr-execution.ts";
1
+ import {
2
+ inspectWorktree,
3
+ readHead,
4
+ requiredOid,
5
+ requiredText,
6
+ runChecked,
7
+ type Exec,
8
+ } from "./pr-execution.ts";
2
9
  import type { LocalMergeSafety } from "./pr-routing.ts";
3
10
 
4
- export type { Exec, ExecResult } from "./pr-execution.ts";
5
-
6
- export type MergeMethod = "merge" | "rebase" | "squash";
7
-
8
11
  const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
9
- const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
10
12
 
11
13
  export type InspectLocalMergeSafetyInput = {
12
14
  exec: Exec;
@@ -15,16 +17,11 @@ export type InspectLocalMergeSafetyInput = {
15
17
  headFetchSource: string;
16
18
  };
17
19
 
18
- export type MergeMethodSelectionInput = {
19
- allowedMergeMethods: readonly MergeMethod[];
20
- viewerDefaultMergeMethod?: MergeMethod | null;
21
- };
22
-
23
20
  export type InspectedLocalMergeSafety = LocalMergeSafety & {
24
21
  headOid: string;
25
22
  };
26
23
 
27
- export type ExecuteGitHubMergeInput = MergeMethodSelectionInput & InspectLocalMergeSafetyInput & {
24
+ export type ExecuteGitHubMergeInput = InspectLocalMergeSafetyInput & {
28
25
  pullRequestId: string;
29
26
  hostname: string;
30
27
  expectedBase: {
@@ -35,62 +32,10 @@ export type ExecuteGitHubMergeInput = MergeMethodSelectionInput & InspectLocalMe
35
32
  revalidateReadiness: (local: InspectedLocalMergeSafety) => Promise<void>;
36
33
  };
37
34
 
38
- function commandText(command: string, args: string[]): string {
39
- return [command, ...args].join(" ");
40
- }
41
-
42
- function errorText(error: unknown): string {
43
- return error instanceof Error ? error.message : String(error);
44
- }
45
-
46
- function commandFailure(command: string, args: string[], detail: string): Error {
47
- return new Error(`${commandText(command, args)} failed: ${detail}`);
48
- }
49
-
50
- async function runCommand(
51
- exec: Exec,
52
- cwd: string,
53
- command: string,
54
- args: string[],
55
- allowedCodes: readonly number[] = [0],
56
- ): Promise<ExecResult> {
57
- let result: ExecResult;
58
- try {
59
- result = await exec(command, args, { cwd });
60
- } catch (error) {
61
- throw commandFailure(command, args, errorText(error));
62
- }
63
-
64
- if (result.killed || !allowedCodes.includes(result.code)) {
65
- const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
66
- throw commandFailure(command, args, detail);
67
- }
68
- return result;
69
- }
70
-
71
- function requiredText(value: unknown, label: string): string {
72
- if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
73
- throw new TypeError(`${label} must be a non-empty string`);
74
- }
75
- return value;
76
- }
77
-
78
- function requiredOid(value: unknown, label: string): string {
79
- const parsed = requiredText(value, label);
80
- if (!OID.test(parsed)) throw new TypeError(`${label} must be a full Git OID`);
81
- return parsed;
82
- }
83
-
84
35
  function isRecord(value: unknown): value is Record<string, unknown> {
85
36
  return typeof value === "object" && value !== null && !Array.isArray(value);
86
37
  }
87
38
 
88
- function requiredOutput(result: ExecResult, label: string): string {
89
- const output = result.stdout.trim();
90
- if (!output) throw new Error(`${label} returned no output`);
91
- return output;
92
- }
93
-
94
39
  function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
95
40
  requiredText(input.cwd, "cwd");
96
41
  requiredOid(input.expectedHead, "expected PR head");
@@ -98,76 +43,43 @@ function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
98
43
  if (typeof input.exec !== "function") throw new TypeError("exec must be a function");
99
44
  }
100
45
 
101
- async function readLocalHead(exec: Exec, cwd: string): Promise<string> {
102
- return requiredOid(
103
- requiredOutput(await runCommand(exec, cwd, "git", ["rev-parse", "--verify", "HEAD^{commit}"]), "local HEAD"),
104
- "local HEAD",
105
- );
106
- }
107
-
108
46
  /** Inspect local state without changing branches, the index, or the worktree. */
109
47
  export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInput): Promise<InspectedLocalMergeSafety> {
110
48
  validateInspectionInput(input);
111
49
 
112
50
  const worktree = await inspectWorktree(input.exec, { cwd: input.cwd });
113
- await runCommand(input.exec, input.cwd, "git", [
51
+ await runChecked(input.exec, "git", [
114
52
  "fetch",
115
53
  "--no-write-fetch-head",
116
54
  "--no-tags",
117
55
  "--no-recurse-submodules",
118
56
  input.headFetchSource,
119
57
  input.expectedHead,
120
- ]);
121
- await runCommand(input.exec, input.cwd, "git", ["cat-file", "-e", `${input.expectedHead}^{commit}`]);
58
+ ], { cwd: input.cwd });
59
+ await runChecked(input.exec, "git", ["cat-file", "-e", `${input.expectedHead}^{commit}`], { cwd: input.cwd });
122
60
 
123
- const headOid = await readLocalHead(input.exec, input.cwd);
61
+ const headOid = await readHead(input.exec, { cwd: input.cwd });
124
62
  if (headOid === input.expectedHead) return { worktree, head: "equal", headOid };
125
63
 
126
- const localAncestor = await runCommand(
64
+ const localAncestor = await runChecked(
127
65
  input.exec,
128
- input.cwd,
129
66
  "git",
130
67
  ["merge-base", "--is-ancestor", headOid, input.expectedHead],
68
+ { cwd: input.cwd },
131
69
  [0, 1],
132
70
  );
133
71
  if (localAncestor.code === 0) return { worktree, head: "behind", headOid };
134
72
 
135
- const expectedAncestor = await runCommand(
73
+ const expectedAncestor = await runChecked(
136
74
  input.exec,
137
- input.cwd,
138
75
  "git",
139
76
  ["merge-base", "--is-ancestor", input.expectedHead, headOid],
77
+ { cwd: input.cwd },
140
78
  [0, 1],
141
79
  );
142
80
  return { worktree, head: expectedAncestor.code === 0 ? "ahead" : "diverged", headOid };
143
81
  }
144
82
 
145
- function validateMergeMethods(input: MergeMethodSelectionInput): void {
146
- if (!Array.isArray(input.allowedMergeMethods)) throw new TypeError("allowedMergeMethods must be an array");
147
- if (input.allowedMergeMethods.some((method) => method !== "merge" && method !== "rebase" && method !== "squash")) {
148
- throw new TypeError("allowedMergeMethods contains an unsupported method");
149
- }
150
- if (
151
- input.viewerDefaultMergeMethod !== undefined &&
152
- input.viewerDefaultMergeMethod !== null &&
153
- input.viewerDefaultMergeMethod !== "merge" &&
154
- input.viewerDefaultMergeMethod !== "rebase" &&
155
- input.viewerDefaultMergeMethod !== "squash"
156
- ) throw new TypeError("viewerDefaultMergeMethod is unsupported");
157
- }
158
-
159
- export function selectMergeMethod(input: MergeMethodSelectionInput): MergeMethod {
160
- validateMergeMethods(input);
161
- if (input.allowedMergeMethods.length === 1) return input.allowedMergeMethods[0]!;
162
- if (input.allowedMergeMethods.includes("squash")) return "squash";
163
- if (
164
- input.viewerDefaultMergeMethod !== undefined &&
165
- input.viewerDefaultMergeMethod !== null &&
166
- input.allowedMergeMethods.includes(input.viewerDefaultMergeMethod)
167
- ) return input.viewerDefaultMergeMethod;
168
- throw new Error("No deterministic GitHub merge method is available");
169
- }
170
-
171
83
  function validateExecuteInput(input: ExecuteGitHubMergeInput): void {
172
84
  validateInspectionInput(input);
173
85
  requiredText(input.pullRequestId, "pullRequestId");
@@ -216,7 +128,6 @@ function parseMergeResponse(output: string, expectedId: string): void {
216
128
 
217
129
  export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promise<void> {
218
130
  validateExecuteInput(input);
219
- const method = selectMergeMethod(input);
220
131
  const local = await inspectLocalMergeSafety(input);
221
132
  if (local.worktree !== "clean" || (local.head !== "equal" && local.head !== "behind")) {
222
133
  throw new Error(`Local merge safety check failed: worktree is ${local.worktree}, HEAD is ${local.head}`);
@@ -226,11 +137,11 @@ export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promis
226
137
  if (finalWorktree !== "clean") {
227
138
  throw new Error("Final local merge safety check failed: worktree is dirty");
228
139
  }
229
- const finalHead = await readLocalHead(input.exec, input.cwd);
140
+ const finalHead = await readHead(input.exec, { cwd: input.cwd });
230
141
  if (finalHead !== local.headOid) {
231
142
  throw new Error(`Final local merge safety check failed: HEAD changed from ${local.headOid} to ${finalHead}`);
232
143
  }
233
- const merged = await runCommand(input.exec, input.cwd, "gh", [
144
+ const merged = await runChecked(input.exec, "gh", [
234
145
  "api",
235
146
  "graphql",
236
147
  "--hostname",
@@ -242,7 +153,9 @@ export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promis
242
153
  "-F",
243
154
  `expectedHeadOid=${input.expectedHead}`,
244
155
  "-F",
245
- `mergeMethod=${method.toUpperCase()}`,
246
- ]);
247
- parseMergeResponse(requiredOutput(merged, "GitHub merge"), input.pullRequestId);
156
+ "mergeMethod=SQUASH",
157
+ ], { cwd: input.cwd });
158
+ const output = merged.stdout.trim();
159
+ if (!output) throw new Error("GitHub merge returned no output");
160
+ parseMergeResponse(output, input.pullRequestId);
248
161
  }
@@ -38,6 +38,10 @@ export type PullRequestTarget = {
38
38
  remoteOid: string | null;
39
39
  };
40
40
 
41
+ export type BranchCreationState = {
42
+ ahead: number;
43
+ };
44
+
41
45
  export type DiscoveryIssue =
42
46
  | { kind: "detached-head" }
43
47
  | { kind: "target-invalid" }
@@ -50,7 +54,7 @@ export type DiscoveryIssue =
50
54
 
51
55
  export type PullRequestDiscovery<T extends PullRequest = PullRequest> =
52
56
  | { kind: "current"; pullRequest: T }
53
- | { kind: "none"; creationTarget: PullRequestTarget }
57
+ | { kind: "none"; creationTarget: PullRequestTarget; branch: BranchCreationState }
54
58
  | { kind: "blocked"; issue: DiscoveryIssue }
55
59
  | { kind: "inactive" };
56
60
 
@@ -87,7 +91,7 @@ export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<Nex
87
91
  export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
88
92
  if (discovery.kind === "inactive") return "none";
89
93
  if (discovery.kind === "blocked") return "blocked";
90
- if (discovery.kind === "none") return "create";
94
+ if (discovery.kind === "none") return discovery.branch.ahead > 0 ? "create" : "none";
91
95
  if (discovery.pullRequest.target.provenance === "inferred") {
92
96
  return discovery.pullRequest.lifecycle === "open" ? "link-branch" : "none";
93
97
  }
@@ -114,7 +114,6 @@ function widgetText(input: PrDisplayInput, nextStep: NextStep): string | undefin
114
114
 
115
115
  export function projectPrDisplay(
116
116
  discovery: PullRequestDiscovery<PrDisplayInput>,
117
- hasLocalCommit = false,
118
117
  ): PrDisplay {
119
118
  const nextStep = deriveNextStep(discovery);
120
119
  if (discovery.kind === "inactive") return { nextStep };
@@ -132,7 +131,7 @@ export function projectPrDisplay(
132
131
  if (discovery.kind === "none") {
133
132
  return {
134
133
  nextStep,
135
- widget: hasLocalCommit ? "Run /pr to create pull request" : undefined,
134
+ widget: nextStep === "create" ? "Run /pr to create pull request" : undefined,
136
135
  };
137
136
  }
138
137
 
@@ -17,7 +17,6 @@ import {
17
17
  runChecked,
18
18
  spawnBounded,
19
19
  withWorktreeLock,
20
- type AttemptState,
21
20
  type Exec,
22
21
  type ExecOptions,
23
22
  } from "./pr-execution.ts";
@@ -26,13 +25,6 @@ export type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified"
26
25
 
27
26
  export type UpdateBranchState = {
28
27
  phase: UpdateBranchPhase;
29
- attempts: {
30
- fetchBase: AttemptState;
31
- merge: AttemptState;
32
- stage: AttemptState;
33
- continueMerge: AttemptState;
34
- push: AttemptState;
35
- };
36
28
  verifiedHead?: string;
37
29
  conflict?: { paths: string[]; statusBaseline: string };
38
30
  };
@@ -62,7 +54,6 @@ function cloneAuthority(value: CurrentPullRequest): CurrentPullRequest {
62
54
  base: { ...value.base },
63
55
  head: { ...value.head },
64
56
  target: { ...value.target },
65
- merge: value.merge ? { ...value.merge, allowedMergeMethods: [...value.merge.allowedMergeMethods] } : null,
66
57
  };
67
58
  }
68
59
 
@@ -89,10 +80,7 @@ function validateDeclaredPaths(paths: readonly string[], expected: readonly stri
89
80
  }
90
81
 
91
82
  export class PullRequestBranchUpdater {
92
- readonly state: UpdateBranchState = {
93
- phase: "ready",
94
- attempts: { fetchBase: "none", merge: "none", stage: "none", continueMerge: "none", push: "none" },
95
- };
83
+ readonly state: UpdateBranchState = { phase: "ready" };
96
84
 
97
85
  private readonly cwd: string;
98
86
  private readonly authority: CurrentPullRequest;
@@ -188,26 +176,18 @@ export class PullRequestBranchUpdater {
188
176
  }
189
177
 
190
178
  async merge(): Promise<UpdateBranchResult> {
191
- if (this.state.phase !== "ready" || this.state.attempts.fetchBase !== "none" || this.state.attempts.merge !== "none") {
192
- throw new Error("Branch update merge action was already consumed");
193
- }
179
+ if (this.state.phase !== "ready") throw new Error("Branch update merge action was already consumed");
194
180
  return await withWorktreeLock(this.cwd, async () => {
195
181
  await this.freshAuthority(this.authority.head.oid, true);
196
182
  const source = await resolveRepositoryFetchSource(this.exec, this.execOptions(), {
197
183
  host: this.authority.host,
198
184
  repository: this.authority.base.repository,
199
185
  });
200
- this.state.attempts.fetchBase = "attempting";
201
- try {
202
- await runChecked(this.exec, "git", [
203
- "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", source, this.authority.base.oid,
204
- ], this.execOptions());
205
- await runChecked(this.exec, "git", ["cat-file", "-e", `${this.authority.base.oid}^{commit}`], this.execOptions());
206
- this.state.attempts.fetchBase = "applied";
207
- } catch (error) {
208
- this.state.attempts.fetchBase = "unknown";
209
- throw error;
210
- }
186
+ this.state.phase = "blocked";
187
+ await runChecked(this.exec, "git", [
188
+ "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", source, this.authority.base.oid,
189
+ ], this.execOptions());
190
+ await runChecked(this.exec, "git", ["cat-file", "-e", `${this.authority.base.oid}^{commit}`], this.execOptions());
211
191
  await this.freshAuthority(this.authority.head.oid, true);
212
192
  if (await isAncestor(this.exec, this.execOptions(), this.authority.base.oid, this.authority.head.oid)) {
213
193
  this.state.phase = "verified";
@@ -215,35 +195,16 @@ export class PullRequestBranchUpdater {
215
195
  return { kind: "verified", head: this.authority.head.oid, fastForward: false };
216
196
  }
217
197
 
218
- this.state.attempts.merge = "attempting";
219
- let result;
220
- try {
221
- result = await this.exec("git", ["merge", "--no-edit", this.authority.base.oid], this.execOptions());
222
- } catch (error) {
223
- this.state.attempts.merge = "unknown";
224
- throw error;
225
- }
226
- if (result.killed) {
227
- this.state.attempts.merge = "unknown";
228
- throw new Error("git merge was killed; its outcome is unknown");
229
- }
198
+ const result = await this.exec("git", ["merge", "--no-edit", this.authority.base.oid], this.execOptions());
199
+ if (result.killed) throw new Error("git merge was killed; its outcome is unknown");
230
200
  if (result.code === 0) {
231
- try {
232
- const verified = await this.verifyMerge(this.authority.head.oid);
233
- this.state.attempts.merge = "applied";
234
- return { kind: "verified", ...verified };
235
- } catch (error) {
236
- this.state.attempts.merge = "unknown";
237
- throw error;
238
- }
201
+ const verified = await this.verifyMerge(this.authority.head.oid);
202
+ return { kind: "verified", ...verified };
239
203
  }
240
204
  try {
241
205
  const paths = await this.captureConflict();
242
- this.state.attempts.merge = "applied";
243
206
  return { kind: "conflict", paths: [...paths] };
244
207
  } catch (error) {
245
- this.state.attempts.merge = "blocked";
246
- this.state.phase = "blocked";
247
208
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
248
209
  throw new Error(`git merge failed: ${detail}; ${error instanceof Error ? error.message : String(error)}`);
249
210
  }
@@ -262,36 +223,19 @@ export class PullRequestBranchUpdater {
262
223
  const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions());
263
224
  assertOnlyDeclaredStatusChanged(this.state.conflict!.statusBaseline, status.stdout, paths);
264
225
 
265
- if (this.state.attempts.stage !== "none") throw new Error("Conflict staging action was already consumed");
266
- this.state.attempts.stage = "attempting";
267
- try {
268
- await runChecked(this.exec, "git", ["add", "--", ...paths], this.execOptions());
269
- this.state.attempts.stage = "applied";
270
- } catch (error) {
271
- this.state.attempts.stage = "unknown";
272
- throw error;
273
- }
226
+ this.state.phase = "blocked";
227
+ await runChecked(this.exec, "git", ["add", "--", ...paths], this.execOptions());
274
228
  const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.execOptions())).stdout, "Unmerged paths");
275
- if (unmerged.length) {
276
- this.state.phase = "blocked";
277
- throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
278
- }
229
+ if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
279
230
 
280
- this.state.attempts.continueMerge = "attempting";
281
- try {
282
- await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.execOptions());
283
- const verified = await this.verifyMerge(this.authority.head.oid);
284
- this.state.attempts.continueMerge = "applied";
285
- return { kind: "verified", ...verified };
286
- } catch (error) {
287
- this.state.attempts.continueMerge = "unknown";
288
- throw error;
289
- }
231
+ await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.execOptions());
232
+ const verified = await this.verifyMerge(this.authority.head.oid);
233
+ return { kind: "verified", ...verified };
290
234
  }, { agentDir: this.agentDir, signal: this.signal });
291
235
  }
292
236
 
293
237
  async publish(): Promise<UpdateBranchResult> {
294
- if (this.state.phase !== "verified" || !this.state.verifiedHead || this.state.attempts.push !== "none") {
238
+ if (this.state.phase !== "verified" || !this.state.verifiedHead) {
295
239
  throw new Error("Branch update is not ready to publish");
296
240
  }
297
241
  const head = this.state.verifiedHead;
@@ -306,22 +250,16 @@ export class PullRequestBranchUpdater {
306
250
  throw new Error("Published branch would not be a fast-forward of the frozen remote OID");
307
251
  }
308
252
  await this.freshAuthority(head, true);
309
- this.state.attempts.push = "attempting";
310
- try {
311
- await runChecked(this.exec, "git", [
312
- "push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
313
- "--recurse-submodules=no", "--", this.authority.target.fetchSource,
314
- `${head}:refs/heads/${this.authority.target.ref}`,
315
- ], this.execOptions());
316
- const remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
317
- if (remote !== head) throw new Error("Published remote ref did not match verified HEAD");
318
- this.state.attempts.push = "applied";
319
- this.state.phase = "published";
320
- return { kind: "published", head };
321
- } catch (error) {
322
- this.state.attempts.push = "unknown";
323
- throw error;
324
- }
253
+ this.state.phase = "blocked";
254
+ await runChecked(this.exec, "git", [
255
+ "push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
256
+ "--recurse-submodules=no", "--", this.authority.target.fetchSource,
257
+ `${head}:refs/heads/${this.authority.target.ref}`,
258
+ ], this.execOptions());
259
+ const remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref);
260
+ if (remote !== head) throw new Error("Published remote ref did not match verified HEAD");
261
+ this.state.phase = "published";
262
+ return { kind: "published", head };
325
263
  }, { agentDir: this.agentDir, signal: this.signal });
326
264
  }
327
265
  }
package/extensions/pr.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  type ExtensionAPI,
6
6
  type ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
- import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import { createHerdrClient } from "@henryqw/pi-herdr";
10
10
  import { Type } from "typebox";
11
11
  import { PullRequestCiFixer, type PullRequestCiFixOptions } from "./pr-ci.ts";
@@ -19,7 +19,6 @@ import {
19
19
  } from "./pr-command.ts";
20
20
  import { PullRequestCreator, type CreatePullRequestOptions } from "./pr-create.ts";
21
21
  import {
22
- hasLocalCommit,
23
22
  loadCurrentPullRequest,
24
23
  parsePullRequestObservation,
25
24
  pullRequestObservation,
@@ -161,7 +160,6 @@ type WorkflowContext =
161
160
 
162
161
  type PullRequestExtensionDependencies = {
163
162
  loadCurrentPullRequest?: typeof loadCurrentPullRequest;
164
- hasLocalCommit?: typeof hasLocalCommit;
165
163
  createPrCommandHandler?: typeof createPrCommandHandler;
166
164
  createBranchUpdater?: (options: UpdateBranchOptions) => UpdateBranchWorkflow;
167
165
  createPullRequestCreator?: (options: CreatePullRequestOptions) => CreateWorkflow;
@@ -255,7 +253,6 @@ export default function pullRequestExtension(
255
253
  };
256
254
 
257
255
  const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
258
- const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
259
256
  const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
260
257
  const createBranchUpdater = dependencies.createBranchUpdater ?? ((options) => new PullRequestBranchUpdater(options));
261
258
  const createPullRequestCreator = dependencies.createPullRequestCreator ?? ((options) => new PullRequestCreator(options));
@@ -265,9 +262,15 @@ export default function pullRequestExtension(
265
262
  const newRunId = dependencies.newRunId ?? randomUUID;
266
263
  let context: ExtensionContext | undefined;
267
264
  let observation: PullRequestObservation | undefined;
268
- const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
265
+ const load: typeof loadCurrentPullRequest = async (
266
+ api,
267
+ loadContext,
268
+ inspectedLocal,
269
+ _observed,
270
+ explicitCreationBase,
271
+ ) => {
269
272
  const generation = sessionGeneration;
270
- const discovery = await discover(api, loadContext, inspectedLocal, observation);
273
+ const discovery = await discover(api, loadContext, inspectedLocal, observation, explicitCreationBase);
271
274
  if (generation !== sessionGeneration) return discovery;
272
275
  if (discovery.kind === "current") {
273
276
  const current = pullRequestObservation(discovery.pullRequest);
@@ -288,7 +291,6 @@ export default function pullRequestExtension(
288
291
  let lastBlockedIssueKey: string | undefined;
289
292
  let delegatedWorkPending = false;
290
293
  let pendingWorkspaceRename = false;
291
- let mergeCompleted = false;
292
294
  let displayedWidget: PrDisplay | undefined;
293
295
  let commandGeneration = 0;
294
296
  let workflowContext: WorkflowContext | undefined;
@@ -296,14 +298,10 @@ export default function pullRequestExtension(
296
298
  let widgetKind: "presentation" | "routing" = "presentation";
297
299
  let routingSpinnerFrame = 0;
298
300
  let routingSpinnerTimer: ReturnType<typeof setInterval> | undefined;
299
- let routingSpinnerTui: TUI | undefined;
300
- let routingSpinnerComponent: (Component & { dispose(): void }) | undefined;
301
301
 
302
302
  const stopRoutingSpinner = (): void => {
303
303
  if (routingSpinnerTimer !== undefined) clearInterval(routingSpinnerTimer);
304
304
  routingSpinnerTimer = undefined;
305
- routingSpinnerTui = undefined;
306
- routingSpinnerComponent = undefined;
307
305
  };
308
306
 
309
307
  const clearWorkflow = (selected: WorkflowContext | undefined): void => {
@@ -524,42 +522,30 @@ export default function pullRequestExtension(
524
522
  stopRoutingSpinner();
525
523
  widgetKind = "routing";
526
524
  routingSpinnerFrame = 0;
527
- if (ctx.mode !== "tui") {
528
- ctx.ui.setWidget(UI_KEY, [`${ROUTING_SPINNER_FRAMES[0]} ${ROUTING_WIDGET_TEXT}`]);
529
- return;
530
- }
525
+ const update = (): void => {
526
+ const frame = ROUTING_SPINNER_FRAMES[routingSpinnerFrame]!;
527
+ if (ctx.mode === "tui") {
528
+ ctx.ui.setWidget(UI_KEY, (_tui, theme) => ({
529
+ invalidate() {},
530
+ render(width) {
531
+ if (width <= 0) return [];
532
+ return [truncateToWidth(`${theme.fg("accent", frame)} ${ROUTING_WIDGET_TEXT}`, width)];
533
+ },
534
+ }));
535
+ return;
536
+ }
537
+ ctx.ui.setWidget(UI_KEY, [`${frame} ${ROUTING_WIDGET_TEXT}`]);
538
+ };
539
+ update();
540
+ if (ctx.mode !== "tui") return;
531
541
 
532
- ctx.ui.setWidget(UI_KEY, (tui, theme) => {
533
- let cachedWidth: number | undefined;
534
- let cachedLines: string[] | undefined;
535
- const component: Component & { dispose(): void } = {
536
- invalidate() {
537
- cachedWidth = undefined;
538
- cachedLines = undefined;
539
- },
540
- render(width) {
541
- if (width <= 0) return [];
542
- if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
543
- const line = `${theme.fg("accent", ROUTING_SPINNER_FRAMES[routingSpinnerFrame]!)} ${ROUTING_WIDGET_TEXT}`;
544
- cachedWidth = width;
545
- cachedLines = [truncateToWidth(line, Math.max(1, width))];
546
- return cachedLines;
547
- },
548
- dispose() {
549
- if (routingSpinnerComponent !== component) return;
550
- stopRoutingSpinner();
551
- widgetKind = "presentation";
552
- },
553
- };
554
- routingSpinnerTui = tui;
555
- routingSpinnerComponent = component;
556
- return component;
557
- });
558
- routingSpinnerTimer = setInterval(() => {
542
+ let spinnerTimer: ReturnType<typeof setInterval>;
543
+ spinnerTimer = setInterval(() => {
544
+ if (routingSpinnerTimer !== spinnerTimer || widgetKind !== "routing") return;
559
545
  routingSpinnerFrame = (routingSpinnerFrame + 1) % ROUTING_SPINNER_FRAMES.length;
560
- routingSpinnerComponent?.invalidate();
561
- routingSpinnerTui?.requestRender();
546
+ update();
562
547
  }, ROUTING_SPINNER_INTERVAL_MS);
548
+ routingSpinnerTimer = spinnerTimer;
563
549
  };
564
550
 
565
551
  const reconcileWidget = (ctx: ExtensionContext): void => {
@@ -573,7 +559,6 @@ export default function pullRequestExtension(
573
559
  const render = (
574
560
  ctx: ExtensionContext,
575
561
  discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
576
- localCommit: boolean,
577
562
  ): void => {
578
563
  if (discovery.kind === "inactive") {
579
564
  if (timer !== undefined) clearInterval(timer);
@@ -585,7 +570,7 @@ export default function pullRequestExtension(
585
570
  reconcileWidget(ctx);
586
571
  return;
587
572
  }
588
- const display = projectPrDisplay(discovery, localCommit);
573
+ const display = projectPrDisplay(discovery);
589
574
  const footer = formatPrFooter(display, ctx.ui.theme);
590
575
  if ((discovery.kind === "current" || discovery.kind === "blocked") && footer === undefined) {
591
576
  throw new Error("Pull request display is missing a footer");
@@ -621,7 +606,6 @@ export default function pullRequestExtension(
621
606
  lastBlockedIssueKey = undefined;
622
607
  delegatedWorkPending = false;
623
608
  pendingWorkspaceRename = false;
624
- mergeCompleted = false;
625
609
  displayedWidget = undefined;
626
610
  commandGeneration = 0;
627
611
  clearWorkflow(workflowContext);
@@ -668,21 +652,17 @@ export default function pullRequestExtension(
668
652
  active = controller;
669
653
  try {
670
654
  let discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
671
- let localCommit = false;
672
655
  try {
673
656
  discovery = await load(pi, loadContext);
674
657
  if (controller.signal.aborted || sessionGeneration !== generation) return;
675
- if (discovery.kind === "none") {
676
- localCommit = !mergeCompleted && await detectLocalCommit(pi, loadContext);
677
- }
678
658
  } catch {
679
- // Keep an established display. A cold Git-worktree failure gets a sanitized placeholder.
659
+ // Keep an established footer. A refresh failure must not leave a stale action hint.
680
660
  if (!controller.signal.aborted && sessionGeneration === generation) {
661
+ displayedWidget = undefined;
662
+ reconcileWidget(ctx);
681
663
  if (!displayEstablished) {
682
664
  const unavailable = unavailablePrDisplay();
683
- displayedWidget = undefined;
684
665
  ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
685
- reconcileWidget(ctx);
686
666
  displayEstablished = true;
687
667
  }
688
668
  reportRefreshFailure();
@@ -690,7 +670,7 @@ export default function pullRequestExtension(
690
670
  return;
691
671
  }
692
672
  if (controller.signal.aborted || sessionGeneration !== generation) return;
693
- render(ctx, discovery, localCommit);
673
+ render(ctx, discovery);
694
674
  refreshFailureReported = false;
695
675
 
696
676
  const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
@@ -786,7 +766,6 @@ export default function pullRequestExtension(
786
766
  if (!isBashToolResult(event)) return;
787
767
  const command = event.input.command;
788
768
  if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_COMMIT.test(command) || GIT_PUSH.test(command))) {
789
- if (GIT_COMMIT.test(command)) mergeCompleted = false;
790
769
  await refresh().catch(reportRefreshFailure);
791
770
  }
792
771
  });
@@ -798,7 +777,7 @@ export default function pullRequestExtension(
798
777
  releaseWorkflow,
799
778
  });
800
779
  pi.registerCommand("pr", {
801
- description: "[--base=<host>/<owner>/<repository>:<ref>] — Run the current branch pull request next step",
780
+ description: "[--base <branch>] [instructions] — Run the current branch pull request next step",
802
781
  handler: async (args, ctx) => {
803
782
  if (!ctx.hasUI || !context) return;
804
783
  const generation = sessionGeneration;
@@ -838,7 +817,6 @@ export default function pullRequestExtension(
838
817
  if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
839
818
  reconcileWidget(ctx);
840
819
  } else {
841
- if (nextStep === "merge") mergeCompleted = true;
842
820
  activeInvocations.delete(invocation);
843
821
  refreshInBackground();
844
822
  }