@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/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,15 +13,21 @@ 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";
|
|
15
20
|
export const INTEGRATE_BRANCH_TOOL_NAME = "integrate_branch";
|
|
16
21
|
export const RETIRE_BRANCH_TOOL_NAME = "retire_branch";
|
|
22
|
+
export const LAND_BRANCH_TOOL_NAME = "land_branch";
|
|
17
23
|
export const PULL_REQUEST_AUTOFILL_ENV_NAME = "BRANCHME_PR_AUTOFILL";
|
|
18
24
|
|
|
19
25
|
export const BRANCHME_TOOL_NAMES = [
|
|
20
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,
|
|
21
31
|
CREATE_BRANCH_TOOL_NAME,
|
|
22
32
|
CHANGE_BRANCH_TOOL_NAME,
|
|
23
33
|
FETCH_BRANCH_TOOL_NAME,
|
|
@@ -25,8 +35,10 @@ export const BRANCHME_TOOL_NAMES = [
|
|
|
25
35
|
REBASE_BRANCH_TOOL_NAME,
|
|
26
36
|
INTEGRATE_BRANCH_TOOL_NAME,
|
|
27
37
|
RETIRE_BRANCH_TOOL_NAME,
|
|
38
|
+
LAND_BRANCH_TOOL_NAME,
|
|
28
39
|
PUSH_BRANCH_TOOL_NAME,
|
|
29
40
|
PULL_REQUEST_TOOL_NAME,
|
|
41
|
+
PULL_REQUEST_STATUS_TOOL_NAME,
|
|
30
42
|
LIST_WORKTREES_TOOL_NAME,
|
|
31
43
|
CREATE_WORKTREE_TOOL_NAME,
|
|
32
44
|
REMOVE_WORKTREE_TOOL_NAME,
|
|
@@ -49,6 +61,9 @@ export const GIT_CONTEXT_VALUE_LIMIT_CHARS = 512;
|
|
|
49
61
|
export const GIT_CONTEXT_SUMMARY_LIMIT_CHARS = 4_000;
|
|
50
62
|
export const PULL_REQUEST_AUTOFILL_COMMIT_LIMIT = 20;
|
|
51
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;
|
|
52
67
|
export const GIT_WORKTREE_RAW_OUTPUT_LIMIT_BYTES = 128 * 1024;
|
|
53
68
|
export const GIT_WORKTREE_ENTRY_LIMIT = 100;
|
|
54
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
|
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
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";
|
|
4
|
+
import {
|
|
5
|
+
canonicalizePathAllowMissing,
|
|
6
|
+
collectWorktreeInventory,
|
|
7
|
+
fetchRemoteBranchWithinQueue,
|
|
8
|
+
formatGitFailure,
|
|
9
|
+
getAheadBehindCount,
|
|
10
|
+
getCanonicalCommonGitDirectory,
|
|
11
|
+
getCanonicalGitWorktreeRoot,
|
|
12
|
+
getCurrentBranch,
|
|
13
|
+
getGitOperationState,
|
|
14
|
+
getLocalBranchCommit,
|
|
15
|
+
getRemoteTrackingRefCommit,
|
|
16
|
+
getWorkingTreeStatus,
|
|
17
|
+
inspectDirectLocalBranchRef,
|
|
18
|
+
inspectDirectRemoteTrackingRef,
|
|
19
|
+
isCommitAncestor,
|
|
20
|
+
pathIsInsideOrEqual,
|
|
21
|
+
removeWorktreeWithinQueue,
|
|
22
|
+
requireLosslessWorktreeIdentity,
|
|
23
|
+
runGit,
|
|
24
|
+
validateBranchName,
|
|
25
|
+
validateBranchNameInput,
|
|
26
|
+
validateWorktreePathInput,
|
|
27
|
+
validateWorktreeRemovalPath,
|
|
28
|
+
withRepositoryMutationQueue,
|
|
29
|
+
type GitCommandContext,
|
|
30
|
+
} from "./git.ts";
|
|
31
|
+
import { retireBranchWithinQueue } from "./git-retirement.ts";
|
|
32
|
+
import { redactSecrets } from "./redaction.ts";
|
|
33
|
+
import { getGitHubPullRequest, parseGitHubRepository, repositoriesEqual, resolveGitHubRepository, resolveGitHubToken, type RelatedPullRequestLookupOptions } from "./github.ts";
|
|
34
|
+
import type { AheadBehindCount } from "./types.ts";
|
|
35
|
+
|
|
36
|
+
type GitAPI = Pick<ExtensionAPI, "exec">;
|
|
37
|
+
type Inventory = Awaited<ReturnType<typeof collectWorktreeInventory>>;
|
|
38
|
+
|
|
39
|
+
export interface LandBranchInput {
|
|
40
|
+
sourceBranch: string;
|
|
41
|
+
targetBranch: string;
|
|
42
|
+
remote?: string;
|
|
43
|
+
worktreePath?: string;
|
|
44
|
+
pullRequestNumber?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LandBranchReceipt {
|
|
48
|
+
repositoryRoot: string;
|
|
49
|
+
remote: string;
|
|
50
|
+
targetBranch: string;
|
|
51
|
+
remoteTargetHead: string | null;
|
|
52
|
+
sourceBranch: string;
|
|
53
|
+
sourceHead: string | null;
|
|
54
|
+
ancestry: { isAncestor: boolean | null };
|
|
55
|
+
mergeProof: "ancestry" | "pull-request" | null;
|
|
56
|
+
pullRequest: { number: number; headSha: string; mergeCommitSha: string } | null;
|
|
57
|
+
worktree: {
|
|
58
|
+
path: string | null;
|
|
59
|
+
outcome: "removed" | "absent" | "refused";
|
|
60
|
+
reason: string | null;
|
|
61
|
+
deletedIgnoredPaths: string[];
|
|
62
|
+
};
|
|
63
|
+
branch: {
|
|
64
|
+
outcome: "deleted" | "absent" | "refused";
|
|
65
|
+
reason: string | null;
|
|
66
|
+
expectedHead: string | null;
|
|
67
|
+
};
|
|
68
|
+
targetSync: {
|
|
69
|
+
mode: "fetch-refspec" | "pull-ff" | "skipped-dirty" | "noop" | "not-run" | "failed";
|
|
70
|
+
worktreePath: string | null;
|
|
71
|
+
before: string | null;
|
|
72
|
+
after: string | null;
|
|
73
|
+
aheadBehind: AheadBehindCount | null;
|
|
74
|
+
reason: string | null;
|
|
75
|
+
};
|
|
76
|
+
steps: {
|
|
77
|
+
step: "repository" | "cwd" | "fetch" | "ancestry" | "worktree" | "branch" | "targetSync";
|
|
78
|
+
outcome: string;
|
|
79
|
+
reason: string | null;
|
|
80
|
+
}[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function landingError(error: unknown): string {
|
|
84
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
85
|
+
return redactSecrets(text).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").slice(0, MAX_SUMMARY_OUTPUT_CHARS);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateLandingInput(input: LandBranchInput): void {
|
|
89
|
+
for (const key of Object.keys(input)) {
|
|
90
|
+
if (!["sourceBranch", "targetBranch", "remote", "worktreePath", "pullRequestNumber"].includes(key)) {
|
|
91
|
+
throw new Error("land_branch accepts only sourceBranch, targetBranch, remote, worktreePath, and pullRequestNumber.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const branch of [input.sourceBranch, input.targetBranch]) {
|
|
95
|
+
validateBranchNameInput(branch);
|
|
96
|
+
requireLosslessWorktreeIdentity(branch, "branch");
|
|
97
|
+
if (branch.startsWith("refs/") || branch.includes("@{")) {
|
|
98
|
+
throw new Error("land_branch requires exact local branch names, not full refs or revision expressions.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (input.sourceBranch === input.targetBranch) throw new Error("Source and target branches must be distinct.");
|
|
102
|
+
if (input.remote !== undefined) {
|
|
103
|
+
validateBranchNameInput(input.remote, "Remote");
|
|
104
|
+
requireLosslessWorktreeIdentity(input.remote, "branch");
|
|
105
|
+
}
|
|
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
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Reused Git helpers all receive explicit -C routing. Disable config-driven pruning,
|
|
113
|
+
// extra fetch mappings and submodule recursion as well as command-line force options.
|
|
114
|
+
async function executeLandingGit(
|
|
115
|
+
pi: GitAPI,
|
|
116
|
+
remote: string,
|
|
117
|
+
command: string,
|
|
118
|
+
args: string[],
|
|
119
|
+
options: Parameters<GitAPI["exec"]>[2],
|
|
120
|
+
) {
|
|
121
|
+
if (!options?.cwd) throw new Error("Landing Git commands require an explicit working directory.");
|
|
122
|
+
let routedArgs = args;
|
|
123
|
+
if (args[0] === "fetch") {
|
|
124
|
+
routedArgs = ["fetch", "--no-prune", "--no-prune-tags", "--refmap=", ...args.slice(1)];
|
|
125
|
+
} else if (args[0] === "pull") {
|
|
126
|
+
routedArgs = [
|
|
127
|
+
"-c", "fetch.prune=false", "-c", "fetch.pruneTags=false",
|
|
128
|
+
"-c", `remote.${remote}.prune=false`, "-c", `remote.${remote}.pruneTags=false`,
|
|
129
|
+
"pull", "--no-tags", "--no-prune", "--refmap=", "--no-recurse-submodules", ...args.slice(1),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
return pi.exec(command, ["-C", options.cwd, ...routedArgs], options);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function newLandingReceipt(root: string, input: LandBranchInput): LandBranchReceipt {
|
|
136
|
+
return {
|
|
137
|
+
repositoryRoot: root,
|
|
138
|
+
remote: input.remote ?? "origin",
|
|
139
|
+
targetBranch: input.targetBranch,
|
|
140
|
+
remoteTargetHead: null,
|
|
141
|
+
sourceBranch: input.sourceBranch,
|
|
142
|
+
sourceHead: null,
|
|
143
|
+
ancestry: { isAncestor: null },
|
|
144
|
+
mergeProof: null,
|
|
145
|
+
pullRequest: null,
|
|
146
|
+
worktree: { path: null, outcome: "refused", reason: "Not attempted.", deletedIgnoredPaths: [] },
|
|
147
|
+
branch: { outcome: "refused", reason: "Not attempted.", expectedHead: null },
|
|
148
|
+
targetSync: { mode: "not-run", worktreePath: null, before: null, after: null, aheadBehind: null, reason: "Not attempted." },
|
|
149
|
+
steps: [{ step: "repository", outcome: "resolved", reason: null }],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
154
|
+
try {
|
|
155
|
+
await lstat(path);
|
|
156
|
+
return true;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function registeredBranchEntries(inventory: Inventory, branch: string) {
|
|
164
|
+
return inventory.entries.filter((entry) => entry.record.branch === branch);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveLandingWorktree(
|
|
168
|
+
inventory: Inventory,
|
|
169
|
+
input: LandBranchInput,
|
|
170
|
+
): Promise<string | null> {
|
|
171
|
+
if (input.worktreePath !== undefined) {
|
|
172
|
+
const path = await canonicalizePathAllowMissing(validateWorktreePathInput(input.worktreePath));
|
|
173
|
+
if (!path) throw new Error("worktreePath could not be resolved.");
|
|
174
|
+
return requireLosslessWorktreeIdentity(path, "cwd");
|
|
175
|
+
}
|
|
176
|
+
const entries = registeredBranchEntries(inventory, input.sourceBranch).filter((entry) => entry.index !== 0);
|
|
177
|
+
if (entries.length > 1) throw new Error("Source branch occupies multiple worktrees; provide an exact worktreePath.");
|
|
178
|
+
if (entries.length === 0) return null;
|
|
179
|
+
const path = entries[0].canonicalPath;
|
|
180
|
+
if (!path) throw new Error("Source worktree path could not be resolved.");
|
|
181
|
+
return requireLosslessWorktreeIdentity(path, "cwd");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function requireSafeLandingCwd(cwd: string, worktreePath: string | null): Promise<void> {
|
|
185
|
+
if (worktreePath === null) return;
|
|
186
|
+
const callerCwd = await realpath(cwd);
|
|
187
|
+
const processCwd = await realpath(process.cwd());
|
|
188
|
+
if (pathIsInsideOrEqual(callerCwd, worktreePath) || pathIsInsideOrEqual(processCwd, worktreePath)) {
|
|
189
|
+
throw new Error("The running cwd is inside worktreePath; run from the repository root.");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function captureLocalHead(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal) {
|
|
194
|
+
const ref = await inspectDirectLocalBranchRef(pi, ctx, branch, signal);
|
|
195
|
+
if (ref.status === "absent") return null;
|
|
196
|
+
const head = await getLocalBranchCommit(pi, ctx, branch, signal);
|
|
197
|
+
if (ref.objectId !== head) throw new Error("Local branch is not a direct ref to its captured commit.");
|
|
198
|
+
return head;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function requireIdleWorktree(pi: GitAPI, ctx: GitCommandContext, signal?: AbortSignal): Promise<void> {
|
|
202
|
+
const state = await getGitOperationState(pi, ctx, signal);
|
|
203
|
+
if (state.active.length > 0) throw new Error("Worktree has an in-progress Git operation; it will not be touched.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function removeLandingWorktree(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
207
|
+
const worktree = receipt.worktree;
|
|
208
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
209
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
210
|
+
if (worktree.path === null) {
|
|
211
|
+
if (registeredBranchEntries(inventory, receipt.sourceBranch).some((entry) => entry.index !== 0)) {
|
|
212
|
+
throw new Error("Source branch became occupied after preflight.");
|
|
213
|
+
}
|
|
214
|
+
worktree.outcome = "absent";
|
|
215
|
+
worktree.reason = null;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const matches = inventory.entries.filter((entry) => entry.canonicalPath === worktree.path);
|
|
219
|
+
if (matches.some((entry) => entry.index === 0) || worktree.path === receipt.repositoryRoot) {
|
|
220
|
+
throw new Error("The primary checkout / repository root can never be removed.");
|
|
221
|
+
}
|
|
222
|
+
if (!(await pathExists(worktree.path))) {
|
|
223
|
+
worktree.outcome = "absent";
|
|
224
|
+
worktree.reason = matches.length > 0 ? "Directory absent; registration retained (no prune)." : null;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const validated = await validateWorktreeRemovalPath(pi, ctx, worktree.path, signal);
|
|
228
|
+
if (![receipt.sourceBranch, receipt.targetBranch].includes(validated.worktree.branch ?? "")) {
|
|
229
|
+
throw new Error("The selected worktree must hold the source or target branch, not an unrelated branch.");
|
|
230
|
+
}
|
|
231
|
+
if (receipt.sourceHead === null) throw new Error("Source branch is absent; cannot prove this existing worktree is merged.");
|
|
232
|
+
const sourceHead = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
233
|
+
if (sourceHead !== receipt.sourceHead) throw new Error("Source branch moved after the ancestry proof; no worktree removed.");
|
|
234
|
+
const worktreeHead = validated.worktree.head;
|
|
235
|
+
const exactMergedPrHead = receipt.mergeProof === "pull-request" &&
|
|
236
|
+
validated.worktree.branch === receipt.sourceBranch && worktreeHead === receipt.pullRequest?.headSha;
|
|
237
|
+
if (!worktreeHead || !receipt.remoteTargetHead ||
|
|
238
|
+
(!exactMergedPrHead && !(await isCommitAncestor(pi, ctx, worktreeHead, receipt.remoteTargetHead, signal)))) {
|
|
239
|
+
throw new Error("Selected worktree HEAD is not merged into the fetched target.");
|
|
240
|
+
}
|
|
241
|
+
await requireIdleWorktree(pi, { cwd: worktree.path }, signal);
|
|
242
|
+
const removed = await removeWorktreeWithinQueue(pi, ctx, worktree.path, signal, true, {
|
|
243
|
+
branch: validated.worktree.branch!, head: worktreeHead,
|
|
244
|
+
});
|
|
245
|
+
if (await pathExists(worktree.path)) throw new Error("Git unregistered the worktree but its directory is still present.");
|
|
246
|
+
worktree.outcome = "removed";
|
|
247
|
+
worktree.reason = null;
|
|
248
|
+
worktree.deletedIgnoredPaths = removed.deletedIgnoredPaths;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function retireLandingBranch(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
252
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
253
|
+
const head = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
254
|
+
if (head === null) {
|
|
255
|
+
receipt.branch.outcome = "absent";
|
|
256
|
+
receipt.branch.reason = null;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (receipt.worktree.outcome === "refused") throw new Error("Worktree removal was refused; source branch retained.");
|
|
260
|
+
if (head !== receipt.sourceHead || receipt.remoteTargetHead === null) {
|
|
261
|
+
throw new Error("Source branch moved after the captured expected-HEAD lease; branch retained.");
|
|
262
|
+
}
|
|
263
|
+
await retireBranchWithinQueue(pi, ctx, {
|
|
264
|
+
branchName: receipt.sourceBranch,
|
|
265
|
+
expectedHead: head,
|
|
266
|
+
targetBranch: `${receipt.remote}/${receipt.targetBranch}`,
|
|
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,
|
|
270
|
+
}, ctx.cwd, signal, "remote-tracking", receipt.remoteTargetHead);
|
|
271
|
+
receipt.branch.outcome = "deleted";
|
|
272
|
+
receipt.branch.reason = null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function requireLandingPullPolicy(pi: GitAPI, ctx: GitCommandContext, branch: string, signal?: AbortSignal): Promise<void> {
|
|
276
|
+
const args = ["config", "--get-all", `branch.${branch}.mergeOptions`];
|
|
277
|
+
const result = await runGit(pi, ctx, args, { signal, allowFailure: true });
|
|
278
|
+
if (result.code === 1) return;
|
|
279
|
+
if (result.code !== 0) throw new Error(formatGitFailure(args, result));
|
|
280
|
+
if (result.stdout.trim()) {
|
|
281
|
+
throw new Error("Target has branch-specific mergeOptions; sync refused to preserve the fixed fast-forward policy.");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function syncLandingTarget(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
286
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
287
|
+
const sync = receipt.targetSync;
|
|
288
|
+
sync.before = await captureLocalHead(pi, ctx, receipt.targetBranch, signal);
|
|
289
|
+
if (sync.before === null) throw new Error("Local target branch is absent; landing will not create it.");
|
|
290
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
291
|
+
const entries = registeredBranchEntries(inventory, receipt.targetBranch);
|
|
292
|
+
if (entries.length > 1) throw new Error("Target branch is checked out in multiple worktrees; sync refused.");
|
|
293
|
+
if (entries.length === 1) {
|
|
294
|
+
sync.worktreePath = entries[0].canonicalPath;
|
|
295
|
+
if (!sync.worktreePath) throw new Error("Target worktree path could not be resolved.");
|
|
296
|
+
requireLosslessWorktreeIdentity(sync.worktreePath, "cwd");
|
|
297
|
+
}
|
|
298
|
+
if (sync.before === receipt.remoteTargetHead) {
|
|
299
|
+
sync.mode = "noop";
|
|
300
|
+
sync.reason = null;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (sync.worktreePath !== null) {
|
|
304
|
+
const targetCtx = { cwd: sync.worktreePath };
|
|
305
|
+
const root = await getCanonicalGitWorktreeRoot(pi, targetCtx, signal);
|
|
306
|
+
if (root !== sync.worktreePath ||
|
|
307
|
+
await getCanonicalCommonGitDirectory(pi, targetCtx, signal) !== await getCanonicalCommonGitDirectory(pi, ctx, signal) ||
|
|
308
|
+
(await getCurrentBranch(pi, targetCtx, signal)).currentBranch !== receipt.targetBranch) {
|
|
309
|
+
throw new Error("Target checkout identity changed; sync refused.");
|
|
310
|
+
}
|
|
311
|
+
const status = await getWorkingTreeStatus(pi, targetCtx, signal);
|
|
312
|
+
if (status.workingTree.state !== "clean") {
|
|
313
|
+
sync.mode = "skipped-dirty";
|
|
314
|
+
sync.reason = "Target checkout is dirty; no pull attempted.";
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await requireIdleWorktree(pi, targetCtx, signal);
|
|
318
|
+
await requireLandingPullPolicy(pi, targetCtx, receipt.targetBranch, signal);
|
|
319
|
+
await runGit(pi, targetCtx, ["pull", "--ff-only", "--no-rebase", "--no-autostash", receipt.remote, `refs/heads/${receipt.targetBranch}`], {
|
|
320
|
+
signal, timeout: GIT_PULL_TIMEOUT_MS,
|
|
321
|
+
});
|
|
322
|
+
sync.mode = "pull-ff";
|
|
323
|
+
} else {
|
|
324
|
+
await runGit(pi, ctx, ["fetch", "--no-tags", "--no-recurse-submodules", receipt.remote, `refs/heads/${receipt.targetBranch}:refs/heads/${receipt.targetBranch}`], {
|
|
325
|
+
signal, timeout: GIT_FETCH_TIMEOUT_MS,
|
|
326
|
+
});
|
|
327
|
+
sync.mode = "fetch-refspec";
|
|
328
|
+
}
|
|
329
|
+
sync.reason = null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function recordTargetAfter(pi: GitAPI, receipt: LandBranchReceipt): Promise<void> {
|
|
333
|
+
const ctx = { cwd: receipt.repositoryRoot };
|
|
334
|
+
const sync = receipt.targetSync;
|
|
335
|
+
sync.after = await captureLocalHead(pi, ctx, receipt.targetBranch);
|
|
336
|
+
if (sync.after !== null && receipt.remoteTargetHead !== null) {
|
|
337
|
+
sync.aheadBehind = await getAheadBehindCount(pi, ctx, undefined, `${sync.after}...${receipt.remoteTargetHead}`);
|
|
338
|
+
}
|
|
339
|
+
if (["fetch-refspec", "pull-ff"].includes(sync.mode)) {
|
|
340
|
+
if (sync.before === null || sync.after === null ||
|
|
341
|
+
!(await isCommitAncestor(pi, ctx, sync.before, sync.after)) ||
|
|
342
|
+
receipt.remoteTargetHead === null || !(await isCommitAncestor(pi, ctx, receipt.remoteTargetHead, sync.after)) ||
|
|
343
|
+
(sync.after === sync.before && sync.before !== receipt.remoteTargetHead)) {
|
|
344
|
+
throw new Error("Git did not produce a verified target fast-forward; inspect the recorded before/after refs.");
|
|
345
|
+
}
|
|
346
|
+
} else if (["noop", "skipped-dirty"].includes(sync.mode) && sync.before !== sync.after) {
|
|
347
|
+
throw new Error("Target ref moved concurrently during a read-only sync decision.");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function finishLandingSync(pi: GitAPI, receipt: LandBranchReceipt, signal?: AbortSignal): Promise<void> {
|
|
352
|
+
try {
|
|
353
|
+
await syncLandingTarget(pi, receipt, signal);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
receipt.targetSync.mode = "failed";
|
|
356
|
+
receipt.targetSync.reason = landingError(error);
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
// Verification is deliberately uncancelled after a possible mutation.
|
|
360
|
+
await recordTargetAfter(pi, receipt);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
receipt.targetSync.mode = "failed";
|
|
363
|
+
receipt.targetSync.reason = landingError(error);
|
|
364
|
+
}
|
|
365
|
+
receipt.steps.push({ step: "targetSync", outcome: receipt.targetSync.mode, reason: receipt.targetSync.reason });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function refuseLanding(pi: GitAPI, receipt: LandBranchReceipt, error: unknown): Promise<LandBranchReceipt> {
|
|
369
|
+
const reason = landingError(error);
|
|
370
|
+
receipt.worktree.reason = reason;
|
|
371
|
+
receipt.branch.reason = reason;
|
|
372
|
+
receipt.targetSync.reason = reason;
|
|
373
|
+
try {
|
|
374
|
+
receipt.targetSync.before ??= await captureLocalHead(pi, { cwd: receipt.repositoryRoot }, receipt.targetBranch);
|
|
375
|
+
await recordTargetAfter(pi, receipt);
|
|
376
|
+
} catch (verificationError) {
|
|
377
|
+
receipt.targetSync.reason = `${reason} Verification: ${landingError(verificationError)}`;
|
|
378
|
+
}
|
|
379
|
+
const recorded = new Set(receipt.steps.map((step) => step.step));
|
|
380
|
+
for (const step of ["cwd", "fetch", "ancestry", "worktree", "branch", "targetSync"] as const) {
|
|
381
|
+
if (!recorded.has(step)) receipt.steps.push({ step, outcome: "not-run", reason });
|
|
382
|
+
}
|
|
383
|
+
return receipt;
|
|
384
|
+
}
|
|
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
|
+
|
|
413
|
+
async function landBranchWithinQueue(
|
|
414
|
+
pi: GitAPI,
|
|
415
|
+
cwd: string,
|
|
416
|
+
root: string,
|
|
417
|
+
input: LandBranchInput,
|
|
418
|
+
signal: AbortSignal | undefined,
|
|
419
|
+
options: RelatedPullRequestLookupOptions,
|
|
420
|
+
): Promise<LandBranchReceipt> {
|
|
421
|
+
const ctx = { cwd: root };
|
|
422
|
+
const receipt = newLandingReceipt(root, input);
|
|
423
|
+
let step: "cwd" | "fetch" | "ancestry" = "cwd";
|
|
424
|
+
try {
|
|
425
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
426
|
+
receipt.worktree.path = await resolveLandingWorktree(inventory, input);
|
|
427
|
+
await requireSafeLandingCwd(cwd, receipt.worktree.path);
|
|
428
|
+
receipt.steps.push({ step: "cwd", outcome: "safe", reason: null });
|
|
429
|
+
step = "fetch";
|
|
430
|
+
await validateBranchName(pi, ctx, input.sourceBranch, signal);
|
|
431
|
+
await validateBranchName(pi, ctx, input.targetBranch, signal);
|
|
432
|
+
receipt.sourceHead = await captureLocalHead(pi, ctx, input.sourceBranch, signal);
|
|
433
|
+
receipt.branch.expectedHead = receipt.sourceHead;
|
|
434
|
+
receipt.targetSync.before = await captureLocalHead(pi, ctx, input.targetBranch, signal);
|
|
435
|
+
if (receipt.targetSync.before === null) throw new Error("Local target branch does not exist.");
|
|
436
|
+
const tracking = `${receipt.remote}/${receipt.targetBranch}`;
|
|
437
|
+
await inspectDirectRemoteTrackingRef(pi, ctx, tracking, signal);
|
|
438
|
+
await fetchRemoteBranchWithinQueue(pi, ctx, receipt.remote, receipt.targetBranch, signal);
|
|
439
|
+
const ref = await inspectDirectRemoteTrackingRef(pi, ctx, tracking, signal);
|
|
440
|
+
receipt.remoteTargetHead = await getRemoteTrackingRefCommit(pi, ctx, tracking, signal);
|
|
441
|
+
if (ref.status !== "present" || ref.objectId !== receipt.remoteTargetHead) {
|
|
442
|
+
throw new Error("Fetched target is not a direct ref to its captured commit.");
|
|
443
|
+
}
|
|
444
|
+
receipt.steps.push({ step: "fetch", outcome: "fetched", reason: null });
|
|
445
|
+
step = "ancestry";
|
|
446
|
+
if (receipt.sourceHead !== null) {
|
|
447
|
+
receipt.ancestry.isAncestor = await isCommitAncestor(pi, ctx, receipt.sourceHead, receipt.remoteTargetHead, signal);
|
|
448
|
+
}
|
|
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 });
|
|
460
|
+
} catch (error) {
|
|
461
|
+
receipt.steps.push({ step, outcome: "refused", reason: landingError(error) });
|
|
462
|
+
return refuseLanding(pi, receipt, error);
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
await removeLandingWorktree(pi, receipt, signal);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
receipt.worktree.reason = landingError(error);
|
|
468
|
+
}
|
|
469
|
+
receipt.steps.push({ step: "worktree", outcome: receipt.worktree.outcome, reason: receipt.worktree.reason });
|
|
470
|
+
try {
|
|
471
|
+
await retireLandingBranch(pi, receipt, signal);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
receipt.branch.reason = landingError(error);
|
|
474
|
+
}
|
|
475
|
+
receipt.steps.push({ step: "branch", outcome: receipt.branch.outcome, reason: receipt.branch.reason });
|
|
476
|
+
await finishLandingSync(pi, receipt, signal);
|
|
477
|
+
return receipt;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function landBranch(pi: GitAPI, ctx: GitCommandContext, input: LandBranchInput, signal?: AbortSignal, options: RelatedPullRequestLookupOptions = {}): Promise<LandBranchReceipt> {
|
|
481
|
+
validateLandingInput(input);
|
|
482
|
+
const routedPi: GitAPI = { exec: executeLandingGit.bind(undefined, pi, input.remote ?? "origin") };
|
|
483
|
+
const callerRoot = await getCanonicalGitWorktreeRoot(routedPi, ctx, signal);
|
|
484
|
+
const inventory = await collectWorktreeInventory(routedPi, { cwd: callerRoot }, signal);
|
|
485
|
+
const primary = inventory.entries[0];
|
|
486
|
+
if (!primary?.canonicalPath || primary.record.bare) throw new Error("Landing requires a primary non-bare checkout.");
|
|
487
|
+
const root = await getCanonicalGitWorktreeRoot(routedPi, { cwd: primary.canonicalPath }, signal);
|
|
488
|
+
requireLosslessWorktreeIdentity(root, "cwd");
|
|
489
|
+
if (root !== primary.canonicalPath ||
|
|
490
|
+
await getCanonicalCommonGitDirectory(routedPi, { cwd: root }, signal) !== await getCanonicalCommonGitDirectory(routedPi, { cwd: callerRoot }, signal)) {
|
|
491
|
+
throw new Error("Primary checkout repository identity could not be verified.");
|
|
492
|
+
}
|
|
493
|
+
return withRepositoryMutationQueue(root, landBranchWithinQueue.bind(undefined, routedPi, ctx.cwd, root, input, signal, options));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function formatLandBranch(receipt: LandBranchReceipt): string {
|
|
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}`;
|
|
499
|
+
const reason = receipt.worktree.reason ?? receipt.branch.reason ?? receipt.targetSync.reason;
|
|
500
|
+
return reason ? `${summary} ${landingError(reason)}` : summary;
|
|
501
|
+
}
|