@senad-d/branchme 0.3.0 → 0.3.2
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 +69 -31
- package/SECURITY.md +31 -21
- package/docs/PROJECT_DEFINITION_BRIEF.md +36 -23
- package/docs/SMOKE_TEST.md +13 -4
- package/docs/STRUCTURE.md +22 -14
- package/docs/TUI_CAPTURE.md +41 -31
- package/package.json +11 -5
- package/src/commands/branchme-command.ts +13 -3
- package/src/constants.ts +13 -0
- package/src/git-integration.ts +27 -6
- package/src/git-landing.ts +63 -11
- package/src/git-workflow.ts +144 -0
- package/src/git.ts +319 -19
- package/src/github.ts +115 -0
- package/src/tools/branchme-tools.ts +84 -12
- package/src/tools/workflow-tools.ts +86 -0
- package/src/types.ts +55 -0
- package/src/ui/branchme-panel.ts +3 -2
|
@@ -25,12 +25,13 @@ export function getBranchMeHelpText(): string {
|
|
|
25
25
|
return [
|
|
26
26
|
"# BranchMe",
|
|
27
27
|
"",
|
|
28
|
-
"
|
|
28
|
+
"Git repository initialization and current-repository workflow tools for Pi.",
|
|
29
29
|
"",
|
|
30
30
|
"Commands only show info; BranchMe tools perform actions.",
|
|
31
31
|
"",
|
|
32
32
|
"## Workflow",
|
|
33
33
|
"",
|
|
34
|
+
"0. `init_repository` — initialize the current directory when it is not already inside a Git repository.",
|
|
34
35
|
"1. `branch_status` — inspect repo and branch state.",
|
|
35
36
|
"2. `change_branch` — switch to a clean existing local branch.",
|
|
36
37
|
"3. `fetch_branch` — fetch its configured upstream remote (or an explicit `remote`/`branch`) without changing local files.",
|
|
@@ -40,6 +41,15 @@ export function getBranchMeHelpText(): string {
|
|
|
40
41
|
"7. `push_branch` — push the current branch.",
|
|
41
42
|
"8. `pull_request` — open a PR after `push_branch` completes and GitHub sees the branches.",
|
|
42
43
|
"",
|
|
44
|
+
"## Discovery and PR lifecycle",
|
|
45
|
+
"",
|
|
46
|
+
"- `list_branches` — discover local/cached remote refs, upstream counts, and worktree occupancy without fetching.",
|
|
47
|
+
"- `track_branch` — fetch an existing remote branch and verify a new clean local tracking checkout.",
|
|
48
|
+
"- `update_from_base` — fetch and merge an explicit base into the current clean feature, preserving published history and upstream.",
|
|
49
|
+
"- `pull_request_status` — inspect a PR by number, or the latest PR for a head branch; not a CI/review verdict.",
|
|
50
|
+
"- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.",
|
|
51
|
+
"- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.",
|
|
52
|
+
"",
|
|
43
53
|
"## Branch integration",
|
|
44
54
|
"",
|
|
45
55
|
"- `integrate_branch` — integrate an exact local source into an exact local target; the clean control worktree must already have the target checked out.",
|
|
@@ -59,12 +69,12 @@ export function getBranchMeHelpText(): string {
|
|
|
59
69
|
"- `list_worktrees` — inspect the main and linked worktrees in the current repository.",
|
|
60
70
|
"- `create_worktree` — create a linked worktree and return a ready handoff with an absolute `handoff.cwd`.",
|
|
61
71
|
"- A separate orchestrator starts the next Pi session or subagent in `handoff.cwd`.",
|
|
62
|
-
"- `remove_worktree` — remove a verified clean linked worktree while retaining its local branch.",
|
|
72
|
+
"- `remove_worktree` — remove a verified clean linked worktree while retaining its local branch; `deleteIgnored: true` explicitly deletes ignored residue.",
|
|
63
73
|
"- BranchMe does not change cwd, start Pi, or copy `.env`; `remove_worktree` never removes its retained branch automatically.",
|
|
64
74
|
"",
|
|
65
75
|
"## Requirements",
|
|
66
76
|
"",
|
|
67
|
-
"-
|
|
77
|
+
"- Keep `git` available. Existing-repository tools run inside a Git repo; `init_repository` targets only the exact current non-repository directory.",
|
|
68
78
|
"- For PRs: GitHub `origin` and `GITHUB_TOKEN` or `GH_TOKEN` (environment or `.env`).",
|
|
69
79
|
"- Optional: set `BRANCHME_PR_AUTOFILL=true` in the environment or `.env` to generate omitted PR fields.",
|
|
70
80
|
"- `fetch_branch` without `branch`, `pull_branch`, and `rebase_branch` require a configured upstream.",
|
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;
|
package/src/git-integration.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/git-landing.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,144 @@
|
|
|
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 readUpstreamConfigurationValue(
|
|
94
|
+
pi: GitAPI,
|
|
95
|
+
ctx: GitCommandContext,
|
|
96
|
+
branch: string,
|
|
97
|
+
signal: AbortSignal | undefined,
|
|
98
|
+
key: string,
|
|
99
|
+
): Promise<string> {
|
|
100
|
+
const result = await runGit(pi, ctx, ["config", "--null", "--get-all", `branch.${branch}.${key}`], { signal, allowFailure: true });
|
|
101
|
+
if (result.code !== 0 && (result.code !== 1 || result.stdout || result.stderr)) {
|
|
102
|
+
throw new Error("Unable to inspect upstream configuration.");
|
|
103
|
+
}
|
|
104
|
+
return result.stdout;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function captureUpstreamConfiguration(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal): Promise<string> {
|
|
108
|
+
const readValue = readUpstreamConfigurationValue.bind(undefined, pi, ctx, branch, signal);
|
|
109
|
+
const values = await Promise.all(["remote", "merge"].map(readValue));
|
|
110
|
+
return JSON.stringify(values);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function updateFromBaseWithinQueue(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal) {
|
|
114
|
+
const remote = input.remote ?? "origin";
|
|
115
|
+
requireLosslessWorktreeIdentity(remote, "branch");
|
|
116
|
+
requireLosslessWorktreeIdentity(input.baseBranch, "branch");
|
|
117
|
+
await validateBranchName(pi, ctx, input.baseBranch, signal);
|
|
118
|
+
await requireCleanIdleCheckout(pi, ctx, signal);
|
|
119
|
+
const current = await getCurrentBranch(pi, ctx, signal);
|
|
120
|
+
if (!current.currentBranch || current.detached) throw new Error("update_from_base requires a current local branch.");
|
|
121
|
+
if (current.currentBranch === input.baseBranch) throw new Error("Use pull_branch for the base branch itself.");
|
|
122
|
+
requireLosslessWorktreeIdentity(current.currentBranch, "branch");
|
|
123
|
+
const upstreamBefore = await captureUpstreamConfiguration(pi, ctx, current.currentBranch, signal);
|
|
124
|
+
await fetchWorkflowBase(pi, ctx, remote, input.baseBranch, signal);
|
|
125
|
+
const integration = await integrateBranchWithinQueue(pi, ctx, {
|
|
126
|
+
sourceBranch: `${remote}/${input.baseBranch}`, targetBranch: current.currentBranch,
|
|
127
|
+
}, ctx.cwd, signal, true);
|
|
128
|
+
if (await captureUpstreamConfiguration(pi, ctx, current.currentBranch) !== upstreamBefore) {
|
|
129
|
+
throw new Error("Upstream configuration changed during update; inspect the repository before retrying.");
|
|
130
|
+
}
|
|
131
|
+
return { action: "update_from_base" as const, request: input, status: integration.status, integration };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function updateFromBase(pi: GitAPI, ctx: GitCommandContext, input: UpdateFromBaseInput, signal?: AbortSignal) {
|
|
135
|
+
const root = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
|
|
136
|
+
requireLosslessWorktreeIdentity(root, "cwd");
|
|
137
|
+
return withRepositoryMutationQueue(root, updateFromBaseWithinQueue.bind(undefined, pi, { cwd: root }, input, signal));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function trackBranch(pi: GitAPI, ctx: GitCommandContext, input: TrackBranchInput, signal?: AbortSignal): Promise<TrackBranchDetails> {
|
|
141
|
+
const root = await getCanonicalGitWorktreeRoot(pi, ctx, signal);
|
|
142
|
+
requireLosslessWorktreeIdentity(root, "cwd");
|
|
143
|
+
return withRepositoryMutationQueue(root, trackBranchWithinQueue.bind(undefined, pi, { cwd: root }, input, signal));
|
|
144
|
+
}
|