@senad-d/branchme 0.3.8 → 0.3.10

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.
@@ -43,6 +43,7 @@ import type {
43
43
  IntegrateBranchDetails,
44
44
  IntegrateBranchToolInput,
45
45
  IntegrateBranchVerification,
46
+ WorkingTreeDetails,
46
47
  } from "./types.ts";
47
48
 
48
49
  export interface PreparedBranchIntegration {
@@ -216,6 +217,12 @@ async function integrationSourceHead(
216
217
  return head;
217
218
  }
218
219
 
220
+ function integrationWorkingTreeIsClean(workingTree: WorkingTreeDetails, allowUntracked: boolean): boolean {
221
+ return allowUntracked
222
+ ? workingTree.staged + workingTree.unstaged === 0
223
+ : workingTree.state === "clean";
224
+ }
225
+
219
226
  async function inspectIntegrationState(
220
227
  pi: Pick<ExtensionAPI, "exec">,
221
228
  prepared: PreparedBranchIntegration,
@@ -234,7 +241,8 @@ async function inspectIntegrationState(
234
241
  signal,
235
242
  timeout: GIT_STATUS_TIMEOUT_MS,
236
243
  });
237
- const clean = parseWorkingTreeStatus(statusResult.stdout).workingTree.state === "clean";
244
+ const workingTree = parseWorkingTreeStatus(statusResult.stdout).workingTree;
245
+ const clean = integrationWorkingTreeIsClean(workingTree, prepared.remoteSource === true || prepared.sourceHeadFixed === true);
238
246
  const sourceIsAncestorOfTarget = await isCommitAncestor(
239
247
  pi,
240
248
  rootCtx,
@@ -767,7 +775,7 @@ export async function prepareBranchIntegration(
767
775
  timeout: GIT_STATUS_TIMEOUT_MS,
768
776
  });
769
777
  const workingTree = parseWorkingTreeStatus(statusResult.stdout).workingTree;
770
- if (workingTree.state !== "clean") {
778
+ if (!integrationWorkingTreeIsClean(workingTree, remoteSource)) {
771
779
  throw new Error(
772
780
  "The control worktree must be clean, with no staged, unstaged, untracked, or unmerged changes, before integration.",
773
781
  );
@@ -1,6 +1,6 @@
1
1
  import { lstat, realpath } from "node:fs/promises";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { GIT_FETCH_TIMEOUT_MS, GIT_PULL_TIMEOUT_MS, MAX_SUMMARY_OUTPUT_CHARS } from "./constants.ts";
3
+ import { GIT_FETCH_TIMEOUT_MS, GIT_MUTATION_TIMEOUT_MS, GIT_PULL_TIMEOUT_MS, MAX_SUMMARY_OUTPUT_CHARS } from "./constants.ts";
4
4
  import {
5
5
  canonicalizePathAllowMissing,
6
6
  collectWorktreeInventory,
@@ -20,6 +20,7 @@ import {
20
20
  pathIsInsideOrEqual,
21
21
  removeWorktreeWithinQueue,
22
22
  requireLosslessWorktreeIdentity,
23
+ requireNoTrackedChanges,
23
24
  runGit,
24
25
  validateBranchName,
25
26
  validateBranchNameInput,
@@ -29,6 +30,7 @@ import {
29
30
  type GitCommandContext,
30
31
  } from "./git.ts";
31
32
  import { retireBranchWithinQueue } from "./git-retirement.ts";
33
+ import { blockerGuidance } from "./blocker-guidance.ts";
32
34
  import { redactSecrets } from "./redaction.ts";
33
35
  import { getGitHubPullRequest, parseGitHubRepository, repositoriesEqual, resolveGitHubRepository, resolveGitHubToken, type RelatedPullRequestLookupOptions } from "./github.ts";
34
36
  import type { AheadBehindCount } from "./types.ts";
@@ -60,6 +62,11 @@ export interface LandBranchReceipt {
60
62
  reason: string | null;
61
63
  deletedIgnoredPaths: string[];
62
64
  };
65
+ /** The primary checkout, switched from the merged source to the target so the source can be retired. */
66
+ checkout: {
67
+ outcome: "switched" | "not-needed" | "refused";
68
+ reason: string | null;
69
+ };
63
70
  branch: {
64
71
  outcome: "deleted" | "absent" | "refused";
65
72
  reason: string | null;
@@ -74,7 +81,7 @@ export interface LandBranchReceipt {
74
81
  reason: string | null;
75
82
  };
76
83
  steps: {
77
- step: "repository" | "cwd" | "fetch" | "ancestry" | "worktree" | "branch" | "targetSync";
84
+ step: "repository" | "cwd" | "fetch" | "ancestry" | "worktree" | "checkout" | "branch" | "targetSync";
78
85
  outcome: string;
79
86
  reason: string | null;
80
87
  }[];
@@ -144,6 +151,7 @@ function newLandingReceipt(root: string, input: LandBranchInput): LandBranchRece
144
151
  mergeProof: null,
145
152
  pullRequest: null,
146
153
  worktree: { path: null, outcome: "refused", reason: "Not attempted.", deletedIgnoredPaths: [] },
154
+ checkout: { outcome: "refused", reason: "Not attempted." },
147
155
  branch: { outcome: "refused", reason: "Not attempted.", expectedHead: null },
148
156
  targetSync: { mode: "not-run", worktreePath: null, before: null, after: null, aheadBehind: null, reason: "Not attempted." },
149
157
  steps: [{ step: "repository", outcome: "resolved", reason: null }],
@@ -248,6 +256,29 @@ async function removeLandingWorktree(pi: GitAPI, receipt: LandBranchReceipt, sig
248
256
  worktree.deletedIgnoredPaths = removed.deletedIgnoredPaths;
249
257
  }
250
258
 
259
+ // The primary checkout can never be removed, so a merged source branch checked out
260
+ // there is released by switching that checkout to the target branch.
261
+ async function releasePrimaryCheckout(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
262
+ const ctx = { cwd: receipt.repositoryRoot };
263
+ const inventory = await collectWorktreeInventory(pi, ctx, signal);
264
+ if (inventory.entries[0]?.record.branch !== receipt.sourceBranch) {
265
+ receipt.checkout.outcome = "not-needed";
266
+ receipt.checkout.reason = null;
267
+ return;
268
+ }
269
+ if (await captureLocalHead(pi, ctx, receipt.sourceBranch, signal) !== receipt.sourceHead) {
270
+ throw new Error("Source branch moved after the ancestry proof; primary checkout retained.");
271
+ }
272
+ await requireIdleWorktree(pi, ctx, signal);
273
+ await requireNoTrackedChanges(pi, ctx, "switching the primary checkout to the target branch", signal);
274
+ await runGit(pi, ctx, ["switch", "--no-overwrite-ignore", receipt.targetBranch], { signal, timeout: GIT_MUTATION_TIMEOUT_MS });
275
+ if ((await getCurrentBranch(pi, ctx, signal)).currentBranch !== receipt.targetBranch) {
276
+ throw new Error("git switch did not end on the target branch; the primary checkout was left as git reported it.");
277
+ }
278
+ receipt.checkout.outcome = "switched";
279
+ receipt.checkout.reason = null;
280
+ }
281
+
251
282
  async function retireLandingBranch(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
252
283
  const ctx = { cwd: receipt.repositoryRoot };
253
284
  const head = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
@@ -309,9 +340,9 @@ async function syncLandingTarget(pi: GitAPI, receipt: LandBranchReceipt, signal?
309
340
  throw new Error("Target checkout identity changed; sync refused.");
310
341
  }
311
342
  const status = await getWorkingTreeStatus(pi, targetCtx, signal);
312
- if (status.workingTree.state !== "clean") {
343
+ if (status.workingTree.staged + status.workingTree.unstaged > 0) {
313
344
  sync.mode = "skipped-dirty";
314
- sync.reason = "Target checkout is dirty; no pull attempted.";
345
+ sync.reason = "Target checkout has tracked changes; no pull attempted.";
315
346
  return;
316
347
  }
317
348
  await requireIdleWorktree(pi, targetCtx, signal);
@@ -377,7 +408,8 @@ async function refuseLanding(pi: GitAPI, receipt: LandBranchReceipt, error: unkn
377
408
  receipt.targetSync.reason = `${reason} Verification: ${landingError(verificationError)}`;
378
409
  }
379
410
  const recorded = new Set(receipt.steps.map((step) => step.step));
380
- for (const step of ["cwd", "fetch", "ancestry", "worktree", "branch", "targetSync"] as const) {
411
+ receipt.checkout.reason = reason;
412
+ for (const step of ["cwd", "fetch", "ancestry", "worktree", "checkout", "branch", "targetSync"] as const) {
381
413
  if (!recorded.has(step)) receipt.steps.push({ step, outcome: "not-run", reason });
382
414
  }
383
415
  return receipt;
@@ -467,6 +499,12 @@ async function landBranchWithinQueue(
467
499
  receipt.worktree.reason = landingError(error);
468
500
  }
469
501
  receipt.steps.push({ step: "worktree", outcome: receipt.worktree.outcome, reason: receipt.worktree.reason });
502
+ try {
503
+ await releasePrimaryCheckout(pi, receipt, signal);
504
+ } catch (error) {
505
+ receipt.checkout.reason = landingError(error);
506
+ }
507
+ receipt.steps.push({ step: "checkout", outcome: receipt.checkout.outcome, reason: receipt.checkout.reason });
470
508
  try {
471
509
  await retireLandingBranch(pi, receipt, signal);
472
510
  } catch (error) {
@@ -493,9 +531,30 @@ export async function landBranch(pi: GitAPI, ctx: GitCommandContext, input: Land
493
531
  return withRepositoryMutationQueue(root, landBranchWithinQueue.bind(undefined, routedPi, ctx.cwd, root, input, signal, options));
494
532
  }
495
533
 
534
+ function shortHead(head: string | null): string {
535
+ return head ? head.slice(0, 7) : "unknown";
536
+ }
537
+
538
+ function formatTargetSync(receipt: LandBranchReceipt): string {
539
+ const sync = receipt.targetSync;
540
+ const target = receipt.targetBranch;
541
+ switch (sync.mode) {
542
+ case "noop": return `${target} already matched ${receipt.remote}/${target} at ${shortHead(sync.after)}`;
543
+ case "fetch-refspec":
544
+ case "pull-ff": return `${target} fast-forwarded ${shortHead(sync.before)} -> ${shortHead(sync.after)}`;
545
+ case "skipped-dirty": return `${target} not updated (its checkout has tracked changes)`;
546
+ case "failed": return `${target} not updated (failed)`;
547
+ case "not-run": return `${target} not updated (not run)`;
548
+ }
549
+ }
550
+
496
551
  export function formatLandBranch(receipt: LandBranchReceipt): string {
497
552
  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}`;
499
- const reason = receipt.worktree.reason ?? receipt.branch.reason ?? receipt.targetSync.reason;
500
- return reason ? `${summary} ${landingError(reason)}` : summary;
553
+ const checkout = receipt.checkout.outcome === "switched" ? `; primary checkout switched to ${receipt.targetBranch}` : "";
554
+ const summary = `land_branch: worktree ${receipt.worktree.outcome}${checkout}; branch ${receipt.branch.outcome}; target sync: ${formatTargetSync(receipt)}.${proof}`;
555
+ const reason = receipt.worktree.reason ?? receipt.checkout.reason ?? receipt.branch.reason ?? receipt.targetSync.reason;
556
+ if (!reason) return summary;
557
+ const guidance = blockerGuidance(reason);
558
+ const suffix = guidance ? `\n${guidance}` : "";
559
+ return `${summary} ${landingError(reason)}${suffix}`;
501
560
  }
@@ -0,0 +1,133 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, stat } from "node:fs/promises";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { resolveGitHubToken } from "./github.ts";
6
+
7
+ type ExecOptions = Parameters<ExtensionAPI["exec"]>[2];
8
+ type ExecResult = Awaited<ReturnType<ExtensionAPI["exec"]>>;
9
+
10
+ const NETWORK_GIT_COMMANDS = new Set(["fetch", "pull", "push", "ls-remote"]);
11
+ const KILL_GRACE_MS = 5000;
12
+ const GIT_EXECUTABLE_CANDIDATES = process.platform === "win32"
13
+ ? [String.raw`C:\Program Files\Git\cmd\git.exe`, String.raw`C:\Program Files\Git\bin\git.exe`]
14
+ : ["/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git", "/bin/git"];
15
+
16
+ // Answers github.com HTTPS credential requests from the token BranchMe already uses
17
+ // for the GitHub API. The token stays in the child environment, never in argv.
18
+ // Git queries this after the user's own helpers, so an existing login still wins.
19
+ // printf emits Git's credential protocol fields; no credential is stored in source.
20
+ export const TOKEN_CREDENTIAL_HELPER = String.raw`!f() { test "$1" = get || exit 0; printf '%s=%s\n' username x-access-token password "$BRANCHME_GIT_TOKEN"; }; f`;
21
+
22
+ /** The git subcommand after leading `-c <config>` and `-C <dir>` options. */
23
+ export function gitSubcommand(args: readonly string[]): string | undefined {
24
+ let index = 0;
25
+ while (args[index] === "-c" || args[index] === "-C") index += 2;
26
+ return args[index];
27
+ }
28
+
29
+ /** Network commands use a fixed system-install location, never a PATH lookup. */
30
+ export async function resolveGitExecutable(): Promise<string> {
31
+ for (const candidate of GIT_EXECUTABLE_CANDIDATES) {
32
+ try {
33
+ if (!(await stat(candidate)).isFile()) continue;
34
+ await access(candidate, constants.X_OK);
35
+ return candidate;
36
+ } catch {
37
+ // Try the next fixed installation location.
38
+ }
39
+ }
40
+ throw new Error("Git was not found in a supported system installation directory; install Git before retrying.");
41
+ }
42
+
43
+ async function resolveTransportToken(env: NodeJS.ProcessEnv, cwd: string | undefined, signal?: AbortSignal): Promise<string | null> {
44
+ try {
45
+ return (await resolveGitHubToken(env, { cwd, signal })).token;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ interface GitProcessState {
52
+ child: ChildProcess;
53
+ stdout: string;
54
+ stderr: string;
55
+ killed: boolean;
56
+ closed: boolean;
57
+ timer?: ReturnType<typeof setTimeout>;
58
+ killTimer?: ReturnType<typeof setTimeout>;
59
+ signal?: AbortSignal;
60
+ onAbort: () => void;
61
+ }
62
+
63
+ function forceKillGit(state: GitProcessState): void {
64
+ if (!state.closed) state.child.kill("SIGKILL");
65
+ }
66
+
67
+ function killGit(state: GitProcessState): void {
68
+ if (state.killed || state.closed) return;
69
+ state.killed = true;
70
+ state.child.kill("SIGTERM");
71
+ state.killTimer = setTimeout(forceKillGit, KILL_GRACE_MS, state);
72
+ state.killTimer.unref();
73
+ }
74
+
75
+ function appendGitOutput(state: GitProcessState, stream: "stdout" | "stderr", data: string): void {
76
+ state[stream] += data;
77
+ }
78
+
79
+ function appendGitError(state: GitProcessState, error: Error): void {
80
+ state.stderr += error.message;
81
+ }
82
+
83
+ function finishGit(state: GitProcessState, resolve: (result: ExecResult) => void, code: number | null): void {
84
+ state.closed = true;
85
+ clearTimeout(state.timer);
86
+ clearTimeout(state.killTimer);
87
+ state.signal?.removeEventListener("abort", state.onAbort);
88
+ resolve({ stdout: state.stdout, stderr: state.stderr, code: code ?? 1, killed: state.killed });
89
+ }
90
+
91
+ function startGit(binary: string, args: string[], env: NodeJS.ProcessEnv, options: ExecOptions, resolve: (result: ExecResult) => void): void {
92
+ const child = spawn(binary, args, { cwd: options?.cwd, env, shell: false, stdio: ["ignore", "pipe", "pipe"] });
93
+ const state: GitProcessState = { child, stdout: "", stderr: "", killed: false, closed: false, signal: options?.signal, onAbort: () => undefined };
94
+ state.onAbort = killGit.bind(undefined, state);
95
+ state.timer = options?.timeout ? setTimeout(killGit, options.timeout, state) : undefined;
96
+ state.signal?.addEventListener("abort", state.onAbort, { once: true });
97
+ child.stdout!.setEncoding("utf8").on("data", appendGitOutput.bind(undefined, state, "stdout"));
98
+ child.stderr!.setEncoding("utf8").on("data", appendGitOutput.bind(undefined, state, "stderr"));
99
+ child.on("error", appendGitError.bind(undefined, state));
100
+ child.on("close", finishGit.bind(undefined, state, resolve));
101
+ }
102
+
103
+ function spawnGit(binary: string, args: string[], env: NodeJS.ProcessEnv, options: ExecOptions): Promise<ExecResult> {
104
+ if (options?.signal?.aborted) return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
105
+ return new Promise(startGit.bind(undefined, binary, args, env, options));
106
+ }
107
+
108
+ async function execGit(
109
+ pi: Pick<ExtensionAPI, "exec">,
110
+ env: NodeJS.ProcessEnv,
111
+ command: string,
112
+ args: string[],
113
+ options: ExecOptions,
114
+ ): Promise<ExecResult> {
115
+ if (command !== "git" || !NETWORK_GIT_COMMANDS.has(gitSubcommand(args) ?? "")) return pi.exec(command, args, options);
116
+ // An agent cannot answer a credential prompt: without this git waits on the
117
+ // terminal until the timeout and reports only "killed".
118
+ const childEnv: NodeJS.ProcessEnv = { ...env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" };
119
+ const token = await resolveTransportToken(env, options?.cwd, options?.signal);
120
+ const binary = await resolveGitExecutable();
121
+ if (token === null) return spawnGit(binary, args, childEnv, options);
122
+ childEnv.BRANCHME_GIT_TOKEN = token;
123
+ return spawnGit(binary, ["-c", `credential.https://github.com.helper=${TOKEN_CREDENTIAL_HELPER}`, ...args], childEnv, options);
124
+ }
125
+
126
+ /**
127
+ * Pi's exec cannot set a child environment. Network git commands (fetch, pull,
128
+ * push, ls-remote) spawn git directly without a terminal prompt and with the
129
+ * GitHub token available to git; every other command goes to Pi's exec.
130
+ */
131
+ export function withGitTransport(pi: Pick<ExtensionAPI, "exec">, env: NodeJS.ProcessEnv = process.env): Pick<ExtensionAPI, "exec"> {
132
+ return { exec: execGit.bind(undefined, pi, env) };
133
+ }
@@ -5,8 +5,8 @@ import type { IntegrateBranchDetails } from "./types.ts";
5
5
  import {
6
6
  fetchRemoteBranchWithinQueue, getCanonicalGitWorktreeRoot, getCurrentBranch,
7
7
  getGitOperationState, getLocalBranchCommit, getRemoteTrackingRefCommit, getUpstreamBranch,
8
- getWorkingTreeStatus, inspectDirectRemoteTrackingRef, localBranchExists,
9
- requireLosslessWorktreeIdentity, runGit, validateBranchName, withRepositoryMutationQueue,
8
+ inspectDirectRemoteTrackingRef, localBranchExists,
9
+ requireLosslessWorktreeIdentity, requireNoTrackedChanges, runGit, validateBranchName, withRepositoryMutationQueue,
10
10
  type GitCommandContext,
11
11
  } from "./git.ts";
12
12
 
@@ -40,13 +40,11 @@ export async function fetchWorkflowBase(pi: GitAPI, ctx: GitCommandContext, remo
40
40
  await fetchRemoteBranchWithinQueue(scopedPi, ctx, remote, branch, signal);
41
41
  }
42
42
 
43
- export async function requireCleanIdleCheckout(pi: GitAPI, ctx: GitCommandContext, signal?: AbortSignal): Promise<void> {
43
+ export async function requireCleanIdleCheckout(pi: GitAPI, ctx: GitCommandContext, operation: string, signal?: AbortSignal): Promise<void> {
44
44
  if ((await getGitOperationState(pi, ctx, signal)).active.length > 0) {
45
45
  throw new Error("An in-progress Git operation must be completed or aborted first.");
46
46
  }
47
- if ((await getWorkingTreeStatus(pi, ctx, signal)).workingTree.state !== "clean") {
48
- throw new Error("The working tree must be clean before this operation.");
49
- }
47
+ await requireNoTrackedChanges(pi, ctx, operation, signal);
50
48
  }
51
49
 
52
50
  async function trackBranchWithinQueue(pi: GitAPI, ctx: GitCommandContext, input: TrackBranchInput, signal?: AbortSignal): Promise<TrackBranchDetails> {
@@ -61,7 +59,7 @@ async function trackBranchWithinQueue(pi: GitAPI, ctx: GitCommandContext, input:
61
59
  throw new Error("track_branch requires branch names, not full refs.");
62
60
  }
63
61
  if (await localBranchExists(pi, ctx, input.branchName, signal)) throw new Error("Local branch already exists; use change_branch.");
64
- await requireCleanIdleCheckout(pi, ctx, signal);
62
+ await requireCleanIdleCheckout(pi, ctx, "track_branch", signal);
65
63
  const previous = await getCurrentBranch(pi, ctx, signal);
66
64
  if (previous.currentBranch !== null) requireLosslessWorktreeIdentity(previous.currentBranch, "branch");
67
65
  await fetchWorkflowBase(pi, ctx, remote, remoteBranch, signal);
@@ -69,7 +67,7 @@ async function trackBranchWithinQueue(pi: GitAPI, ctx: GitCommandContext, input:
69
67
  const ref = await inspectDirectRemoteTrackingRef(pi, ctx, upstream, signal);
70
68
  const head = await getRemoteTrackingRefCommit(pi, ctx, upstream, signal);
71
69
  if (ref.status !== "present" || ref.objectId !== head) throw new Error("Fetched branch is not a direct remote-tracking commit ref.");
72
- await requireCleanIdleCheckout(pi, ctx, signal);
70
+ await requireCleanIdleCheckout(pi, ctx, "track_branch", signal);
73
71
  try {
74
72
  await runGit(pi, ctx, ["switch", "--no-overwrite-ignore", "--track=direct", "-c", input.branchName, `refs/remotes/${upstream}`], {
75
73
  signal, timeout: GIT_MUTATION_TIMEOUT_MS,
@@ -79,7 +77,7 @@ async function trackBranchWithinQueue(pi: GitAPI, ctx: GitCommandContext, input:
79
77
  await getUpstreamBranch(pi, ctx) !== upstream) {
80
78
  throw new Error("Branch, commit, or configured upstream did not match the captured remote branch.");
81
79
  }
82
- await requireCleanIdleCheckout(pi, ctx);
80
+ await requireCleanIdleCheckout(pi, ctx, "track_branch");
83
81
  } catch {
84
82
  throw new Error("Tracking checkout did not complete with verified postconditions; inspect the repository before retrying. No automatic rollback was attempted.");
85
83
  }
@@ -125,7 +123,7 @@ async function updateFromBaseWithinQueue(pi: GitAPI, ctx: GitCommandContext, inp
125
123
  requireLosslessWorktreeIdentity(remote, "branch");
126
124
  requireLosslessWorktreeIdentity(input.baseBranch, "branch");
127
125
  await validateBranchName(pi, ctx, input.baseBranch, signal);
128
- await requireCleanIdleCheckout(pi, ctx, signal);
126
+ await requireCleanIdleCheckout(pi, ctx, "update_from_base", signal);
129
127
  const current = await getCurrentBranch(pi, ctx, signal);
130
128
  if (!current.currentBranch || current.detached) throw new Error("update_from_base requires a current local branch.");
131
129
  if (current.currentBranch === input.baseBranch) throw new Error("Use pull_branch for the base branch itself.");
package/src/git.ts CHANGED
@@ -591,6 +591,34 @@ export async function hasWorkingTreeChanges(
591
591
  .some((line) => line.length > 0 && !line.startsWith("## "));
592
592
  }
593
593
 
594
+ const TRACKED_CHANGE_PATH_LIMIT = 5;
595
+
596
+ /**
597
+ * Refuses when tracked files have staged, unstaged, or unmerged changes. Untracked
598
+ * files (a local .env, editor or tool state) never block: git itself refuses a
599
+ * switch, pull, rebase, or merge that would overwrite one.
600
+ */
601
+ export async function requireNoTrackedChanges(
602
+ pi: Pick<ExtensionAPI, "exec">,
603
+ ctx: GitCommandContext,
604
+ operation: string,
605
+ signal?: AbortSignal,
606
+ ): Promise<void> {
607
+ const result = await runGit(pi, ctx, ["status", "--porcelain=v1", "--branch"], {
608
+ signal,
609
+ timeout: GIT_STATUS_TIMEOUT_MS,
610
+ });
611
+ // Display only: git quotes unusual paths and shows renames as "old -> new".
612
+ const paths = result.stdout
613
+ .split("\n")
614
+ .filter((line) => line.length > 3 && !line.startsWith("## ") && !line.startsWith("?? ") && !line.startsWith("!! "))
615
+ .map((line) => line.slice(3));
616
+ if (paths.length === 0) return;
617
+ const shown = paths.slice(0, TRACKED_CHANGE_PATH_LIMIT).map((path) => JSON.stringify(safeDetail(path))).join(", ");
618
+ const more = paths.length > TRACKED_CHANGE_PATH_LIMIT ? ` and ${paths.length - TRACKED_CHANGE_PATH_LIMIT} more` : "";
619
+ throw new Error(`Tracked files have uncommitted changes; ${operation} refused: ${shown}${more}. Untracked files do not block ${operation}.`);
620
+ }
621
+
594
622
  export interface WorkingTreeStatus {
595
623
  workingTree: WorkingTreeDetails;
596
624
  unstagedChanges: GitFileChangeSummary;
@@ -2714,9 +2742,7 @@ export async function changeExistingLocalBranch(
2714
2742
  throw new Error(`Already on branch '${redactSecrets(branchName)}'.`);
2715
2743
  }
2716
2744
 
2717
- if (await hasWorkingTreeChanges(pi, rootCtx, signal)) {
2718
- throw new Error("Working tree has uncommitted changes; clean it before changing branches.");
2719
- }
2745
+ await requireNoTrackedChanges(pi, rootCtx, "change_branch", signal);
2720
2746
 
2721
2747
  await runGit(pi, rootCtx, ["switch", branchName], {
2722
2748
  signal,
@@ -2872,9 +2898,7 @@ export async function pullCurrentBranch(
2872
2898
 
2873
2899
  return withRepositoryMutationQueue(repoRoot, async () => {
2874
2900
  const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
2875
- if (await hasWorkingTreeChanges(pi, rootCtx, signal)) {
2876
- throw new Error("Working tree has uncommitted changes; clean it before pulling the current branch.");
2877
- }
2901
+ await requireNoTrackedChanges(pi, rootCtx, "pull_branch", signal);
2878
2902
 
2879
2903
  const target = await resolveConfiguredUpstreamTarget(pi, rootCtx, currentBranch, "pull", signal);
2880
2904
  if (!target) {
@@ -2934,9 +2958,7 @@ export async function rebaseCurrentBranch(
2934
2958
 
2935
2959
  return withRepositoryMutationQueue(repoRoot, async () => {
2936
2960
  const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
2937
- if (await hasWorkingTreeChanges(pi, rootCtx, signal)) {
2938
- throw new Error("Working tree has uncommitted changes; clean it before rebasing the current branch.");
2939
- }
2961
+ await requireNoTrackedChanges(pi, rootCtx, "rebase_branch", signal);
2940
2962
 
2941
2963
  const target = await resolveConfiguredUpstreamTarget(pi, rootCtx, currentBranch, "rebase", signal);
2942
2964
  if (!target) {