@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.
- package/CHANGELOG.md +15 -3
- package/README.md +93 -21
- package/SECURITY.md +34 -15
- package/docs/PROJECT_DEFINITION_BRIEF.md +34 -18
- package/docs/SMOKE_TEST.md +13 -4
- package/docs/STRUCTURE.md +22 -10
- package/docs/TUI_CAPTURE.md +41 -31
- package/package.json +3 -2
- package/src/commands/branchme-command.ts +13 -3
- package/src/constants.ts +15 -0
- package/src/git-integration.ts +27 -6
- package/src/git-landing.ts +501 -0
- package/src/git-retirement.ts +34 -11
- package/src/git-workflow.ts +136 -0
- package/src/git.ts +353 -20
- package/src/github.ts +115 -0
- package/src/tools/branchme-tools.ts +115 -9
- package/src/tools/workflow-tools.ts +86 -0
- package/src/types.ts +55 -0
- package/src/ui/branchme-panel.ts +3 -2
package/src/git-retirement.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|