@senad-d/branchme 0.3.10 → 0.3.11
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 +5 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/blocker-guidance.ts +10 -0
- package/src/git-landing.ts +23 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.11 - Unreleased
|
|
4
|
+
|
|
5
|
+
- `land_branch` with `pullRequestNumber` accepts a local source branch that is the merged PR head or one of its ancestors: a branch left behind because the PR received commits pushed from another checkout (for example a merge of the base) holds nothing the PR lacks, and is retired. A local branch with commits the PR head does not contain is still refused, now with its own message and Blocked/Resolve guidance; a PR head that is not available locally counts as not containing it.
|
|
6
|
+
- Blocked/Resolve guidance for the PR-evidence refusals of `land_branch`.
|
|
7
|
+
|
|
3
8
|
## 0.3.10 - Unreleased
|
|
4
9
|
|
|
5
10
|
- Added `reply_pull_request_feedback`: one reply in a review thread of a PR, optionally resolving it, or one PR conversation comment, on open, merged and closed PRs of the repository (forks included). The thread is verified to belong to the PR, and a conversation comment's number to be a pull request, before anything is posted. Agents that address PR feedback can now answer every comment on GitHub, so the reviewer, and the next automated check, see what was handled.
|
package/README.md
CHANGED
|
@@ -415,7 +415,7 @@ After the pull request **merged on the host**, run from the repository root:
|
|
|
415
415
|
|
|
416
416
|
`remote` defaults to `origin`. Omit `worktreePath` to find the linked worktree holding `sourceBranch`, if any. An explicit path can also select a worktree incorrectly parked on `targetBranch`; unrelated branches, primary/root checkouts, detached, locked, dirty, or foreign worktrees are not removed. Invocation from inside the removal directory (including a nested or symlinked cwd) is refused: **run from the repository root**. Other caller directories in the same repository are supported; every Git command has an explicit `-C` directory, and cleanup runs through the primary checkout. When the merged source branch is checked out in the primary checkout and its tracked files are clean, `land_branch` switches that checkout to the target branch first (`checkout: switched`) so the source can be retired; untracked files stay in place.
|
|
417
417
|
|
|
418
|
-
The tool fetches the exact remote target and by default requires the captured source tip to be its ancestor before cleanup. For squash/rebase merges, supply `pullRequestNumber`. BranchMe verifies a closed, merged PR in the same GitHub repository as the configured landing remote, exact head/base branch names,
|
|
418
|
+
The tool fetches the exact remote target and by default requires the captured source tip to be its ancestor before cleanup. For squash/rebase merges, supply `pullRequestNumber`. BranchMe verifies a closed, merged PR in the same GitHub repository as the configured landing remote, exact head/base branch names, a source SHA equal to the PR head or one of its ancestors (a local branch left behind holds nothing the PR lacks; one with commits the PR head does not contain is refused), and containment of the reported merge commit in the fetched target. Supplying a PR number requires valid evidence even when ordinary ancestry succeeds. There is no general force escape hatch. This host-merge proof authorizes retiring the original rewritten source history; those original commits may eventually become unreachable. An existing selected source checkout must be at the proven PR head or at that contained source SHA; a checkout parked on the target still needs target ancestry. Tracked/staged changes and non-ignored untracked files block removal. **Ignored `.env`, `.pi/`, `node_modules/`, `dist/`, and other ignored residue are deleted with the worktree**; preserve anything needed first. Only their top-level path entries, never contents, appear in `deletedIgnoredPaths`. Source retirement uses the captured expected-HEAD lease and the **remote-tracking target**, not local `HEAD` or a stale local target.
|
|
419
419
|
|
|
420
420
|
Target sync is last, even when worktree removal or branch retirement is refused:
|
|
421
421
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senad-d/branchme",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension for verified Git repository initialization, branch, worktree, integration, retirement, push, and pull request workflows.",
|
|
6
6
|
"license": "MIT",
|
package/src/blocker-guidance.ts
CHANGED
|
@@ -112,6 +112,16 @@ const BLOCKER_RULES: readonly BlockerRule[] = [
|
|
|
112
112
|
why: "no local branch has that name.",
|
|
113
113
|
resolve: "list_branches to check names (fetch_remote first for remote branches); track_branch creates a local branch from a remote one.",
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
pattern: /has commits the merged PR head does not contain/iu,
|
|
117
|
+
why: "the local branch holds commits that never reached the merged pull request, so deleting it would lose them.",
|
|
118
|
+
resolve: "report the branch and the PR; those commits need their own delivery (a new branch and pull request from another step) or the user's decision to discard them. Do not retry land_branch unchanged.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
pattern: /PR merge evidence does not match/iu,
|
|
122
|
+
why: "the pull request is not merged, or names a different head or base branch than the landing request.",
|
|
123
|
+
resolve: "read pull_request_status for the PR and retry land_branch once with its exact head and base branch names.",
|
|
124
|
+
},
|
|
115
125
|
{
|
|
116
126
|
pattern: /moved after|changed while|moved concurrently|changed during/iu,
|
|
117
127
|
why: "repository state changed while the call ran.",
|
package/src/git-landing.ts
CHANGED
|
@@ -55,7 +55,8 @@ export interface LandBranchReceipt {
|
|
|
55
55
|
sourceHead: string | null;
|
|
56
56
|
ancestry: { isAncestor: boolean | null };
|
|
57
57
|
mergeProof: "ancestry" | "pull-request" | null;
|
|
58
|
-
|
|
58
|
+
/** sourceInHead: the local source head is the merged PR head or one of its ancestors (a branch left behind). */
|
|
59
|
+
pullRequest: { number: number; headSha: string; mergeCommitSha: string; sourceInHead: boolean } | null;
|
|
59
60
|
worktree: {
|
|
60
61
|
path: string | null;
|
|
61
62
|
outcome: "removed" | "absent" | "refused";
|
|
@@ -240,8 +241,8 @@ async function removeLandingWorktree(pi: GitAPI, receipt: LandBranchReceipt, sig
|
|
|
240
241
|
const sourceHead = await captureLocalHead(pi, ctx, receipt.sourceBranch, signal);
|
|
241
242
|
if (sourceHead !== receipt.sourceHead) throw new Error("Source branch moved after the ancestry proof; no worktree removed.");
|
|
242
243
|
const worktreeHead = validated.worktree.head;
|
|
243
|
-
const exactMergedPrHead = receipt.mergeProof === "pull-request" &&
|
|
244
|
-
|
|
244
|
+
const exactMergedPrHead = receipt.mergeProof === "pull-request" && validated.worktree.branch === receipt.sourceBranch &&
|
|
245
|
+
(worktreeHead === receipt.pullRequest?.headSha || (worktreeHead === receipt.sourceHead && receipt.pullRequest?.sourceInHead === true));
|
|
245
246
|
if (!worktreeHead || !receipt.remoteTargetHead ||
|
|
246
247
|
(!exactMergedPrHead && !(await isCommitAncestor(pi, ctx, worktreeHead, receipt.remoteTargetHead, signal)))) {
|
|
247
248
|
throw new Error("Selected worktree HEAD is not merged into the fetched target.");
|
|
@@ -297,7 +298,7 @@ async function retireLandingBranch(pi: GitAPI, receipt: LandBranchReceipt, signa
|
|
|
297
298
|
targetBranch: `${receipt.remote}/${receipt.targetBranch}`,
|
|
298
299
|
// A verified host merge authorizes retiring rewritten source history, not a
|
|
299
300
|
// general force option. Identity, occupancy, target stability and lease checks remain.
|
|
300
|
-
force: receipt.mergeProof === "pull-request" && receipt.pullRequest?.
|
|
301
|
+
force: receipt.mergeProof === "pull-request" && receipt.pullRequest?.sourceInHead === true && head === receipt.sourceHead,
|
|
301
302
|
}, ctx.cwd, signal, "remote-tracking", receipt.remoteTargetHead);
|
|
302
303
|
receipt.branch.outcome = "deleted";
|
|
303
304
|
receipt.branch.reason = null;
|
|
@@ -415,6 +416,15 @@ async function refuseLanding(pi: GitAPI, receipt: LandBranchReceipt, error: unkn
|
|
|
415
416
|
return receipt;
|
|
416
417
|
}
|
|
417
418
|
|
|
419
|
+
// A commit missing locally (an unfetched squash-merged PR head) proves nothing: not contained.
|
|
420
|
+
async function commitContains(pi: GitAPI, ctx: GitCommandContext, ancestor: string, descendant: string, signal?: AbortSignal): Promise<boolean> {
|
|
421
|
+
try {
|
|
422
|
+
return await isCommitAncestor(pi, ctx, ancestor, descendant, signal);
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
418
428
|
async function verifyLandingPullRequest(
|
|
419
429
|
pi: GitAPI,
|
|
420
430
|
ctx: GitCommandContext,
|
|
@@ -431,14 +441,20 @@ async function verifyLandingPullRequest(
|
|
|
431
441
|
}
|
|
432
442
|
const token = (await resolveGitHubToken(options.env, { cwd: ctx.cwd, signal })).token;
|
|
433
443
|
const pr = await getGitHubPullRequest(repository, number, token, { fetchImpl: options.fetchImpl, signal });
|
|
434
|
-
if (!pr.merged || pr.state !== "closed" || !pr.mergeCommitSha || pr.head !== receipt.sourceBranch ||
|
|
435
|
-
pr.base !== receipt.targetBranch || (receipt.sourceHead !== null && pr.headSha !== receipt.sourceHead)) {
|
|
444
|
+
if (!pr.merged || pr.state !== "closed" || !pr.mergeCommitSha || pr.head !== receipt.sourceBranch || pr.base !== receipt.targetBranch) {
|
|
436
445
|
throw new Error("PR merge evidence does not match the exact source commit and target branch; landing refused.");
|
|
437
446
|
}
|
|
447
|
+
// A local branch behind the merged head (the PR received commits pushed from another
|
|
448
|
+
// checkout, such as a merge of the base) holds nothing the PR lacks.
|
|
449
|
+
const sourceInHead = receipt.sourceHead === null || pr.headSha === receipt.sourceHead ||
|
|
450
|
+
await commitContains(pi, ctx, receipt.sourceHead, pr.headSha, signal);
|
|
451
|
+
if (!sourceInHead) {
|
|
452
|
+
throw new Error("The local source branch has commits the merged PR head does not contain; landing refused to keep them.");
|
|
453
|
+
}
|
|
438
454
|
if (!receipt.remoteTargetHead || !(await isCommitAncestor(pi, ctx, pr.mergeCommitSha, receipt.remoteTargetHead, signal))) {
|
|
439
455
|
throw new Error("The PR merge commit is not contained in the fetched remote target; landing refused.");
|
|
440
456
|
}
|
|
441
|
-
receipt.pullRequest = { number: pr.number, headSha: pr.headSha, mergeCommitSha: pr.mergeCommitSha };
|
|
457
|
+
receipt.pullRequest = { number: pr.number, headSha: pr.headSha, mergeCommitSha: pr.mergeCommitSha, sourceInHead };
|
|
442
458
|
receipt.mergeProof = "pull-request";
|
|
443
459
|
}
|
|
444
460
|
|