@henryqw/pi-pr 4.0.2 → 4.0.3

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 CHANGED
@@ -42,11 +42,11 @@ GitHub, use the network, or write files.
42
42
 
43
43
  Run `/pr` in a GitHub checkout. It reads fresh pull request and local state, then runs one route. The PR hostname selects its GitHub API host, and the extension works outside Herdr.
44
44
 
45
- Creation accepts one optional base anchor. Use `/pr --base=<host>/<owner>/<repository>:<ref>`. All other route instructions fail instead of being ignored.
45
+ For creation, put an optional base branch first. For example, run `/pr --base release/2026 Keep the title concise.` The base is a branch name, not a host or repository. Only creation accepts the base and remaining guidance. Other routes reject them instead of ignoring them.
46
46
 
47
47
  | Surface | Type | Purpose |
48
48
  | --- | --- | --- |
49
- | `/pr [--base=<host>/<owner>/<repository>:<ref>]` | command | Run the current pull request's next safe route. |
49
+ | `/pr [--base BRANCH] [creation instructions]` | command | Run the current pull request's next safe route. |
50
50
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
51
51
  | Widget | ui | Show one action hint or transient routing status. |
52
52
 
@@ -64,7 +64,7 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
64
64
 
65
65
  | Current condition | `/pr` route |
66
66
  | --- | --- |
67
- | No current-branch pull request, no published matching ref, and safe Git push configuration | Start pull-request creation. |
67
+ | No current-branch pull request, no published matching ref, safe Git push configuration, and a commit ahead of the selected base | Start pull-request creation. |
68
68
  | One open pull request inferred from a published matching ref | Confirm the exact `remote/ref`, then link the local branch. |
69
69
  | Ambiguous or unsafe discovery | Show the blocked reason and do not mutate Git or GitHub. |
70
70
  | Base update required or merge conflict | Update from the base branch's current target when the tree is clean and local HEAD equals the PR head. |
@@ -74,11 +74,13 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
74
74
  | No-action state | Report the state without taking action. |
75
75
  | Merge-ready pull request | Ask for final confirmation, recheck fresh state, and merge directly if confirmed. |
76
76
 
77
- `pi-pr-create` accepts an anchored base from `/pr --base=...`. Otherwise, it finds one unique parent from validated `origin` refs and commit history. The parent can be a feature branch.
77
+ `pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires at least one committed change ahead. Dirty work alone does not enable creation.
78
78
 
79
- It merges the parent's captured commit before validation and push. It resolves clear conflicts and stops when the base or conflict intent is ambiguous.
79
+ The base always comes from validated `origin`. The head may use that repository or a fork with the same GitHub source. Base and head must use the same GitHub host. Other fork relationships stop before mutation.
80
80
 
81
- It honors an existing configured push target. Without one, it pushes a captured OID to the local branch ref on `origin` and sets upstream.
81
+ It merges the captured base commit before validation and push. It resolves clear conflicts and stops when the base or conflict intent is ambiguous.
82
+
83
+ A configured target never changes branch upstream settings. Without a target, the helper pushes the captured OID to the local branch ref on validated `origin` and fetches its tracking ref. It leaves upstream unset. It creates or updates and validates the exact PR before it sets and verifies upstream. A failed setup rolls back only unchanged helper-owned settings. If configuration changed concurrently, it stops without overwriting it. Retrying `publish` resumes setup without another push or PR mutation.
82
84
 
83
85
  Without a configured push target, discovery checks validated remotes for the same branch ref. One exact open PR becomes an inferred target. `/pr` names the exact `remote/ref` and asks before linking it. The extension revalidates the branch, PR, remote OID, and Git configuration before mutation. It rolls back its upstream and remote-tracking changes if final verification fails.
84
86
 
@@ -145,7 +147,7 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
145
147
 
146
148
  ## Limits and recovery
147
149
 
148
- - `/pr` accepts only the optional anchored creation base described above. It does not open a browser.
150
+ - `/pr` accepts creation syntax only as a leading `--base BRANCH`, followed by optional creation guidance. It does not open a browser.
149
151
  - It does not run `/done` or `/sweep`.
150
152
  - Polling does not auto-triage comments or start a workflow. The package comment sweep runs only when an explicit `/pr` selects it.
151
153
  - It does not enable auto-merge or add a merge queue.
@@ -26,8 +26,6 @@ const WORKFLOWS: Record<WorkflowNextStep, { command: string; action: string }> =
26
26
  sweep: { command: "skill:pi-pr-comment-sweep", action: "start" },
27
27
  "fix-ci": { command: "skill:pi-pr-fix-ci", action: "collect" },
28
28
  };
29
- const CREATE_BASE = /^--base=([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[^\s\0]+)$/;
30
-
31
29
  export type WorkflowReservation =
32
30
  | { route: "create"; target: PullRequestTarget; base?: string }
33
31
  | { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
@@ -62,21 +60,30 @@ export type PrCommandDependencies = {
62
60
  releaseWorkflow?: (runId: string, invocation?: PrCommandInvocation) => void;
63
61
  };
64
62
 
65
- function createBase(instructions: string): string | undefined {
66
- if (!instructions) return undefined;
67
- const match = CREATE_BASE.exec(instructions);
68
- if (!match) throw new Error("PR creation accepts only --base=<host>/<owner>/<repository>:<ref>");
69
- return match[1]!;
63
+ type ParsedPrArguments = {
64
+ base?: string;
65
+ instructions: string;
66
+ };
67
+
68
+ function parsePrArguments(args: string): ParsedPrArguments {
69
+ const leading = args.trimStart();
70
+ if (leading.startsWith("--base=")) throw new Error("/pr base syntax is --base <branch>");
71
+ if (!leading.startsWith("--base") || !/^--base(?:\s|$)/.test(leading)) {
72
+ return { instructions: args.trim() };
73
+ }
74
+ const value = /^--base\s+(\S+)/.exec(leading);
75
+ if (!value) throw new Error("/pr --base requires a branch");
76
+ return { base: value[1]!, instructions: leading.slice(value[0].length).trim() };
70
77
  }
71
78
 
72
79
  function workflowReservation(
73
80
  nextStep: WorkflowNextStep,
74
81
  discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
82
+ base: string | undefined,
75
83
  instructions: string,
76
84
  ): WorkflowReservation {
77
85
  if (nextStep === "create") {
78
86
  if (discovery.kind !== "none") throw new Error("/pr create failed: creation target is unavailable");
79
- const base = createBase(instructions);
80
87
  return { route: "create", target: discovery.creationTarget, ...(base === undefined ? {} : { base }) };
81
88
  }
82
89
  if (instructions) throw new Error("The current /pr helper route does not accept instructions");
@@ -104,6 +111,7 @@ async function dispatchWorkflow(
104
111
  reserve: NonNullable<PrCommandDependencies["reserveWorkflow"]>,
105
112
  markPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]>,
106
113
  release: NonNullable<PrCommandDependencies["releaseWorkflow"]>,
114
+ instructions: string,
107
115
  ): Promise<void> {
108
116
  const workflow = packageWorkflowCommand(pi, route);
109
117
  let runId: string | undefined;
@@ -117,7 +125,7 @@ async function dispatchWorkflow(
117
125
  ? { deliverAs: "followUp" as const, expandPromptTemplates: true }
118
126
  : { expandPromptTemplates: true };
119
127
  invocation?.assertCurrent();
120
- pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}`, options);
128
+ pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}${instructions ? ` ${instructions}` : ""}`, options);
121
129
  } catch (error) {
122
130
  if (runId !== undefined) release(runId, invocation);
123
131
  throw error;
@@ -258,11 +266,14 @@ export function createPrCommandHandler(
258
266
  const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved
259
267
  ? onRouteResolved as PrCommandInvocation
260
268
  : undefined;
261
- const instructions = args.trim();
262
- const discovery = await load(pi, ctx);
269
+ const { base, instructions } = parsePrArguments(args);
270
+ const discovery = await load(pi, ctx, undefined, undefined, base);
263
271
  commandInvocation?.assertCurrent();
264
272
  const nextStep = deriveNextStep(discovery);
265
273
  onRouteResolved?.(nextStep);
274
+ if (base !== undefined && nextStep !== "create") {
275
+ throw new Error("/pr --base is accepted only for pull request creation");
276
+ }
266
277
  if (instructions && !(nextStep in WORKFLOWS)) {
267
278
  throw new Error("The current /pr route does not accept instructions");
268
279
  }
@@ -289,8 +300,18 @@ export function createPrCommandHandler(
289
300
 
290
301
  if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
291
302
  const route = nextStep as WorkflowNextStep;
292
- const reservation = workflowReservation(route, discovery, instructions);
293
- await dispatchWorkflow(pi, ctx, route, reservation, commandInvocation, reserve, markPromptQueued, release);
303
+ const reservation = workflowReservation(route, discovery, base, instructions);
304
+ await dispatchWorkflow(
305
+ pi,
306
+ ctx,
307
+ route,
308
+ reservation,
309
+ commandInvocation,
310
+ reserve,
311
+ markPromptQueued,
312
+ release,
313
+ route === "create" ? instructions : "",
314
+ );
294
315
  return nextStep;
295
316
  };
296
317
  }
@@ -5,11 +5,15 @@ import {
5
5
  findExactHeadPullRequests,
6
6
  loadCurrentPullRequest,
7
7
  loadPullRequestPublication,
8
+ preflightPullRequestCreation,
9
+ readBranchUpstreamConfiguration,
8
10
  readPullRequestBaseRefOid,
9
11
  readTrackingOid,
10
12
  readValidatedRemoteAuthority,
13
+ restoreBranchUpstreamConfiguration,
11
14
  setBranchUpstream,
12
15
  verifyBranchUpstream,
16
+ type BranchUpstreamConfiguration,
13
17
  type BranchUpstreamTarget,
14
18
  type PullRequestLoadContext,
15
19
  type PullRequestPublication,
@@ -24,7 +28,6 @@ import {
24
28
  readRemoteOid,
25
29
  requiredOid,
26
30
  requiredText,
27
- resolveRepositoryFetchSource,
28
31
  runChecked,
29
32
  spawnBounded,
30
33
  withWorktreeLock,
@@ -33,8 +36,6 @@ import {
33
36
  type ExecOptions,
34
37
  } from "./pr-execution.ts";
35
38
 
36
- const BASE_RERUN = "Rerun /pr --base=<host>/<owner>/<repository>:<ref>";
37
- const MAX_REMOTE_BRANCHES = 128;
38
39
  const MAX_TITLE_BYTES = 256;
39
40
  const MAX_BODY_BYTES = 64 * 1024;
40
41
  const CREATE_AUTHORITY_QUERY = "query($baseOwner:String!,$baseName:String!,$headOwner:String!,$headName:String!){base:repository(owner:$baseOwner,name:$baseName){id nameWithOwner}head:repository(owner:$headOwner,name:$headName){id nameWithOwner owner{__typename}}createInput:__type(name:\"CreatePullRequestInput\"){inputFields{name}}}";
@@ -75,6 +76,7 @@ export type CreatePullRequestState = {
75
76
  mergeHead?: string;
76
77
  publicationHead?: string;
77
78
  conflict?: { paths: string[]; statusBaseline: string; originalHead: string };
79
+ originalUpstream?: BranchUpstreamConfiguration;
78
80
  url?: string;
79
81
  };
80
82
 
@@ -100,6 +102,11 @@ function sameTarget(left: PullRequestTarget, right: PullRequestTarget, expectedR
100
102
  left.fetchSource === right.fetchSource && right.remoteOid === expectedRemoteOid;
101
103
  }
102
104
 
105
+ function sameUpstreamConfiguration(left: BranchUpstreamConfiguration, right: BranchUpstreamConfiguration): boolean {
106
+ return left.remote.length === right.remote.length && left.remote.every((value, index) => value === right.remote[index]) &&
107
+ left.merge.length === right.merge.length && left.merge.every((value, index) => value === right.merge[index]);
108
+ }
109
+
103
110
  function line(output: string, label: string): string {
104
111
  const normalized = output.replace(/\r\n/g, "\n");
105
112
  const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
@@ -107,16 +114,6 @@ function line(output: string, label: string): string {
107
114
  return values[0];
108
115
  }
109
116
 
110
- function configuredValues(output: string): string[] {
111
- if (output === "") return [];
112
- const normalized = output.replace(/\r\n/g, "\n");
113
- const values = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
114
- if (values.some((value) => !value) || new Set(values).size !== values.length) {
115
- throw new Error("Git configuration returned invalid values");
116
- }
117
- return values;
118
- }
119
-
120
117
  function isRecord(value: unknown): value is Record<string, unknown> {
121
118
  return typeof value === "object" && value !== null && !Array.isArray(value);
122
119
  }
@@ -191,14 +188,6 @@ function parseCreatedUrl(output: string, host: string, baseRepository: string):
191
188
  return url;
192
189
  }
193
190
 
194
- function parseExplicitBase(value: string): Omit<CreateBaseAuthority, "oid" | "fetchSource"> {
195
- const match = /^([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+):(.+)$/.exec(value);
196
- if (!match || match[2] === "." || match[2] === ".." || match[3] === "." || match[3] === "..") {
197
- throw new Error(`Invalid base authority. ${BASE_RERUN}`);
198
- }
199
- return { host: match[1]!.toLowerCase(), repository: `${match[2]}/${match[3]}`, ref: requiredText(match[4], "base ref") };
200
- }
201
-
202
191
  function resolvedPaths(paths: readonly string[], expected: readonly string[]): string[] {
203
192
  if (!Array.isArray(paths)) throw new TypeError("resolvedPaths must be an array");
204
193
  const parsed = parseNulPaths(`${paths.join("\0")}${paths.length ? "\0" : ""}`, "Resolved conflict paths");
@@ -224,6 +213,7 @@ export class PullRequestCreator {
224
213
  private readonly agentDir?: string;
225
214
  private readonly exec: Exec;
226
215
  private readonly load: Load;
216
+ private explicitBase?: string;
227
217
 
228
218
  constructor(options: CreatePullRequestOptions) {
229
219
  if (!options.target || options.target.remoteOid !== null && !requiredOid(options.target.remoteOid, "remote OID")) {
@@ -257,7 +247,7 @@ export class PullRequestCreator {
257
247
  }
258
248
 
259
249
  private async freshNone(): Promise<void> {
260
- const discovery = await this.load(this.pi(), this.context());
250
+ const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
261
251
  if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
262
252
  throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
263
253
  }
@@ -265,71 +255,6 @@ export class PullRequestCreator {
265
255
  if (branch !== this.target.branch) throw new Error("PR creation cancelled: current branch changed");
266
256
  }
267
257
 
268
- private async originAuthority() {
269
- return await readValidatedRemoteAuthority(this.pi(), this.context(), "origin");
270
- }
271
-
272
- private async inferBaseRef(): Promise<{ authority: Awaited<ReturnType<PullRequestCreator["originAuthority"]>>; ref: string }> {
273
- const authority = await this.originAuthority();
274
- await runChecked(this.exec, "git", [
275
- "fetch", "--prune", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules",
276
- authority.fetchSource, "+refs/heads/*:refs/remotes/origin/*",
277
- ], this.options());
278
- const refs = await runChecked(this.exec, "git", [
279
- "for-each-ref", "--format=%(refname)%09%(objectname)%09%(symref)", "refs/remotes/origin",
280
- ], this.options());
281
- const rows = configuredValues(refs.stdout);
282
- if (rows.length > MAX_REMOTE_BRANCHES) throw new Error(`${BASE_RERUN}; more than ${MAX_REMOTE_BRANCHES} base candidates exist`);
283
- const originOwnsTarget = authority.host === this.target.host &&
284
- authority.repository.toLowerCase() === this.target.repository.toLowerCase();
285
- const candidates: Array<{ ref: string; score: number }> = [];
286
- for (const row of rows) {
287
- const parts = row.split("\t");
288
- if (parts.length !== 3) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
289
- const prefix = "refs/remotes/origin/";
290
- if (!parts[0]!.startsWith(prefix)) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
291
- const ref = parts[0]!.slice(prefix.length);
292
- if (ref === "HEAD" || originOwnsTarget && ref === this.target.ref) continue;
293
- if (!ref || parts[2] !== "") throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
294
- const oid = requiredOid(parts[1], "remote base OID");
295
- const distance = line((await runChecked(this.exec, "git", ["rev-list", "--left-right", "--count", `HEAD...${oid}`], this.options())).stdout, "base distance");
296
- const counts = /^(\d+)\s+(\d+)$/.exec(distance);
297
- if (!counts) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
298
- const score = Number(counts[1]) + Number(counts[2]);
299
- if (!Number.isSafeInteger(score)) throw new Error(`Base inference is unsafe. ${BASE_RERUN}`);
300
- candidates.push({ ref, score });
301
- }
302
- if (!candidates.length) throw new Error(`Base cannot be inferred. ${BASE_RERUN}`);
303
- const minimum = Math.min(...candidates.map(({ score }) => score));
304
- const nearest = candidates.filter(({ score }) => score === minimum);
305
- if (nearest.length !== 1) throw new Error(`Base is ambiguous. ${BASE_RERUN}`);
306
- return { authority, ref: nearest[0]!.ref };
307
- }
308
-
309
- private async resolveBase(explicit?: string): Promise<Omit<CreateBaseAuthority, "oid">> {
310
- if (explicit !== undefined) {
311
- const parsed = parseExplicitBase(explicit);
312
- if (parsed.host !== this.target.host.toLowerCase()) {
313
- throw new Error("PR creation base and head must use the same GitHub host");
314
- }
315
- await runChecked(this.exec, "git", ["check-ref-format", "--branch", parsed.ref], this.options());
316
- return { ...parsed, fetchSource: await resolveRepositoryFetchSource(this.exec, this.options(), parsed) };
317
- }
318
- const configured = await runChecked(this.exec, "git", [
319
- "config", "--get-all", `branch.${this.target.branch}.gh-merge-base`,
320
- ], this.options(), [0, 1]);
321
- const values = configured.code === 1 && configured.stdout === "" ? [] : configuredValues(configured.stdout);
322
- if (values.length > 1) throw new Error(`Configured base is ambiguous. ${BASE_RERUN}`);
323
- if (values.length === 1) {
324
- await runChecked(this.exec, "git", ["check-ref-format", "--branch", values[0]!], this.options());
325
- const authority = await this.originAuthority();
326
- return { ...authority, ref: values[0]! };
327
- }
328
- const inferred = await this.inferBaseRef();
329
- await runChecked(this.exec, "git", ["check-ref-format", "--branch", inferred.ref], this.options());
330
- return { ...inferred.authority, ref: inferred.ref };
331
- }
332
-
333
258
  private async liveBase(): Promise<string> {
334
259
  if (!this.state.base) throw new Error("PR creation base is unavailable");
335
260
  return await readPullRequestBaseRefOid(this.pi(), this.context(), this.state.base);
@@ -362,36 +287,50 @@ export class PullRequestCreator {
362
287
  throw new Error("PR creation prepare action was already consumed");
363
288
  }
364
289
  return await withWorktreeLock(this.cwd, async () => {
290
+ this.explicitBase = explicitBase;
365
291
  await this.freshNone();
366
- const base = await this.resolveBase(explicitBase);
367
- if (base.host !== this.target.host.toLowerCase()) {
368
- throw new Error("PR creation base and head must use the same GitHub host");
292
+ const preflight = await preflightPullRequestCreation(
293
+ this.pi(),
294
+ this.context(),
295
+ this.target,
296
+ this.explicitBase,
297
+ );
298
+ if (preflight.ahead === 0) {
299
+ throw new Error("PR creation requires at least one commit ahead of the selected base");
369
300
  }
370
- const oid = await readPullRequestBaseRefOid(this.pi(), this.context(), base);
371
- this.state.base = { ...base, oid };
301
+ const { base } = preflight;
302
+ this.state.base = {
303
+ host: base.host,
304
+ repository: base.repository,
305
+ ref: base.ref,
306
+ oid: base.oid,
307
+ fetchSource: base.fetchSource,
308
+ };
372
309
  this.state.attempts.fetchBase = "attempting";
373
310
  try {
374
311
  await runChecked(this.exec, "git", [
375
- "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, oid,
312
+ "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, base.oid,
376
313
  ], this.options());
377
- await runChecked(this.exec, "git", ["cat-file", "-e", `${oid}^{commit}`], this.options());
314
+ await runChecked(this.exec, "git", ["cat-file", "-e", `${base.oid}^{commit}`], this.options());
378
315
  this.state.attempts.fetchBase = "applied";
379
316
  } catch (error) {
380
317
  this.state.attempts.fetchBase = "unknown";
381
318
  throw error;
382
319
  }
383
320
  await this.freshNone();
384
- if (await this.liveBase() !== oid) throw new Error("PR creation cancelled: base ref moved during prepare");
385
- const mergeBase = requiredOid(line((await runChecked(this.exec, "git", ["merge-base", "HEAD", oid], this.options())).stdout, "merge base"), "merge base");
321
+ if (await readHead(this.exec, this.options()) !== preflight.head) {
322
+ throw new Error("PR creation cancelled: local HEAD changed during prepare");
323
+ }
324
+ if (await this.liveBase() !== base.oid) throw new Error("PR creation cancelled: base ref moved during prepare");
386
325
  this.state.phase = "prepared";
387
- return { kind: "prepared", base: { ...this.state.base }, mergeBase };
326
+ return { kind: "prepared", base: { ...this.state.base }, mergeBase: base.mergeBase };
388
327
  }, { agentDir: this.agentDir, signal: this.signal });
389
328
  }
390
329
 
391
330
  private async verifyMerge(originalHead: string): Promise<{ head: string; fastForward: boolean }> {
392
331
  const base = this.state.base!;
393
332
  const head = await readHead(this.exec, this.options());
394
- const commits = line((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", "HEAD"], this.options())).stdout, "merge parents")
333
+ const commits = line((await runChecked(this.exec, "git", ["rev-list", "--parents", "-n", "1", head], this.options())).stdout, "merge parents")
395
334
  .split(" ").map((value, index) => requiredOid(value, index ? "merge parent" : "merged HEAD"));
396
335
  if (commits[0] !== head) throw new Error("PR creation merge verification returned a different HEAD");
397
336
  let fastForward = false;
@@ -504,14 +443,27 @@ export class PullRequestCreator {
504
443
  }, { agentDir: this.agentDir, signal: this.signal });
505
444
  }
506
445
 
507
- private async configureNoTargetUpstream(head: string): Promise<void> {
508
- const target: BranchUpstreamTarget = {
446
+ private upstreamTarget(head: string): BranchUpstreamTarget {
447
+ return {
509
448
  branch: this.target.branch,
510
449
  remote: this.target.remote,
511
450
  ref: this.target.ref,
512
451
  fetchSource: this.target.fetchSource,
513
452
  remoteOid: head,
514
453
  };
454
+ }
455
+
456
+ private async captureNoTargetUpstream(): Promise<void> {
457
+ if (!this.noTarget || this.state.originalUpstream !== undefined) return;
458
+ this.state.originalUpstream = await readBranchUpstreamConfiguration(this.pi(), this.context(), this.target.branch);
459
+ }
460
+
461
+ private async fetchNoTargetTracking(head: string): Promise<void> {
462
+ if (this.state.attempts.fetchTracking === "applied") return;
463
+ if (this.state.attempts.fetchTracking !== "none" && this.state.attempts.fetchTracking !== "unknown") {
464
+ throw new Error("PR creation tracking fetch cannot be retried");
465
+ }
466
+ const target = this.upstreamTarget(head);
515
467
  this.state.attempts.fetchTracking = "attempting";
516
468
  try {
517
469
  await fetchBranchTrackingRef(this.pi(), this.context(), target);
@@ -523,6 +475,19 @@ export class PullRequestCreator {
523
475
  this.state.attempts.fetchTracking = "unknown";
524
476
  throw error;
525
477
  }
478
+ }
479
+
480
+ private async configureNoTargetUpstream(head: string): Promise<void> {
481
+ const original = this.state.originalUpstream;
482
+ if (!original) throw new Error("PR creation original branch upstream is unavailable");
483
+ if (!sameUpstreamConfiguration(
484
+ await readBranchUpstreamConfiguration(this.pi(), this.context(), this.target.branch),
485
+ original,
486
+ )) {
487
+ throw new Error("PR creation cancelled: branch upstream changed before setup");
488
+ }
489
+ await this.fetchNoTargetTracking(head);
490
+ const target = this.upstreamTarget(head);
526
491
  this.state.attempts.setUpstream = "attempting";
527
492
  try {
528
493
  await setBranchUpstream(this.pi(), this.context(), target);
@@ -530,6 +495,13 @@ export class PullRequestCreator {
530
495
  this.state.attempts.setUpstream = "applied";
531
496
  } catch (error) {
532
497
  this.state.attempts.setUpstream = "unknown";
498
+ const rollbackContext = { cwd: this.cwd, signal: new AbortController().signal };
499
+ try {
500
+ await restoreBranchUpstreamConfiguration(this.pi(), rollbackContext, target, original);
501
+ } catch {
502
+ this.state.phase = "blocked";
503
+ throw new Error("PR creation upstream setup failed and rollback was incomplete");
504
+ }
533
505
  throw error;
534
506
  }
535
507
  }
@@ -553,6 +525,7 @@ export class PullRequestCreator {
553
525
  await this.freshNone();
554
526
  if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
555
527
  if (await this.requireCleanHead() !== head) throw new Error("PR creation cancelled: local HEAD changed before push");
528
+ if (this.noTarget) await this.captureNoTargetUpstream();
556
529
  this.state.publicationHead = head;
557
530
  this.state.attempts.push = "attempting";
558
531
  try {
@@ -569,7 +542,7 @@ export class PullRequestCreator {
569
542
  throw error;
570
543
  }
571
544
  this.state.phase = "pushed";
572
- if (this.noTarget) await this.configureNoTargetUpstream(head);
545
+ if (this.noTarget) await this.fetchNoTargetTracking(head);
573
546
  return { kind: "pushed", head };
574
547
  }, { agentDir: this.agentDir, signal: this.signal });
575
548
  }
@@ -587,7 +560,7 @@ export class PullRequestCreator {
587
560
  }
588
561
  return;
589
562
  }
590
- const discovery = await this.load(this.pi(), this.context());
563
+ const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
591
564
  if (discovery.kind === "none") {
592
565
  if (!sameTarget(this.target, discovery.creationTarget, head)) throw new Error("Published target authority changed");
593
566
  return;
@@ -614,9 +587,16 @@ export class PullRequestCreator {
614
587
  }
615
588
 
616
589
  async publish(titleInput: string, body: string): Promise<CreatePullRequestResult> {
617
- if (this.state.phase !== "pushed" || !this.state.base || !this.state.publicationHead || this.state.attempts.pullRequest !== "none") {
590
+ const retryingUpstream = this.noTarget && this.state.attempts.pullRequest === "applied";
591
+ if (
592
+ this.state.phase !== "pushed" || !this.state.base || !this.state.publicationHead ||
593
+ (this.state.attempts.pullRequest !== "none" && !retryingUpstream)
594
+ ) {
618
595
  throw new Error("PR creation is not ready to publish metadata");
619
596
  }
597
+ if (this.noTarget && !this.state.originalUpstream) {
598
+ throw new Error("PR creation original branch upstream is unavailable");
599
+ }
620
600
  const title = requiredText(titleInput, "pull request title");
621
601
  if (Buffer.byteLength(title, "utf8") > MAX_TITLE_BYTES) throw new Error(`Pull request title exceeds ${MAX_TITLE_BYTES} bytes`);
622
602
  if (typeof body !== "string" || body.includes("\0") || Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
@@ -629,56 +609,66 @@ export class PullRequestCreator {
629
609
  if (before && (before.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() || before.base.ref !== this.state.base!.ref)) {
630
610
  throw new Error("Exact-head pull request targets a different base");
631
611
  }
632
- const repository = `${this.state.base!.host}/${this.state.base!.repository}`;
633
- const sameRepository = this.state.base!.repository.toLowerCase() === this.target.repository.toLowerCase();
634
- const organizationAuthority = !sameRepository && this.state.createAuthority?.headOwnerType === "Organization"
635
- ? this.state.createAuthority
636
- : undefined;
637
- if (!sameRepository && !this.state.createAuthority) {
638
- throw new Error("Cross-repository PR creation was not preflighted before push");
639
- }
640
- const args = before
641
- ? ["pr", "edit", String(before.number), "--repo", repository, "--title", title, "--body-file", "-"]
642
- : organizationAuthority
643
- ? [
644
- "api", "graphql", "--hostname", this.state.base!.host,
645
- "-f", `query=${CREATE_PULL_REQUEST_MUTATION}`,
646
- "-f", `repositoryId=${organizationAuthority.baseRepositoryId}`,
647
- "-f", `baseRefName=${this.state.base!.ref}`,
648
- "-f", `headRepositoryId=${organizationAuthority.headRepositoryId}`,
649
- "-f", `headRefName=${this.target.ref}`,
650
- "-f", `title=${title}`,
651
- "-f", `body=${body}`,
652
- ]
653
- : [
654
- "pr", "create", "--repo", repository, "--head",
655
- sameRepository ? this.target.ref : `${this.target.repository.split("/")[0]}:${this.target.ref}`,
656
- "--base", this.state.base!.ref, "--title", title, "--body-file", "-",
657
- ];
658
- this.state.attempts.pullRequest = "attempting";
659
- try {
660
- const created = await runChecked(this.exec, "gh", args, this.options({ stdin: organizationAuthority ? undefined : body }));
661
- if (!before && organizationAuthority) {
662
- parseCreatedUrl(created.stdout, this.state.base!.host, this.state.base!.repository);
612
+ let after: PullRequestPublication;
613
+ if (retryingUpstream) {
614
+ if (!before || before.title !== title || before.body !== body) {
615
+ throw new Error("Published pull request changed before upstream completion");
663
616
  }
664
- } catch (error) {
665
- this.state.attempts.pullRequest = "unknown";
666
- throw error;
667
- }
668
- try {
669
- const after = await this.exactCandidate();
670
- if (!after || after.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() ||
671
- after.base.ref !== this.state.base!.ref || after.title !== title || after.body !== body) {
672
- throw new Error("Published pull request did not retain canonical identity, title, and body");
617
+ after = before;
618
+ } else {
619
+ const repository = `${this.state.base!.host}/${this.state.base!.repository}`;
620
+ const sameRepository = this.state.base!.repository.toLowerCase() === this.target.repository.toLowerCase();
621
+ const organizationAuthority = !sameRepository && this.state.createAuthority?.headOwnerType === "Organization"
622
+ ? this.state.createAuthority
623
+ : undefined;
624
+ if (!sameRepository && !this.state.createAuthority) {
625
+ throw new Error("Cross-repository PR creation was not preflighted before push");
626
+ }
627
+ const args = before
628
+ ? ["pr", "edit", String(before.number), "--repo", repository, "--title", title, "--body-file", "-"]
629
+ : organizationAuthority
630
+ ? [
631
+ "api", "graphql", "--hostname", this.state.base!.host,
632
+ "-f", `query=${CREATE_PULL_REQUEST_MUTATION}`,
633
+ "-f", `repositoryId=${organizationAuthority.baseRepositoryId}`,
634
+ "-f", `baseRefName=${this.state.base!.ref}`,
635
+ "-f", `headRepositoryId=${organizationAuthority.headRepositoryId}`,
636
+ "-f", `headRefName=${this.target.ref}`,
637
+ "-f", `title=${title}`,
638
+ "-f", `body=${body}`,
639
+ ]
640
+ : [
641
+ "pr", "create", "--repo", repository, "--head",
642
+ sameRepository ? this.target.ref : `${this.target.repository.split("/")[0]}:${this.target.ref}`,
643
+ "--base", this.state.base!.ref, "--title", title, "--body-file", "-",
644
+ ];
645
+ this.state.attempts.pullRequest = "attempting";
646
+ try {
647
+ const created = await runChecked(this.exec, "gh", args, this.options({ stdin: organizationAuthority ? undefined : body }));
648
+ if (!before && organizationAuthority) {
649
+ parseCreatedUrl(created.stdout, this.state.base!.host, this.state.base!.repository);
650
+ }
651
+ } catch (error) {
652
+ this.state.attempts.pullRequest = "unknown";
653
+ throw error;
654
+ }
655
+ try {
656
+ const refreshed = await this.exactCandidate();
657
+ if (!refreshed || refreshed.base.repository.toLowerCase() !== this.state.base!.repository.toLowerCase() ||
658
+ refreshed.base.ref !== this.state.base!.ref || refreshed.title !== title || refreshed.body !== body) {
659
+ throw new Error("Published pull request did not retain canonical identity, title, and body");
660
+ }
661
+ after = refreshed;
662
+ this.state.attempts.pullRequest = "applied";
663
+ } catch (error) {
664
+ this.state.attempts.pullRequest = "unknown";
665
+ throw error;
673
666
  }
674
- this.state.attempts.pullRequest = "applied";
675
- this.state.phase = "published";
676
- this.state.url = after.url.href;
677
- return { kind: "published", url: after.url.href };
678
- } catch (error) {
679
- this.state.attempts.pullRequest = "unknown";
680
- throw error;
681
667
  }
668
+ if (this.noTarget) await this.configureNoTargetUpstream(this.state.publicationHead!);
669
+ this.state.phase = "published";
670
+ this.state.url = after.url.href;
671
+ return { kind: "published", url: after.url.href };
682
672
  }, { agentDir: this.agentDir, signal: this.signal });
683
673
  }
684
674
  }
@@ -105,6 +105,24 @@ export type PullRequestObservation = {
105
105
 
106
106
  export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
107
107
 
108
+ export type PullRequestCreationPreflight = {
109
+ head: string;
110
+ base: {
111
+ host: string;
112
+ repository: string;
113
+ fetchSource: string;
114
+ ref: string;
115
+ oid: string;
116
+ mergeBase: string;
117
+ };
118
+ ahead: number;
119
+ };
120
+
121
+ type CreationIdentity = {
122
+ target: PullRequestTarget;
123
+ head: string;
124
+ };
125
+
108
126
  type CommandOutput = {
109
127
  stdout: string;
110
128
  stderr: string;
@@ -187,6 +205,11 @@ export type BranchUpstreamTarget = {
187
205
  remoteOid: string;
188
206
  };
189
207
 
208
+ export type BranchUpstreamConfiguration = {
209
+ remote: string[];
210
+ merge: string[];
211
+ };
212
+
190
213
  type SearchSelection =
191
214
  | { kind: "candidate"; candidate: SearchPullRequest; pullRequest: ListedPullRequest | null }
192
215
  | { kind: "none" }
@@ -332,6 +355,12 @@ function parseCommandOutput(value: unknown, action: string): CommandOutput {
332
355
  return { stdout, stderr, code, killed };
333
356
  }
334
357
 
358
+ function hasExactKeys(value: Record<string, unknown>, keys: string[]): boolean {
359
+ const actual = Object.keys(value).sort();
360
+ const expected = [...keys].sort();
361
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
362
+ }
363
+
335
364
  async function invoke(
336
365
  pi: Pick<ExtensionAPI, "exec">,
337
366
  context: PullRequestLoadContext,
@@ -1050,27 +1079,232 @@ function parseMergeMethodSettings(output: string, rulesetMethods: MergeMethod[]
1050
1079
  return { allowedMergeMethods, viewerDefaultMergeMethod };
1051
1080
  }
1052
1081
 
1053
- export async function hasLocalCommit(
1082
+ function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
1083
+ if (!isRecord(target)) fail("Read creation target", "invalid target");
1084
+ if (target.provenance !== "configured" && target.provenance !== "inferred") {
1085
+ fail("Read creation target", "invalid provenance");
1086
+ }
1087
+ return {
1088
+ provenance: target.provenance,
1089
+ branch: text(target.branch, "Read creation target", "branch"),
1090
+ remote: text(target.remote, "Read creation target", "remote"),
1091
+ ref: text(target.ref, "Read creation target", "ref"),
1092
+ repository: repositoryName(target.repository, "Read creation target", "repository"),
1093
+ host: text(target.host, "Read creation target", "host").toLowerCase(),
1094
+ fetchSource: text(target.fetchSource, "Read creation target", "fetch source"),
1095
+ remoteOid: target.remoteOid === null ? null : oid(target.remoteOid, "Read creation target", "remote OID"),
1096
+ };
1097
+ }
1098
+
1099
+ function sameCreationTarget(left: PullRequestTarget, right: PullRequestTarget): boolean {
1100
+ return left.provenance === right.provenance && left.branch === right.branch &&
1101
+ left.remote === right.remote && left.ref === right.ref &&
1102
+ normalizeRepository(left.repository) === normalizeRepository(right.repository) &&
1103
+ left.host === right.host && left.fetchSource === right.fetchSource && left.remoteOid === right.remoteOid;
1104
+ }
1105
+
1106
+ async function validateCreationRef(
1054
1107
  pi: Pick<ExtensionAPI, "exec">,
1055
1108
  context: PullRequestLoadContext,
1056
- ): Promise<boolean> {
1109
+ ref: string,
1110
+ ): Promise<string> {
1111
+ const requested = text(ref, "Validate creation base", "ref");
1112
+ const checked = singleLine(
1113
+ (await execute(pi, context, "Validate creation base", "git", ["check-ref-format", "--branch", requested])).stdout,
1114
+ "Validate creation base",
1115
+ "ref",
1116
+ );
1117
+ if (checked !== requested) fail("Validate creation base", "ref changed");
1118
+ return requested;
1119
+ }
1120
+
1121
+ async function captureCreationIdentity(
1122
+ pi: Pick<ExtensionAPI, "exec">,
1123
+ context: PullRequestLoadContext,
1124
+ target: PullRequestTarget,
1125
+ ): Promise<CreationIdentity> {
1126
+ const validatedTarget = validatedCreationTarget(target);
1057
1127
  const branch = singleLine(
1058
- (await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])).stdout,
1059
- "Read current branch",
1128
+ (await execute(pi, context, "Read creation branch", "git", ["branch", "--show-current"])).stdout,
1129
+ "Read creation branch",
1060
1130
  "branch",
1061
1131
  );
1062
- const output = (await execute(pi, context, "Read branch history", "git", [
1063
- "reflog",
1064
- "show",
1065
- "--format=%H",
1066
- `refs/heads/${branch}`,
1067
- ])).stdout.replace(/\r\n/g, "\n");
1068
- const entries = output.split("\n");
1069
- if (entries.at(-1) === "") entries.pop();
1070
- if (!entries.length) fail("Read branch history", "missing branch creation entry");
1071
- const commits = entries.map((entry) => oid(entry, "Read branch history", "commit"));
1072
- // ponytail: reflog expiry can hide old branch history; resolve the PR base if this becomes observable.
1073
- return commits[0] !== commits.at(-1);
1132
+ if (branch !== validatedTarget.branch) fail("Read creation branch", "branch changed");
1133
+ await validateCreationRef(pi, context, branch);
1134
+ const head = oid(singleLine(
1135
+ (await execute(pi, context, "Read creation HEAD", "git", ["rev-parse", "--verify", "HEAD^{commit}"])).stdout,
1136
+ "Read creation HEAD",
1137
+ "OID",
1138
+ ), "Read creation HEAD", "OID");
1139
+ return { target: validatedTarget, head };
1140
+ }
1141
+
1142
+ async function readConfiguredCreationBaseRef(
1143
+ pi: Pick<ExtensionAPI, "exec">,
1144
+ context: PullRequestLoadContext,
1145
+ branch: string,
1146
+ ): Promise<string | null> {
1147
+ const result = await invoke(pi, context, "Read creation base configuration", "git", [
1148
+ "config", "--get-all", `branch.${branch}.gh-merge-base`,
1149
+ ]);
1150
+ if (result.killed) commandFailure("Read creation base configuration", result);
1151
+ if (result.code === 1 && result.stdout === "" && result.stderr === "") return null;
1152
+ if (result.code !== 0) commandFailure("Read creation base configuration", result);
1153
+ if (result.stderr !== "") fail("Read creation base configuration", "unexpected diagnostic");
1154
+ const values = lines(result.stdout, "Read creation base configuration", "base ref");
1155
+ if (values.length !== 1) fail("Read creation base configuration", "multiple base refs");
1156
+ return await validateCreationRef(pi, context, values[0]!);
1157
+ }
1158
+
1159
+ function parseDefaultCreationBaseRef(output: string): string {
1160
+ const value = parseJson(output, "Read creation default branch");
1161
+ if (!isRecord(value) || !hasExactKeys(value, ["defaultBranchRef"]) || !isRecord(value.defaultBranchRef) ||
1162
+ !hasExactKeys(value.defaultBranchRef, ["name"])) {
1163
+ fail("Read creation default branch", "invalid GitHub CLI output");
1164
+ }
1165
+ return text(value.defaultBranchRef.name, "Read creation default branch", "default branch ref");
1166
+ }
1167
+
1168
+ async function readDefaultCreationBaseRef(
1169
+ pi: Pick<ExtensionAPI, "exec">,
1170
+ context: PullRequestLoadContext,
1171
+ origin: { repository: PushRepository },
1172
+ ): Promise<string> {
1173
+ const result = await execute(pi, context, "Read creation default branch", "gh", [
1174
+ "repo", "view", `${origin.repository.host}/${origin.repository.nameWithOwner}`, "--json", "defaultBranchRef",
1175
+ ]);
1176
+ if (result.stderr !== "") fail("Read creation default branch", "unexpected diagnostic");
1177
+ return await validateCreationRef(pi, context, parseDefaultCreationBaseRef(result.stdout));
1178
+ }
1179
+
1180
+ function parseCreationRepositoryLineage(output: string, expected: PushRepository): string {
1181
+ const value = parseJson(output, "Read creation repository");
1182
+ if (!isRecord(value)) fail("Read creation repository", "invalid GitHub CLI output");
1183
+ const fullName = repositoryName(value.full_name, "Read creation repository", "full_name");
1184
+ const url = parseHttpUrl(value.html_url, "Read creation repository", "html_url");
1185
+ if (
1186
+ normalizeRepository(fullName) !== expected.normalizedName || url.protocol !== "https:" || url.port ||
1187
+ url.hostname.toLowerCase() !== expected.host || url.pathname.toLowerCase() !== `/${expected.normalizedName}`
1188
+ ) fail("Read creation repository", "response does not match repository");
1189
+ const source = value.source;
1190
+ if (source !== undefined && source !== null && !isRecord(source)) {
1191
+ fail("Read creation repository", "invalid source");
1192
+ }
1193
+ const sourceName = source === undefined || source === null
1194
+ ? fullName
1195
+ : repositoryName(source.full_name, "Read creation repository", "source.full_name");
1196
+ return normalizeRepository(sourceName);
1197
+ }
1198
+
1199
+ async function assertCreationRepositoryRelation(
1200
+ pi: Pick<ExtensionAPI, "exec">,
1201
+ context: PullRequestLoadContext,
1202
+ target: PullRequestTarget,
1203
+ origin: { repository: PushRepository },
1204
+ baseRef: string,
1205
+ ): Promise<void> {
1206
+ if (target.host !== origin.repository.host) {
1207
+ fail("Read creation repository", "base and head hosts do not match");
1208
+ }
1209
+ if (normalizeRepository(target.repository) === origin.repository.normalizedName) {
1210
+ if (target.ref === baseRef) fail("Read creation repository", "head and base refs match");
1211
+ return;
1212
+ }
1213
+ const read = async (repository: PushRepository): Promise<string> => {
1214
+ const [owner, name] = repository.nameWithOwner.split("/");
1215
+ const result = await execute(pi, context, "Read creation repository", "gh", [
1216
+ "api", "--hostname", origin.repository.host, `repos/${owner}/${name}`,
1217
+ ]);
1218
+ if (result.stderr !== "") fail("Read creation repository", "unexpected diagnostic");
1219
+ return parseCreationRepositoryLineage(result.stdout, repository);
1220
+ };
1221
+ const originSource = await read(origin.repository);
1222
+ const targetSource = await read({
1223
+ nameWithOwner: target.repository,
1224
+ normalizedName: normalizeRepository(target.repository),
1225
+ host: target.host,
1226
+ });
1227
+ if (originSource !== targetSource) fail("Read creation repository", "base and head are unrelated");
1228
+ }
1229
+
1230
+ function parseCreationAhead(output: string): number {
1231
+ const value = singleLine(output, "Count creation commits", "ahead count");
1232
+ if (!/^(?:0|[1-9][0-9]*)$/.test(value)) fail("Count creation commits", "invalid ahead count");
1233
+ const ahead = Number(value);
1234
+ if (!Number.isSafeInteger(ahead) || ahead < 0) fail("Count creation commits", "invalid ahead count");
1235
+ return ahead;
1236
+ }
1237
+
1238
+ async function preflightCreation(
1239
+ pi: Pick<ExtensionAPI, "exec">,
1240
+ context: PullRequestLoadContext,
1241
+ target: PullRequestTarget,
1242
+ explicitBaseRef: string | undefined,
1243
+ identity: CreationIdentity,
1244
+ ): Promise<PullRequestCreationPreflight> {
1245
+ const validatedTarget = validatedCreationTarget(target);
1246
+ if (!sameCreationTarget(identity.target, validatedTarget)) {
1247
+ fail("Read creation target", "target changed");
1248
+ }
1249
+ const origin = await readRemoteAuthority(pi, context, "origin", true);
1250
+ if (!origin) fail("Read creation repository", "origin is unavailable");
1251
+ const configuredBaseRef = explicitBaseRef === undefined
1252
+ ? await readConfiguredCreationBaseRef(pi, context, identity.target.branch)
1253
+ : await validateCreationRef(pi, context, explicitBaseRef);
1254
+ const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
1255
+ await assertCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
1256
+ const trackingRef = `refs/remotes/origin/${baseRef}`;
1257
+ await execute(pi, context, "Fetch creation base", "git", [
1258
+ "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
1259
+ origin.fetchSource, `+refs/heads/${baseRef}:${trackingRef}`,
1260
+ ]);
1261
+ const baseOid = oid(singleLine(
1262
+ (await execute(pi, context, "Read creation base", "git", ["rev-parse", "--verify", `${trackingRef}^{commit}`])).stdout,
1263
+ "Read creation base",
1264
+ "OID",
1265
+ ), "Read creation base", "OID");
1266
+ const mergeBase = oid(singleLine(
1267
+ (await execute(pi, context, "Find creation merge base", "git", ["merge-base", identity.head, baseOid])).stdout,
1268
+ "Find creation merge base",
1269
+ "OID",
1270
+ ), "Find creation merge base", "OID");
1271
+ const ahead = parseCreationAhead((await execute(pi, context, "Count creation commits", "git", [
1272
+ "rev-list", "--count", `${mergeBase}..${identity.head}`,
1273
+ ])).stdout);
1274
+ return {
1275
+ head: identity.head,
1276
+ base: {
1277
+ host: origin.repository.host,
1278
+ repository: origin.repository.nameWithOwner,
1279
+ fetchSource: origin.fetchSource,
1280
+ ref: baseRef,
1281
+ oid: baseOid,
1282
+ mergeBase,
1283
+ },
1284
+ ahead,
1285
+ };
1286
+ }
1287
+
1288
+ export async function preflightPullRequestCreation(
1289
+ pi: Pick<ExtensionAPI, "exec">,
1290
+ context: PullRequestLoadContext,
1291
+ target: PullRequestTarget,
1292
+ explicitBaseRef?: string,
1293
+ ): Promise<PullRequestCreationPreflight> {
1294
+ const identity = await captureCreationIdentity(pi, context, target);
1295
+ return await preflightCreation(pi, context, target, explicitBaseRef, identity);
1296
+ }
1297
+
1298
+ async function creationDiscovery(
1299
+ pi: Pick<ExtensionAPI, "exec">,
1300
+ context: PullRequestLoadContext,
1301
+ target: PullRequestTarget,
1302
+ explicitBaseRef: string | undefined,
1303
+ identity?: CreationIdentity,
1304
+ ): Promise<CurrentPullRequestDiscovery> {
1305
+ const captured = identity ?? await captureCreationIdentity(pi, context, target);
1306
+ const preflight = await preflightCreation(pi, context, target, explicitBaseRef, captured);
1307
+ return { kind: "none", creationTarget: target, branch: { ahead: preflight.ahead } };
1074
1308
  }
1075
1309
 
1076
1310
  async function readRemoteAuthority(
@@ -1167,6 +1401,22 @@ async function readConfigValues(
1167
1401
  }
1168
1402
  }
1169
1403
 
1404
+ export async function readBranchUpstreamConfiguration(
1405
+ pi: Pick<ExtensionAPI, "exec">,
1406
+ context: PullRequestLoadContext,
1407
+ branch: string,
1408
+ ): Promise<BranchUpstreamConfiguration> {
1409
+ const checkedBranch = text(branch, "Read branch upstream", "branch");
1410
+ const [remote, merge] = await Promise.all([
1411
+ readConfigValues(pi, context, `branch.${checkedBranch}.remote`),
1412
+ readConfigValues(pi, context, `branch.${checkedBranch}.merge`),
1413
+ ]);
1414
+ if (remote === null || merge === null) {
1415
+ throw new Error("Read branch upstream failed: invalid Git configuration");
1416
+ }
1417
+ return { remote, merge };
1418
+ }
1419
+
1170
1420
  async function readBooleanConfigValues(
1171
1421
  pi: Pick<ExtensionAPI, "exec">,
1172
1422
  context: PullRequestLoadContext,
@@ -1694,6 +1944,18 @@ export async function loadCurrentPullRequest(
1694
1944
  context: PullRequestLoadContext,
1695
1945
  inspectedLocal?: LocalMergeSafety,
1696
1946
  observed?: unknown,
1947
+ explicitCreationBase?: string,
1948
+ ): Promise<CurrentPullRequestDiscovery> {
1949
+ return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase);
1950
+ }
1951
+
1952
+ async function loadCurrentPullRequestInternal(
1953
+ pi: Pick<ExtensionAPI, "exec">,
1954
+ context: PullRequestLoadContext,
1955
+ inspectedLocal: LocalMergeSafety | undefined,
1956
+ observed: unknown,
1957
+ explicitCreationBase: string | undefined,
1958
+ creationIdentity?: CreationIdentity,
1697
1959
  ): Promise<CurrentPullRequestDiscovery> {
1698
1960
  const read = await readPushTarget(pi, context);
1699
1961
  if (read.kind === "inactive") return { kind: "inactive" };
@@ -1714,10 +1976,18 @@ export async function loadCurrentPullRequest(
1714
1976
  };
1715
1977
  }
1716
1978
  if (inferred.kind === "none") {
1979
+ const target = publicTarget(inferred.target);
1980
+ if (creationIdentity === undefined) {
1981
+ const captured = await captureCreationIdentity(pi, context, target);
1982
+ return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase, captured);
1983
+ }
1984
+ if (!sameCreationTarget(creationIdentity.target, validatedCreationTarget(target))) {
1985
+ fail("Read creation target", "target changed");
1986
+ }
1717
1987
  if (!canLinkTarget(await readLinkConfiguration(pi, context, inferred.target), inferred.target)) {
1718
1988
  return { kind: "blocked", issue: { kind: "link-configuration", remote: inferred.target.remote } };
1719
1989
  }
1720
- return { kind: "none", creationTarget: publicTarget(inferred.target) };
1990
+ return await creationDiscovery(pi, context, target, explicitCreationBase, creationIdentity);
1721
1991
  }
1722
1992
  pushTarget = inferred.target;
1723
1993
  } else {
@@ -1814,7 +2084,10 @@ export async function loadCurrentPullRequest(
1814
2084
  }
1815
2085
  throw error;
1816
2086
  }
1817
- if (candidate === null) return { kind: "none", creationTarget: publicTarget(pushTarget) };
2087
+ if (candidate === null) {
2088
+ if (creationIdentity !== undefined) fail("Read creation target", "target changed");
2089
+ return await creationDiscovery(pi, context, publicTarget(pushTarget), explicitCreationBase);
2090
+ }
1818
2091
  }
1819
2092
 
1820
2093
  return {
@@ -1942,6 +2215,42 @@ function sameConfigValues(left: readonly string[], right: readonly string[]): bo
1942
2215
  return left.length === right.length && left.every((value, index) => value === right[index]);
1943
2216
  }
1944
2217
 
2218
+ export async function restoreBranchUpstreamConfiguration(
2219
+ pi: Pick<ExtensionAPI, "exec">,
2220
+ context: PullRequestLoadContext,
2221
+ target: Pick<BranchUpstreamTarget, "branch" | "remote" | "ref">,
2222
+ original: BranchUpstreamConfiguration,
2223
+ ): Promise<void> {
2224
+ let incomplete = false;
2225
+ for (const [key, expected, values] of [
2226
+ [`branch.${target.branch}.remote`, target.remote, original.remote],
2227
+ [`branch.${target.branch}.merge`, `refs/heads/${target.ref}`, original.merge],
2228
+ ] as const) {
2229
+ try {
2230
+ const current = await readConfigValues(pi, context, key);
2231
+ if (current === null) incomplete = true;
2232
+ else if (sameConfigValues(current, values)) continue;
2233
+ else if (current.length === 1 && current[0] === expected) {
2234
+ await restoreConfigValue(pi, context, key, expected, values);
2235
+ } else incomplete = true;
2236
+ } catch {
2237
+ incomplete = true;
2238
+ }
2239
+ }
2240
+ for (const [key, values] of [
2241
+ [`branch.${target.branch}.remote`, original.remote],
2242
+ [`branch.${target.branch}.merge`, original.merge],
2243
+ ] as const) {
2244
+ try {
2245
+ const current = await readConfigValues(pi, context, key);
2246
+ if (current === null || !sameConfigValues(current, values)) incomplete = true;
2247
+ } catch {
2248
+ incomplete = true;
2249
+ }
2250
+ }
2251
+ if (incomplete) throw new Error("Restore branch upstream failed and rollback was incomplete");
2252
+ }
2253
+
1945
2254
  async function restoreLinkState(
1946
2255
  pi: Pick<ExtensionAPI, "exec">,
1947
2256
  context: PullRequestLoadContext,
@@ -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
 
package/extensions/pr.ts CHANGED
@@ -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;
@@ -573,7 +575,6 @@ export default function pullRequestExtension(
573
575
  const render = (
574
576
  ctx: ExtensionContext,
575
577
  discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
576
- localCommit: boolean,
577
578
  ): void => {
578
579
  if (discovery.kind === "inactive") {
579
580
  if (timer !== undefined) clearInterval(timer);
@@ -585,7 +586,7 @@ export default function pullRequestExtension(
585
586
  reconcileWidget(ctx);
586
587
  return;
587
588
  }
588
- const display = projectPrDisplay(discovery, localCommit);
589
+ const display = projectPrDisplay(discovery);
589
590
  const footer = formatPrFooter(display, ctx.ui.theme);
590
591
  if ((discovery.kind === "current" || discovery.kind === "blocked") && footer === undefined) {
591
592
  throw new Error("Pull request display is missing a footer");
@@ -621,7 +622,6 @@ export default function pullRequestExtension(
621
622
  lastBlockedIssueKey = undefined;
622
623
  delegatedWorkPending = false;
623
624
  pendingWorkspaceRename = false;
624
- mergeCompleted = false;
625
625
  displayedWidget = undefined;
626
626
  commandGeneration = 0;
627
627
  clearWorkflow(workflowContext);
@@ -668,21 +668,17 @@ export default function pullRequestExtension(
668
668
  active = controller;
669
669
  try {
670
670
  let discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
671
- let localCommit = false;
672
671
  try {
673
672
  discovery = await load(pi, loadContext);
674
673
  if (controller.signal.aborted || sessionGeneration !== generation) return;
675
- if (discovery.kind === "none") {
676
- localCommit = !mergeCompleted && await detectLocalCommit(pi, loadContext);
677
- }
678
674
  } catch {
679
- // Keep an established display. A cold Git-worktree failure gets a sanitized placeholder.
675
+ // Keep an established footer. A refresh failure must not leave a stale action hint.
680
676
  if (!controller.signal.aborted && sessionGeneration === generation) {
677
+ displayedWidget = undefined;
678
+ reconcileWidget(ctx);
681
679
  if (!displayEstablished) {
682
680
  const unavailable = unavailablePrDisplay();
683
- displayedWidget = undefined;
684
681
  ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
685
- reconcileWidget(ctx);
686
682
  displayEstablished = true;
687
683
  }
688
684
  reportRefreshFailure();
@@ -690,7 +686,7 @@ export default function pullRequestExtension(
690
686
  return;
691
687
  }
692
688
  if (controller.signal.aborted || sessionGeneration !== generation) return;
693
- render(ctx, discovery, localCommit);
689
+ render(ctx, discovery);
694
690
  refreshFailureReported = false;
695
691
 
696
692
  const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
@@ -786,7 +782,6 @@ export default function pullRequestExtension(
786
782
  if (!isBashToolResult(event)) return;
787
783
  const command = event.input.command;
788
784
  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
785
  await refresh().catch(reportRefreshFailure);
791
786
  }
792
787
  });
@@ -798,7 +793,7 @@ export default function pullRequestExtension(
798
793
  releaseWorkflow,
799
794
  });
800
795
  pi.registerCommand("pr", {
801
- description: "[--base=<host>/<owner>/<repository>:<ref>] — Run the current branch pull request next step",
796
+ description: "[--base <branch>] [instructions] — Run the current branch pull request next step",
802
797
  handler: async (args, ctx) => {
803
798
  if (!ctx.hasUI || !context) return;
804
799
  const generation = sessionGeneration;
@@ -838,7 +833,6 @@ export default function pullRequestExtension(
838
833
  if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
839
834
  reconcileWidget(ctx);
840
835
  } else {
841
- if (nextStep === "merge") mergeCompleted = true;
842
836
  activeInvocations.delete(invocation);
843
837
  refreshInBackground();
844
838
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -5,12 +5,12 @@ description: Prepare and publish the current branch pull request with determinis
5
5
 
6
6
  # Pi PR Create
7
7
 
8
- Use the `pi_pr_create` helper for base, merge, push, upstream, and GitHub mechanics. Do not reproduce its checks or commands with shell tools.
8
+ Use only `pi_pr_create` for base selection, merge, push, upstream, and GitHub work. Do not repeat its Git or GitHub checks with shell tools.
9
9
 
10
- Use the prepared base and merge-base to inspect the live change. Separate pending work into coherent commits. Preserve coherent existing staging. Exclude `.context/` and unrelated changes. Stop when changes cannot be separated safely.
10
+ Start with the prompted `prepare` action. It uses the saved branch base: explicit `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then validated `origin` default. It requires a committed change ahead. Dirty work alone cannot start this route. The base is always `origin`; fork heads must share its GitHub source and host. Apply trailing prompt guidance only to the PR content.
11
11
 
12
- If the helper reports conflicts, inspect only its returned paths and bounded conflict hunks. Resolve only clear intent, then declare the complete resolved path set to the helper. Ask the user when the correct behavior is unclear.
12
+ After `prepare`, inspect the returned base and merge-base. Separate and commit coherent pending work. Preserve coherent staging. Exclude `.context/` and unrelated changes. Stop when separation is unsafe.
13
13
 
14
- Choose and run the smallest relevant validation after the helper verifies the merge. Stop on failure.
14
+ Use this action order: `prepare`, `merge`, optional `continue`, `push`, then `publish`. On conflict, inspect only returned paths and bounded hunks. Give `continue` every resolved path. Run the smallest relevant validation after the helper verifies the merge.
15
15
 
16
- Write a concise Conventional Commit title. Write a body with `Summary` and `Testing` sections that matches the live diff and checks. Give both to the helper only after it has published the captured head. Reply with only the helper's validated pull request URL.
16
+ Give `publish` a concise Conventional Commit title and a body with `Summary` and `Testing`. It validates the exact PR before no-target upstream setup. If that setup fails after publication, retry `publish` with the same title and body. Do not push again or create another PR. Reply only with the validated PR URL.