@senad-d/branchme 0.2.2 → 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.
@@ -10,7 +10,9 @@ import {
10
10
  getCanonicalCommonGitDirectory,
11
11
  getCanonicalGitWorktreeRoot,
12
12
  getLocalBranchCommit,
13
+ getRemoteTrackingRefCommit,
13
14
  inspectDirectLocalBranchRef,
15
+ inspectDirectRemoteTrackingRef,
14
16
  inspectLocalBranchWorktreeOccupancy,
15
17
  isCommitAncestor,
16
18
  isLosslessGitMetadata,
@@ -36,6 +38,8 @@ import type {
36
38
  const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
37
39
  const RETIREMENT_REQUEST_FIELDS = new Set(["branchName", "expectedHead", "targetBranch", "force"]);
38
40
 
41
+ type RetirementTargetScope = "local" | "remote-tracking";
42
+
39
43
  export interface PreparedBranchRetirement {
40
44
  worktreeRoot: string;
41
45
  canonicalCommonGitDirectory: string;
@@ -62,7 +66,7 @@ function normalizeExpectedHead(value: unknown): string {
62
66
  return value.toLowerCase();
63
67
  }
64
68
 
65
- function validateRetirementRequest(request: unknown): RetireBranchToolInput {
69
+ function validateRetirementRequest(request: unknown, targetScope: RetirementTargetScope = "local"): RetireBranchToolInput {
66
70
  if (typeof request !== "object" || request === null || Array.isArray(request)) {
67
71
  throw new TypeError("Branch retirement request must be an object.");
68
72
  }
@@ -78,7 +82,7 @@ function validateRetirementRequest(request: unknown): RetireBranchToolInput {
78
82
 
79
83
  validateRetirementBranchName(values.branchName, "Retiring branch");
80
84
  validateRetirementBranchName(values.targetBranch, "Target branch");
81
- if (values.branchName === values.targetBranch) {
85
+ if (targetScope === "local" && values.branchName === values.targetBranch) {
82
86
  throw new Error("Retiring branch and target branch must be distinct local branches.");
83
87
  }
84
88
  const expectedHead = normalizeExpectedHead(values.expectedHead);
@@ -164,13 +168,26 @@ function retirementRefIdentity(
164
168
  };
165
169
  }
166
170
 
171
+ function retirementTargetScope(prepared: PreparedBranchRetirement): RetirementTargetScope {
172
+ return prepared.target.fullRef.startsWith("refs/remotes/") ? "remote-tracking" : "local";
173
+ }
174
+
175
+ function targetRefInspector(scope: RetirementTargetScope) {
176
+ return scope === "local" ? inspectDirectLocalBranchRef : inspectDirectRemoteTrackingRef;
177
+ }
178
+
179
+ function targetCommitResolver(scope: RetirementTargetScope) {
180
+ return scope === "local" ? getLocalBranchCommit : getRemoteTrackingRefCommit;
181
+ }
182
+
167
183
  export async function prepareBranchRetirement(
168
184
  pi: Pick<ExtensionAPI, "exec">,
169
185
  ctx: GitCommandContext,
170
186
  requestValue: unknown,
171
187
  signal?: AbortSignal,
188
+ targetScope: RetirementTargetScope = "local",
172
189
  ): Promise<PreparedBranchRetirement> {
173
- const request = validateRetirementRequest(requestValue);
190
+ const request = validateRetirementRequest(requestValue, targetScope);
174
191
  const worktreeRoot = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
175
192
  const rootCtx = { cwd: worktreeRoot };
176
193
  const canonicalCommonGitDirectory = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
@@ -182,11 +199,11 @@ export async function prepareBranchRetirement(
182
199
  "Retiring",
183
200
  );
184
201
  const targetRef = requirePresentDirectRef(
185
- await inspectDirectLocalBranchRef(pi, rootCtx, request.targetBranch, signal),
202
+ await targetRefInspector(targetScope)(pi, rootCtx, request.targetBranch, signal),
186
203
  "Target",
187
204
  );
188
205
  const retiringHead = await getLocalBranchCommit(pi, rootCtx, request.branchName, signal);
189
- const targetHead = await getLocalBranchCommit(pi, rootCtx, request.targetBranch, signal);
206
+ const targetHead = await targetCommitResolver(targetScope)(pi, rootCtx, request.targetBranch, signal);
190
207
  requireDirectRefCommitIdentity(retiringRef, retiringHead, "Retiring");
191
208
  requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
192
209
  if (!sameObjectIdentity(retiringHead, request.expectedHead)) {
@@ -328,7 +345,7 @@ async function inspectImmediateRetirementState(
328
345
  "Retiring",
329
346
  );
330
347
  const targetRef = requirePresentDirectRef(
331
- await inspectDirectLocalBranchRef(pi, rootCtx, prepared.request.targetBranch, signal),
348
+ await targetRefInspector(retirementTargetScope(prepared))(pi, rootCtx, prepared.request.targetBranch, signal),
332
349
  "Target",
333
350
  );
334
351
  const retiringHead = await getLocalBranchCommit(
@@ -337,7 +354,7 @@ async function inspectImmediateRetirementState(
337
354
  prepared.request.branchName,
338
355
  signal,
339
356
  );
340
- const targetHead = await getLocalBranchCommit(
357
+ const targetHead = await targetCommitResolver(retirementTargetScope(prepared))(
341
358
  pi,
342
359
  rootCtx,
343
360
  prepared.request.targetBranch,
@@ -445,7 +462,7 @@ async function inspectPostRetirementState(
445
462
  rootCtx,
446
463
  prepared.request.branchName,
447
464
  );
448
- const targetRef = await inspectDirectLocalBranchRef(
465
+ const targetRef = await targetRefInspector(retirementTargetScope(prepared))(
449
466
  pi,
450
467
  rootCtx,
451
468
  prepared.request.targetBranch,
@@ -458,7 +475,7 @@ async function inspectPostRetirementState(
458
475
  }
459
476
  let targetHead: string | null = null;
460
477
  if (targetRef.status === "present") {
461
- targetHead = await getLocalBranchCommit(pi, rootCtx, prepared.request.targetBranch);
478
+ targetHead = await targetCommitResolver(retirementTargetScope(prepared))(pi, rootCtx, prepared.request.targetBranch);
462
479
  requireDirectRefCommitIdentity(targetRef, targetHead, "Target");
463
480
  targetHead = targetHead.toLowerCase();
464
481
  }
@@ -610,14 +627,20 @@ function classifyRetirementOutcome(
610
627
  );
611
628
  }
612
629
 
613
- async function retireBranchWithinQueue(
630
+ // Caller must hold the repository mutation queue; remote targets are used only by land_branch.
631
+ export async function retireBranchWithinQueue(
614
632
  pi: Pick<ExtensionAPI, "exec">,
615
633
  ctx: GitCommandContext,
616
634
  request: RetireBranchToolInput,
617
635
  queuedWorktreeRoot: string,
618
636
  signal?: AbortSignal,
637
+ targetScope: RetirementTargetScope = "local",
638
+ expectedTargetHead?: string,
619
639
  ): Promise<RetireBranchDetails> {
620
- const prepared = await prepareBranchRetirement(pi, ctx, request, signal);
640
+ const prepared = await prepareBranchRetirement(pi, ctx, request, signal, targetScope);
641
+ if (expectedTargetHead !== undefined && prepared.target.head !== expectedTargetHead) {
642
+ throw new Error("The fetched retirement target moved after the landing ancestry proof.");
643
+ }
621
644
  if (prepared.worktreeRoot !== queuedWorktreeRoot) {
622
645
  throw new Error("The active worktree changed while preparing branch retirement.");
623
646
  }
@@ -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
+ }