@senad-d/branchme 0.3.6 → 0.3.9
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/CHANGELOG.md +17 -0
- package/README.md +9 -8
- package/SECURITY.md +7 -7
- package/docs/PROJECT_DEFINITION_BRIEF.md +6 -5
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +11 -5
- package/docs/TUI_CAPTURE.md +1 -0
- package/package.json +1 -1
- package/src/blocker-guidance.ts +154 -0
- package/src/commands/branchme-command.ts +1 -0
- package/src/constants.ts +11 -0
- package/src/extension.ts +2 -1
- package/src/git-integration.ts +10 -2
- package/src/git-landing.ts +67 -8
- package/src/git-transport.ts +133 -0
- package/src/git-workflow.ts +8 -10
- package/src/git.ts +31 -9
- package/src/github-feedback.ts +252 -0
- package/src/github.ts +10 -10
- package/src/tools/branchme-tools.ts +8 -1
- package/src/tools/workflow-tools.ts +34 -3
- package/src/types.ts +40 -0
package/src/git-integration.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
);
|
package/src/git-landing.ts
CHANGED
|
@@ -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.
|
|
343
|
+
if (status.workingTree.staged + status.workingTree.unstaged > 0) {
|
|
313
344
|
sync.mode = "skipped-dirty";
|
|
314
|
-
sync.reason = "Target checkout
|
|
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
|
-
|
|
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
|
|
499
|
-
const
|
|
500
|
-
|
|
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
|
+
}
|
package/src/git-workflow.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|