@senad-d/branchme 0.3.0 → 0.3.1

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/src/constants.ts CHANGED
@@ -2,6 +2,10 @@ export const EXTENSION_DISPLAY_NAME = "BranchMe";
2
2
  export const BRANCHME_COMMAND_NAME = "branchme";
3
3
 
4
4
  export const BRANCH_STATUS_TOOL_NAME = "branch_status";
5
+ export const LIST_BRANCHES_TOOL_NAME = "list_branches";
6
+ export const TRACK_BRANCH_TOOL_NAME = "track_branch";
7
+ export const UPDATE_FROM_BASE_TOOL_NAME = "update_from_base";
8
+ export const INIT_REPOSITORY_TOOL_NAME = "init_repository";
5
9
  export const CREATE_BRANCH_TOOL_NAME = "create_branch";
6
10
  export const CHANGE_BRANCH_TOOL_NAME = "change_branch";
7
11
  export const FETCH_BRANCH_TOOL_NAME = "fetch_branch";
@@ -9,6 +13,7 @@ export const PULL_BRANCH_TOOL_NAME = "pull_branch";
9
13
  export const REBASE_BRANCH_TOOL_NAME = "rebase_branch";
10
14
  export const PUSH_BRANCH_TOOL_NAME = "push_branch";
11
15
  export const PULL_REQUEST_TOOL_NAME = "pull_request";
16
+ export const PULL_REQUEST_STATUS_TOOL_NAME = "pull_request_status";
12
17
  export const LIST_WORKTREES_TOOL_NAME = "list_worktrees";
13
18
  export const CREATE_WORKTREE_TOOL_NAME = "create_worktree";
14
19
  export const REMOVE_WORKTREE_TOOL_NAME = "remove_worktree";
@@ -19,6 +24,10 @@ export const PULL_REQUEST_AUTOFILL_ENV_NAME = "BRANCHME_PR_AUTOFILL";
19
24
 
20
25
  export const BRANCHME_TOOL_NAMES = [
21
26
  BRANCH_STATUS_TOOL_NAME,
27
+ LIST_BRANCHES_TOOL_NAME,
28
+ TRACK_BRANCH_TOOL_NAME,
29
+ UPDATE_FROM_BASE_TOOL_NAME,
30
+ INIT_REPOSITORY_TOOL_NAME,
22
31
  CREATE_BRANCH_TOOL_NAME,
23
32
  CHANGE_BRANCH_TOOL_NAME,
24
33
  FETCH_BRANCH_TOOL_NAME,
@@ -29,6 +38,7 @@ export const BRANCHME_TOOL_NAMES = [
29
38
  LAND_BRANCH_TOOL_NAME,
30
39
  PUSH_BRANCH_TOOL_NAME,
31
40
  PULL_REQUEST_TOOL_NAME,
41
+ PULL_REQUEST_STATUS_TOOL_NAME,
32
42
  LIST_WORKTREES_TOOL_NAME,
33
43
  CREATE_WORKTREE_TOOL_NAME,
34
44
  REMOVE_WORKTREE_TOOL_NAME,
@@ -51,6 +61,9 @@ export const GIT_CONTEXT_VALUE_LIMIT_CHARS = 512;
51
61
  export const GIT_CONTEXT_SUMMARY_LIMIT_CHARS = 4_000;
52
62
  export const PULL_REQUEST_AUTOFILL_COMMIT_LIMIT = 20;
53
63
  export const PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS = 256;
64
+ export const GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES = 128 * 1024;
65
+ export const GIT_BRANCH_ENTRY_LIMIT = 200;
66
+ export const GIT_BRANCH_SUMMARY_LIMIT_CHARS = 4_000;
54
67
  export const GIT_WORKTREE_RAW_OUTPUT_LIMIT_BYTES = 128 * 1024;
55
68
  export const GIT_WORKTREE_ENTRY_LIMIT = 100;
56
69
  export const GIT_WORKTREE_PATH_LIMIT_CHARS = 4_096;
@@ -16,6 +16,8 @@ import {
16
16
  getCurrentBranch,
17
17
  getGitOperationState,
18
18
  getLocalBranchCommit,
19
+ getRemoteTrackingRefCommit,
20
+ inspectDirectRemoteTrackingRef,
19
21
  isCommitAncestor,
20
22
  isLosslessGitMetadata,
21
23
  parseWorkingTreeStatus,
@@ -44,6 +46,7 @@ export interface PreparedBranchIntegration {
44
46
  sourceHead: string;
45
47
  targetHead: string;
46
48
  sourceAlreadyIntegrated: boolean;
49
+ remoteSource?: true;
47
50
  }
48
51
 
49
52
  interface IntegrationStateSnapshot {
@@ -186,6 +189,20 @@ function parseConflictPaths(output: string): CapturedConflictPaths {
186
189
  };
187
190
  }
188
191
 
192
+ async function integrationSourceHead(
193
+ pi: Pick<ExtensionAPI, "exec">,
194
+ ctx: GitCommandContext,
195
+ sourceBranch: string,
196
+ signal?: AbortSignal,
197
+ remoteSource = false,
198
+ ): Promise<string> {
199
+ if (!remoteSource) return getLocalBranchCommit(pi, ctx, sourceBranch, signal);
200
+ const ref = await inspectDirectRemoteTrackingRef(pi, ctx, sourceBranch, signal);
201
+ const head = await getRemoteTrackingRefCommit(pi, ctx, sourceBranch, signal);
202
+ if (ref.status !== "present" || ref.objectId !== head) throw new Error("Integration source must be a direct remote-tracking commit ref.");
203
+ return head;
204
+ }
205
+
189
206
  async function inspectIntegrationState(
190
207
  pi: Pick<ExtensionAPI, "exec">,
191
208
  prepared: PreparedBranchIntegration,
@@ -195,7 +212,7 @@ async function inspectIntegrationState(
195
212
  const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, rootCtx, signal);
196
213
  const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
197
214
  const current = await getCurrentBranch(pi, rootCtx, signal);
198
- const sourceHead = await getLocalBranchCommit(pi, rootCtx, prepared.sourceBranch, signal);
215
+ const sourceHead = await integrationSourceHead(pi, rootCtx, prepared.sourceBranch, signal, prepared.remoteSource);
199
216
  const targetHead = await getLocalBranchCommit(pi, rootCtx, prepared.targetBranch, signal);
200
217
  const operationState = await getGitOperationState(pi, rootCtx, signal);
201
218
  const statusResult = await runGit(pi, rootCtx, INTEGRATION_STATUS_ARGS, {
@@ -357,7 +374,7 @@ async function runMerge(
357
374
  prepared: PreparedBranchIntegration,
358
375
  signal?: AbortSignal,
359
376
  ): Promise<Error | null> {
360
- const args = mergeArgs(prepared.sourceBranch);
377
+ const args = prepared.remoteSource ? [...INTEGRATION_MERGE_POLICY_ARGS, prepared.sourceHead] : mergeArgs(prepared.sourceBranch);
361
378
  let result: GitExecResult;
362
379
  try {
363
380
  result = await runGit(pi, { cwd: prepared.worktreeRoot }, args, {
@@ -544,14 +561,16 @@ async function recoverFailedMerge(
544
561
  throw mergeFailure;
545
562
  }
546
563
 
547
- async function integrateBranchWithinQueue(
564
+ // Caller holds the mutation queue. Remote sources are internal to update_from_base only.
565
+ export async function integrateBranchWithinQueue(
548
566
  pi: Pick<ExtensionAPI, "exec">,
549
567
  ctx: GitCommandContext,
550
568
  request: IntegrateBranchToolInput,
551
569
  queuedWorktreeRoot: string,
552
570
  signal?: AbortSignal,
571
+ remoteSource = false,
553
572
  ): Promise<IntegrateBranchDetails> {
554
- const prepared = await prepareBranchIntegration(pi, ctx, request, signal);
573
+ const prepared = await prepareBranchIntegration(pi, ctx, request, signal, remoteSource);
555
574
  if (prepared.worktreeRoot !== queuedWorktreeRoot) {
556
575
  throw new Error("The control worktree changed while preparing branch integration.");
557
576
  }
@@ -591,6 +610,7 @@ export async function prepareBranchIntegration(
591
610
  ctx: GitCommandContext,
592
611
  request: IntegrateBranchToolInput,
593
612
  signal?: AbortSignal,
613
+ remoteSource = false,
594
614
  ): Promise<PreparedBranchIntegration> {
595
615
  validateBranchNameInput(request.sourceBranch, "Source branch");
596
616
  validateBranchNameInput(request.targetBranch, "Target branch");
@@ -604,9 +624,9 @@ export async function prepareBranchIntegration(
604
624
 
605
625
  await validateBranchName(pi, rootCtx, request.sourceBranch, signal);
606
626
  await validateBranchName(pi, rootCtx, request.targetBranch, signal);
607
- await requireExistingLocalBranch(pi, rootCtx, request.sourceBranch, "Source", signal);
627
+ if (!remoteSource) await requireExistingLocalBranch(pi, rootCtx, request.sourceBranch, "Source", signal);
608
628
  await requireExistingLocalBranch(pi, rootCtx, request.targetBranch, "Target", signal);
609
- const sourceHead = await getLocalBranchCommit(pi, rootCtx, request.sourceBranch, signal);
629
+ const sourceHead = await integrationSourceHead(pi, rootCtx, request.sourceBranch, signal, remoteSource);
610
630
  const targetHead = await getLocalBranchCommit(pi, rootCtx, request.targetBranch, signal);
611
631
 
612
632
  const current = await getCurrentBranch(pi, rootCtx, signal);
@@ -651,6 +671,7 @@ export async function prepareBranchIntegration(
651
671
  sourceHead,
652
672
  targetHead,
653
673
  sourceAlreadyIntegrated,
674
+ ...(remoteSource ? { remoteSource: true as const } : {}),
654
675
  };
655
676
  }
656
677
 
@@ -30,6 +30,7 @@ import {
30
30
  } from "./git.ts";
31
31
  import { retireBranchWithinQueue } from "./git-retirement.ts";
32
32
  import { redactSecrets } from "./redaction.ts";
33
+ import { getGitHubPullRequest, parseGitHubRepository, repositoriesEqual, resolveGitHubRepository, resolveGitHubToken, type RelatedPullRequestLookupOptions } from "./github.ts";
33
34
  import type { AheadBehindCount } from "./types.ts";
34
35
 
35
36
  type GitAPI = Pick<ExtensionAPI, "exec">;
@@ -40,6 +41,7 @@ export interface LandBranchInput {
40
41
  targetBranch: string;
41
42
  remote?: string;
42
43
  worktreePath?: string;
44
+ pullRequestNumber?: number;
43
45
  }
44
46
 
45
47
  export interface LandBranchReceipt {
@@ -50,6 +52,8 @@ export interface LandBranchReceipt {
50
52
  sourceBranch: string;
51
53
  sourceHead: string | null;
52
54
  ancestry: { isAncestor: boolean | null };
55
+ mergeProof: "ancestry" | "pull-request" | null;
56
+ pullRequest: { number: number; headSha: string; mergeCommitSha: string } | null;
53
57
  worktree: {
54
58
  path: string | null;
55
59
  outcome: "removed" | "absent" | "refused";
@@ -83,8 +87,8 @@ function landingError(error: unknown): string {
83
87
 
84
88
  function validateLandingInput(input: LandBranchInput): void {
85
89
  for (const key of Object.keys(input)) {
86
- if (!["sourceBranch", "targetBranch", "remote", "worktreePath"].includes(key)) {
87
- throw new Error("land_branch accepts only sourceBranch, targetBranch, remote, and worktreePath.");
90
+ if (!["sourceBranch", "targetBranch", "remote", "worktreePath", "pullRequestNumber"].includes(key)) {
91
+ throw new Error("land_branch accepts only sourceBranch, targetBranch, remote, worktreePath, and pullRequestNumber.");
88
92
  }
89
93
  }
90
94
  for (const branch of [input.sourceBranch, input.targetBranch]) {
@@ -100,6 +104,9 @@ function validateLandingInput(input: LandBranchInput): void {
100
104
  requireLosslessWorktreeIdentity(input.remote, "branch");
101
105
  }
102
106
  if (input.worktreePath !== undefined) validateWorktreePathInput(input.worktreePath);
107
+ if (input.pullRequestNumber !== undefined && (!Number.isSafeInteger(input.pullRequestNumber) || input.pullRequestNumber <= 0)) {
108
+ throw new Error("pullRequestNumber must be a positive safe integer.");
109
+ }
103
110
  }
104
111
 
105
112
  // Reused Git helpers all receive explicit -C routing. Disable config-driven pruning,
@@ -134,6 +141,8 @@ function newLandingReceipt(root: string, input: LandBranchInput): LandBranchRece
134
141
  sourceBranch: input.sourceBranch,
135
142
  sourceHead: null,
136
143
  ancestry: { isAncestor: null },
144
+ mergeProof: null,
145
+ pullRequest: null,
137
146
  worktree: { path: null, outcome: "refused", reason: "Not attempted.", deletedIgnoredPaths: [] },
138
147
  branch: { outcome: "refused", reason: "Not attempted.", expectedHead: null },
139
148
  targetSync: { mode: "not-run", worktreePath: null, before: null, after: null, aheadBehind: null, reason: "Not attempted." },
@@ -223,8 +232,10 @@ async function removeLandingWorktree(pi: GitAPI, receipt: LandBranchReceipt, sig
223
232
  const sourceHead = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
224
233
  if (sourceHead !== receipt.sourceHead) throw new Error("Source branch moved after the ancestry proof; no worktree removed.");
225
234
  const worktreeHead = validated.worktree.head;
235
+ const exactMergedPrHead = receipt.mergeProof === "pull-request" &&
236
+ validated.worktree.branch === receipt.sourceBranch && worktreeHead === receipt.pullRequest?.headSha;
226
237
  if (!worktreeHead || !receipt.remoteTargetHead ||
227
- !(await isCommitAncestor(pi, ctx, worktreeHead, receipt.remoteTargetHead, signal))) {
238
+ (!exactMergedPrHead && !(await isCommitAncestor(pi, ctx, worktreeHead, receipt.remoteTargetHead, signal)))) {
228
239
  throw new Error("Selected worktree HEAD is not merged into the fetched target.");
229
240
  }
230
241
  await requireIdleWorktree(pi, { cwd: worktree.path }, signal);
@@ -253,7 +264,9 @@ async function retireLandingBranch(pi: GitAPI, receipt: LandBranchReceipt, signa
253
264
  branchName: receipt.sourceBranch,
254
265
  expectedHead: head,
255
266
  targetBranch: `${receipt.remote}/${receipt.targetBranch}`,
256
- force: false,
267
+ // A verified host merge authorizes retiring rewritten source history, not a
268
+ // general force option. Identity, occupancy, target stability and lease checks remain.
269
+ force: receipt.mergeProof === "pull-request" && receipt.pullRequest?.headSha === head,
257
270
  }, ctx.cwd, signal, "remote-tracking", receipt.remoteTargetHead);
258
271
  receipt.branch.outcome = "deleted";
259
272
  receipt.branch.reason = null;
@@ -370,12 +383,40 @@ async function refuseLanding(pi: GitAPI, receipt: LandBranchReceipt, error: unkn
370
383
  return receipt;
371
384
  }
372
385
 
386
+ async function verifyLandingPullRequest(
387
+ pi: GitAPI,
388
+ ctx: GitCommandContext,
389
+ receipt: LandBranchReceipt,
390
+ number: number,
391
+ options: RelatedPullRequestLookupOptions,
392
+ signal?: AbortSignal,
393
+ ): Promise<void> {
394
+ const repository = await resolveGitHubRepository(pi, ctx, signal, options.env);
395
+ const remoteUrl = await runGit(pi, ctx, ["remote", "get-url", receipt.remote], { signal });
396
+ const remoteRepository = parseGitHubRepository(remoteUrl.stdout.trim());
397
+ if (!remoteRepository || !repositoriesEqual(repository, remoteRepository)) {
398
+ throw new Error("Landing remote does not match the GitHub repository used for PR evidence.");
399
+ }
400
+ const token = (await resolveGitHubToken(options.env, { cwd: ctx.cwd, signal })).token;
401
+ const pr = await getGitHubPullRequest(repository, number, token, { fetchImpl: options.fetchImpl, signal });
402
+ if (!pr.merged || pr.state !== "closed" || !pr.mergeCommitSha || pr.head !== receipt.sourceBranch ||
403
+ pr.base !== receipt.targetBranch || (receipt.sourceHead !== null && pr.headSha !== receipt.sourceHead)) {
404
+ throw new Error("PR merge evidence does not match the exact source commit and target branch; landing refused.");
405
+ }
406
+ if (!receipt.remoteTargetHead || !(await isCommitAncestor(pi, ctx, pr.mergeCommitSha, receipt.remoteTargetHead, signal))) {
407
+ throw new Error("The PR merge commit is not contained in the fetched remote target; landing refused.");
408
+ }
409
+ receipt.pullRequest = { number: pr.number, headSha: pr.headSha, mergeCommitSha: pr.mergeCommitSha };
410
+ receipt.mergeProof = "pull-request";
411
+ }
412
+
373
413
  async function landBranchWithinQueue(
374
414
  pi: GitAPI,
375
415
  cwd: string,
376
416
  root: string,
377
417
  input: LandBranchInput,
378
- signal?: AbortSignal,
418
+ signal: AbortSignal | undefined,
419
+ options: RelatedPullRequestLookupOptions,
379
420
  ): Promise<LandBranchReceipt> {
380
421
  const ctx = { cwd: root };
381
422
  const receipt = newLandingReceipt(root, input);
@@ -392,8 +433,9 @@ async function landBranchWithinQueue(
392
433
  receipt.branch.expectedHead = receipt.sourceHead;
393
434
  receipt.targetSync.before = await captureLocalHead(pi, ctx, input.targetBranch, signal);
394
435
  if (receipt.targetSync.before === null) throw new Error("Local target branch does not exist.");
395
- await fetchRemoteBranchWithinQueue(pi, ctx, receipt.remote, receipt.targetBranch, signal);
396
436
  const tracking = `${receipt.remote}/${receipt.targetBranch}`;
437
+ await inspectDirectRemoteTrackingRef(pi, ctx, tracking, signal);
438
+ await fetchRemoteBranchWithinQueue(pi, ctx, receipt.remote, receipt.targetBranch, signal);
397
439
  const ref = await inspectDirectRemoteTrackingRef(pi, ctx, tracking, signal);
398
440
  receipt.remoteTargetHead = await getRemoteTrackingRefCommit(pi, ctx, tracking, signal);
399
441
  if (ref.status !== "present" || ref.objectId !== receipt.remoteTargetHead) {
@@ -403,9 +445,18 @@ async function landBranchWithinQueue(
403
445
  step = "ancestry";
404
446
  if (receipt.sourceHead !== null) {
405
447
  receipt.ancestry.isAncestor = await isCommitAncestor(pi, ctx, receipt.sourceHead, receipt.remoteTargetHead, signal);
406
- if (!receipt.ancestry.isAncestor) throw new Error("Source branch is not merged into the fetched remote target; landing refused.");
407
448
  }
408
- receipt.steps.push({ step: "ancestry", outcome: receipt.sourceHead === null ? "absent" : "verified", reason: null });
449
+ if (input.pullRequestNumber !== undefined) {
450
+ await verifyLandingPullRequest(pi, ctx, receipt, input.pullRequestNumber, options, signal);
451
+ } else if (receipt.ancestry.isAncestor === false) {
452
+ throw new Error("Source branch is not merged into the fetched remote target; landing refused. For a squash/rebase merge, supply pullRequestNumber for verified host evidence.");
453
+ } else if (receipt.ancestry.isAncestor) {
454
+ receipt.mergeProof = "ancestry";
455
+ }
456
+ let ancestryOutcome = "verified";
457
+ if (receipt.mergeProof === "pull-request") ancestryOutcome = "pr-verified";
458
+ else if (receipt.sourceHead === null) ancestryOutcome = "absent";
459
+ receipt.steps.push({ step: "ancestry", outcome: ancestryOutcome, reason: null });
409
460
  } catch (error) {
410
461
  receipt.steps.push({ step, outcome: "refused", reason: landingError(error) });
411
462
  return refuseLanding(pi, receipt, error);
@@ -426,7 +477,7 @@ async function landBranchWithinQueue(
426
477
  return receipt;
427
478
  }
428
479
 
429
- export async function landBranch(pi: GitAPI, ctx: GitCommandContext, input: LandBranchInput, signal?: AbortSignal): Promise<LandBranchReceipt> {
480
+ export async function landBranch(pi: GitAPI, ctx: GitCommandContext, input: LandBranchInput, signal?: AbortSignal, options: RelatedPullRequestLookupOptions = {}): Promise<LandBranchReceipt> {
430
481
  validateLandingInput(input);
431
482
  const routedPi: GitAPI = { exec: executeLandingGit.bind(undefined, pi, input.remote ?? "origin") };
432
483
  const callerRoot = await getCanonicalGitWorktreeRoot(routedPi, ctx, signal);
@@ -439,11 +490,12 @@ export async function landBranch(pi: GitAPI, ctx: GitCommandContext, input: Land
439
490
  await getCanonicalCommonGitDirectory(routedPi, { cwd: root }, signal) !== await getCanonicalCommonGitDirectory(routedPi, { cwd: callerRoot }, signal)) {
440
491
  throw new Error("Primary checkout repository identity could not be verified.");
441
492
  }
442
- return withRepositoryMutationQueue(root, landBranchWithinQueue.bind(undefined, routedPi, ctx.cwd, root, input, signal));
493
+ return withRepositoryMutationQueue(root, landBranchWithinQueue.bind(undefined, routedPi, ctx.cwd, root, input, signal, options));
443
494
  }
444
495
 
445
496
  export function formatLandBranch(receipt: LandBranchReceipt): string {
446
- const summary = `land_branch: worktree ${receipt.worktree.outcome}; branch ${receipt.branch.outcome}; target sync ${receipt.targetSync.mode}.`;
497
+ const proof = receipt.pullRequest ? ` Verified merged PR #${receipt.pullRequest.number}; source ancestry: ${receipt.ancestry.isAncestor ?? "unknown"}.` : "";
498
+ const summary = `land_branch: worktree ${receipt.worktree.outcome}; branch ${receipt.branch.outcome}; target sync ${receipt.targetSync.mode}.${proof}`;
447
499
  const reason = receipt.worktree.reason ?? receipt.branch.reason ?? receipt.targetSync.reason;
448
500
  return reason ? `${summary} ${landingError(reason)}` : summary;
449
501
  }
@@ -0,0 +1,136 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { GIT_MUTATION_TIMEOUT_MS } from "./constants.ts";
3
+ import { integrateBranchWithinQueue } from "./git-integration.ts";
4
+ import {
5
+ fetchRemoteBranchWithinQueue, getCanonicalGitWorktreeRoot, getCurrentBranch,
6
+ getGitOperationState, getLocalBranchCommit, getRemoteTrackingRefCommit, getUpstreamBranch,
7
+ getWorkingTreeStatus, inspectDirectRemoteTrackingRef, localBranchExists,
8
+ requireLosslessWorktreeIdentity, runGit, validateBranchName, withRepositoryMutationQueue,
9
+ type GitCommandContext,
10
+ } from "./git.ts";
11
+
12
+ export interface TrackBranchInput {
13
+ branchName: string;
14
+ remote?: string;
15
+ remoteBranch?: string;
16
+ }
17
+
18
+ export interface TrackBranchDetails {
19
+ action: "track_branch";
20
+ branchName: string;
21
+ previousBranch: string | null;
22
+ head: string;
23
+ upstream: string;
24
+ repoRoot: string;
25
+ }
26
+
27
+ type GitAPI = Pick<ExtensionAPI, "exec">;
28
+
29
+ async function executeNarrowFetch(pi: GitAPI, command: string, args: string[], options: Parameters<GitAPI["exec"]>[2]) {
30
+ const safeArgs = args[0] === "fetch" ? ["fetch", "--no-prune", "--no-prune-tags", "--refmap=", ...args.slice(1)] : args;
31
+ return pi.exec(command, safeArgs, options);
32
+ }
33
+
34
+ export async function fetchWorkflowBase(pi: GitAPI, ctx: GitCommandContext, remote: string, branch: string, signal?: AbortSignal): Promise<void> {
35
+ // Fetch dereferences a symbolic destination, potentially updating a local branch.
36
+ // Reject it before fetching, not only when inspecting the fetched commit.
37
+ await inspectDirectRemoteTrackingRef(pi, ctx, `${remote}/${branch}`, signal);
38
+ const scopedPi: GitAPI = { exec: executeNarrowFetch.bind(undefined, pi) };
39
+ await fetchRemoteBranchWithinQueue(scopedPi, ctx, remote, branch, signal);
40
+ }
41
+
42
+ export async function requireCleanIdleCheckout(pi: GitAPI, ctx: GitCommandContext, signal?: AbortSignal): Promise<void> {
43
+ if ((await getGitOperationState(pi, ctx, signal)).active.length > 0) {
44
+ throw new Error("An in-progress Git operation must be completed or aborted first.");
45
+ }
46
+ if ((await getWorkingTreeStatus(pi, ctx, signal)).workingTree.state !== "clean") {
47
+ throw new Error("The working tree must be clean before this operation.");
48
+ }
49
+ }
50
+
51
+ async function trackBranchWithinQueue(pi: GitAPI, ctx: GitCommandContext, input: TrackBranchInput, signal?: AbortSignal): Promise<TrackBranchDetails> {
52
+ const remote = input.remote ?? "origin";
53
+ const remoteBranch = input.remoteBranch ?? input.branchName;
54
+ requireLosslessWorktreeIdentity(input.branchName, "branch");
55
+ requireLosslessWorktreeIdentity(remote, "branch");
56
+ requireLosslessWorktreeIdentity(remoteBranch, "branch");
57
+ await validateBranchName(pi, ctx, input.branchName, signal);
58
+ await validateBranchName(pi, ctx, remoteBranch, signal);
59
+ if (input.branchName.startsWith("refs/") || remoteBranch.startsWith("refs/")) {
60
+ throw new Error("track_branch requires branch names, not full refs.");
61
+ }
62
+ if (await localBranchExists(pi, ctx, input.branchName, signal)) throw new Error("Local branch already exists; use change_branch.");
63
+ await requireCleanIdleCheckout(pi, ctx, signal);
64
+ const previous = await getCurrentBranch(pi, ctx, signal);
65
+ if (previous.currentBranch !== null) requireLosslessWorktreeIdentity(previous.currentBranch, "branch");
66
+ await fetchWorkflowBase(pi, ctx, remote, remoteBranch, signal);
67
+ const upstream = `${remote}/${remoteBranch}`;
68
+ const ref = await inspectDirectRemoteTrackingRef(pi, ctx, upstream, signal);
69
+ const head = await getRemoteTrackingRefCommit(pi, ctx, upstream, signal);
70
+ if (ref.status !== "present" || ref.objectId !== head) throw new Error("Fetched branch is not a direct remote-tracking commit ref.");
71
+ await requireCleanIdleCheckout(pi, ctx, signal);
72
+ try {
73
+ await runGit(pi, ctx, ["switch", "--no-overwrite-ignore", "--track=direct", "-c", input.branchName, `refs/remotes/${upstream}`], {
74
+ signal, timeout: GIT_MUTATION_TIMEOUT_MS,
75
+ });
76
+ if ((await getCurrentBranch(pi, ctx)).currentBranch !== input.branchName ||
77
+ await getLocalBranchCommit(pi, ctx, input.branchName) !== head ||
78
+ await getUpstreamBranch(pi, ctx) !== upstream) {
79
+ throw new Error("Branch, commit, or configured upstream did not match the captured remote branch.");
80
+ }
81
+ await requireCleanIdleCheckout(pi, ctx);
82
+ } catch {
83
+ throw new Error("Tracking checkout did not complete with verified postconditions; inspect the repository before retrying. No automatic rollback was attempted.");
84
+ }
85
+ return { action: "track_branch", repoRoot: ctx.cwd, branchName: input.branchName, previousBranch: previous.currentBranch, head, upstream };
86
+ }
87
+
88
+ export interface UpdateFromBaseInput {
89
+ baseBranch: string;
90
+ remote?: string;
91
+ }
92
+
93
+ async function captureUpstreamConfiguration(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal): Promise<string> {
94
+ const values: string[] = [];
95
+ for (const key of ["remote", "merge"]) {
96
+ const result = await runGit(pi, ctx, ["config", "--null", "--get-all", `branch.${branch}.${key}`], { signal, allowFailure: true });
97
+ if (result.code !== 0 && (result.code !== 1 || result.stdout || result.stderr)) {
98
+ throw new Error("Unable to inspect upstream configuration.");
99
+ }
100
+ values.push(result.stdout);
101
+ }
102
+ return JSON.stringify(values);
103
+ }
104
+
105
+ async function updateFromBaseWithinQueue(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal) {
106
+ const remote = input.remote ?? "origin";
107
+ requireLosslessWorktreeIdentity(remote, "branch");
108
+ requireLosslessWorktreeIdentity(input.baseBranch, "branch");
109
+ await validateBranchName(pi, ctx, input.baseBranch, signal);
110
+ await requireCleanIdleCheckout(pi, ctx, signal);
111
+ const current = await getCurrentBranch(pi, ctx, signal);
112
+ if (!current.currentBranch || current.detached) throw new Error("update_from_base requires a current local branch.");
113
+ if (current.currentBranch === input.baseBranch) throw new Error("Use pull_branch for the base branch itself.");
114
+ requireLosslessWorktreeIdentity(current.currentBranch, "branch");
115
+ const upstreamBefore = await captureUpstreamConfiguration(pi, ctx, current.currentBranch, signal);
116
+ await fetchWorkflowBase(pi, ctx, remote, input.baseBranch, signal);
117
+ const integration = await integrateBranchWithinQueue(pi, ctx, {
118
+ sourceBranch: `${remote}/${input.baseBranch}`, targetBranch: current.currentBranch,
119
+ }, ctx.cwd, signal, true);
120
+ if (await captureUpstreamConfiguration(pi, ctx, current.currentBranch) !== upstreamBefore) {
121
+ throw new Error("Upstream configuration changed during update; inspect the repository before retrying.");
122
+ }
123
+ return { action: "update_from_base" as const, request: input, status: integration.status, integration };
124
+ }
125
+
126
+ export async function updateFromBase(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal) {
127
+ const root = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
128
+ requireLosslessWorktreeIdentity(root, "cwd");
129
+ return withRepositoryMutationQueue(root, updateFromBaseWithinQueue.bind(undefined, pi, { cwd: root }, input, signal));
130
+ }
131
+
132
+ export async function trackBranch(pi: GitAPI, ctx: GitCommandContext, input: TrackBranchInput, signal?: AbortSignal): Promise<TrackBranchDetails> {
133
+ const root = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
134
+ requireLosslessWorktreeIdentity(root, "cwd");
135
+ return withRepositoryMutationQueue(root, trackBranchWithinQueue.bind(undefined, pi, { cwd: root }, input, signal));
136
+ }