@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 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, the source SHA, 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 match the proven PR head exactly; 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.
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.10",
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",
@@ -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.",
@@ -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
- pullRequest: { number: number; headSha: string; mergeCommitSha: string } | null;
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
- validated.worktree.branch === receipt.sourceBranch && worktreeHead === receipt.pullRequest?.headSha;
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?.headSha === head,
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