@brainervirus/workit-core 8.31.0 → 8.32.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.31.0",
3
+ "version": "8.32.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -17,7 +17,8 @@
17
17
  import { mergeBase, fileAt } from "../../git/rev";
18
18
  import { computeLane, driftFiles, kindOf, parseRiskPaths, RISK_PATHS_FILE } from "./lane";
19
19
 
20
- export type CarryRule = "lane-none" | "tests-only";
20
+ /** `rebase`: the same patch on a moved base (ledger.ts exact carry), never a drift rule. */
21
+ export type CarryRule = "lane-none" | "tests-only" | "rebase";
21
22
 
22
23
  export type Drift =
23
24
  | { carries: true; rule: CarryRule; since: string; files: string[] }
@@ -156,6 +156,13 @@ const workspaceConfigSchema = z
156
156
  * authorship lives in the ledger only (`commit.recorded`).
157
157
  */
158
158
  trailers: z.boolean().optional(),
159
+ /**
160
+ * Whether `workit pr merge` deletes the head branch after a merge (default
161
+ * true); `--keep-branch` opts out for one merge. Skipped, never refused,
162
+ * for a protected or base branch, a fork, a stack parent and a foreign
163
+ * author (forge/pr-ops.ts).
164
+ */
165
+ deleteBranch: z.boolean().optional(),
159
166
  /**
160
167
  * Whether the live-session guard denies edits in a checkout another live
161
168
  * agent session owns (default true; core/live-session.ts).
@@ -842,6 +849,11 @@ const inheritedSetting = (
842
849
  return { workspace: selected.name, source: [selected, ...enclosing].find(has) ?? null };
843
850
  };
844
851
 
852
+ /** Whether `pr merge` deletes the head branch by default: true unless a covering entry sets `deleteBranch: false`. */
853
+ export const workspaceDeleteBranch = (cwd: string, dir?: string): boolean =>
854
+ inheritedSetting(cwd, dir, (entry) => typeof entry.deleteBranch === "boolean")?.source
855
+ ?.deleteBranch !== false;
856
+
845
857
  /**
846
858
  * Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
847
859
  * The selected workspace's own `trailers` wins; unset, it inherits from the
@@ -44,8 +44,9 @@ type RestPr = {
44
44
  merged?: boolean;
45
45
  draft?: boolean;
46
46
  title?: string;
47
- base?: { ref?: string };
48
- head?: { ref?: string };
47
+ base?: { ref?: string; repo?: { full_name?: string } | null };
48
+ head?: { ref?: string; repo?: { full_name?: string } | null };
49
+ user?: { login?: string } | null;
49
50
  };
50
51
 
51
52
  type Page<T> = { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: T[] };
@@ -587,10 +588,27 @@ export function createGitHubForge(options: {
587
588
  title: value.title ?? "",
588
589
  base: value.base?.ref ?? "",
589
590
  headBranch: value.head?.ref ?? "",
591
+ author: value.user?.login ?? null,
592
+ // A deleted fork has no head repo: not ours, so a fork.
593
+ fork:
594
+ value.base?.repo?.full_name === undefined
595
+ ? null
596
+ : value.head?.repo?.full_name?.toLowerCase() !==
597
+ value.base.repo.full_name.toLowerCase(),
590
598
  };
591
599
  return success(meta);
592
600
  },
593
601
 
602
+ openPrsInto(branch) {
603
+ const list = rest<Array<{ number?: number }>>(
604
+ `repos/${repo}/pulls?state=open&base=${encodeURIComponent(branch)}&per_page=100`,
605
+ `open pull requests into ${branch}`,
606
+ );
607
+ if (!list.ok) return list;
608
+ if (!Array.isArray(list.data)) return failure("failed", "pull request list is not a list");
609
+ return success(list.data.flatMap((pr) => (typeof pr.number === "number" ? [pr.number] : [])));
610
+ },
611
+
594
612
  setDraft(number, draft) {
595
613
  const pr = restPr(number);
596
614
  if (!pr.ok) return pr;
@@ -36,6 +36,8 @@ type GlMr = {
36
36
  sha: string;
37
37
  diff_refs?: { base_sha?: string | null } | null;
38
38
  source_project_id?: number;
39
+ target_project_id?: number;
40
+ author?: { username?: string } | null;
39
41
  head_pipeline?: {
40
42
  id: number;
41
43
  sha: string;
@@ -626,10 +628,30 @@ export function createGitLabForge(options: {
626
628
  title: value.title ?? "",
627
629
  base: value.target_branch,
628
630
  headBranch: value.source_branch,
631
+ author: value.author?.username ?? null,
632
+ fork:
633
+ value.source_project_id === undefined || value.target_project_id === undefined
634
+ ? null
635
+ : value.source_project_id !== value.target_project_id,
629
636
  };
630
637
  return success(meta);
631
638
  },
632
639
 
640
+ openPrsInto(branch) {
641
+ const list = apiJson<GlMr[]>(
642
+ runner,
643
+ "glab",
644
+ apiHost,
645
+ api(
646
+ `${project}/merge_requests?state=opened&target_branch=${encodeURIComponent(branch)}&per_page=100`,
647
+ ),
648
+ `open merge requests into ${branch}`,
649
+ );
650
+ if (!list.ok) return list;
651
+ if (!Array.isArray(list.data)) return failure("failed", "merge request list is not a list");
652
+ return success(list.data.map((mr) => mr.iid));
653
+ },
654
+
633
655
  setDraft(iid, draft) {
634
656
  const mr = getMr(iid);
635
657
  if (!mr.ok) return mr;
@@ -0,0 +1,100 @@
1
+ // After `workit pr merge` deleted the remote head branch (#384): the local
2
+ // branch and the slice's clean fanout worktree go too. Never forced.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { gitRun, nulList } from "../fanout";
6
+ import { SCRATCH_DIR, listWorktrees } from "../fanout-worktree";
7
+ import { GIT_TIMEOUTS } from "../git/rev";
8
+ import { FANOUT_TOKEN_FILE } from "../hooks/worktree-confine";
9
+
10
+ const SLOW = GIT_TIMEOUTS.worktree;
11
+
12
+ /** `workit fanout worktree create` marked this worktree's admin dir with its token. */
13
+ const isFanoutWorktree = (worktree: string): boolean => {
14
+ const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
15
+ if (!dir.ok) return false;
16
+ try {
17
+ return fs.readFileSync(path.join(dir.stdout.trim(), FANOUT_TOKEN_FILE), "utf8").trim() !== "";
18
+ } catch {
19
+ return false;
20
+ }
21
+ };
22
+
23
+ const inside = (child: string, parent: string): boolean => {
24
+ const relative = path.relative(fs.realpathSync.native(parent), fs.realpathSync.native(child));
25
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
26
+ };
27
+
28
+ export type LocalCleanup = {
29
+ /** The local branch was deleted. */
30
+ branchDeleted: boolean;
31
+ /** The Workit fanout worktree that was on the branch and clean, removed. */
32
+ worktreeRemoved: string | null;
33
+ /** Why anything was kept: one line each. */
34
+ notes: string[];
35
+ };
36
+
37
+ /**
38
+ * After `pr merge` deleted the remote head branch: delete the local branch
39
+ * when it is not checked out anywhere, and first remove a clean worktree that
40
+ * `workit fanout worktree create` made on it. Never forced: a dirty worktree,
41
+ * one someone else made, the main checkout, the one this command runs in, or
42
+ * a local branch with commits beyond the merged head are kept and named.
43
+ */
44
+ export function cleanupMergedBranch(cwd: string, branch: string, mergedHead: string): LocalCleanup {
45
+ const out: LocalCleanup = { branchDeleted: false, worktreeRemoved: null, notes: [] };
46
+ if (!branch || branch.startsWith("-")) return out;
47
+ const ref = `refs/heads/${branch}`;
48
+ const tip = gitRun(cwd, ["rev-parse", "--verify", "--quiet", ref]);
49
+ if (!tip.ok) return out;
50
+ if (
51
+ tip.stdout.trim() !== mergedHead &&
52
+ !gitRun(cwd, ["merge-base", "--is-ancestor", tip.stdout.trim(), mergedHead]).ok
53
+ ) {
54
+ out.notes.push(`local ${branch} has commits that were not merged; kept it`);
55
+ return out;
56
+ }
57
+ const trees = listWorktrees(cwd);
58
+ for (const [index, entry] of trees.entries()) {
59
+ if (entry.branch !== ref) continue;
60
+ if (index === 0 || inside(cwd, entry.path)) {
61
+ out.notes.push(`local ${branch} is checked out in ${entry.path}; kept it`);
62
+ return out;
63
+ }
64
+ if (!isFanoutWorktree(entry.path)) {
65
+ out.notes.push(
66
+ `local ${branch} is checked out in ${entry.path}, not a Workit fanout worktree; kept both`,
67
+ );
68
+ return out;
69
+ }
70
+ const status = gitRun(entry.path, [
71
+ "status",
72
+ "--porcelain=v1",
73
+ "-z",
74
+ "--untracked-files=all",
75
+ "--ignore-submodules=none",
76
+ ]);
77
+ const dirty = status.ok
78
+ ? nulList(status.stdout).filter((line) => !line.slice(3).startsWith(`${SCRATCH_DIR}/`))
79
+ : ["unreadable"];
80
+ if (dirty.length > 0) {
81
+ out.notes.push(
82
+ `worktree ${entry.path} has ${dirty.length === 1 && dirty[0] === "unreadable" ? "an unreadable state" : `${dirty.length} uncommitted change${dirty.length === 1 ? "" : "s"}`}; kept it and local ${branch}`,
83
+ );
84
+ return out;
85
+ }
86
+ const removed = gitRun(cwd, ["worktree", "remove", entry.path], SLOW);
87
+ if (!removed.ok) {
88
+ out.notes.push(
89
+ `could not remove worktree ${entry.path} (${removed.stderr.trim().split("\n")[0]}); kept local ${branch}`,
90
+ );
91
+ return out;
92
+ }
93
+ out.worktreeRemoved = entry.path;
94
+ }
95
+ if (listWorktrees(cwd).some((entry) => entry.branch === ref)) return out;
96
+ const deleted = gitRun(cwd, ["branch", "-D", "--", branch]);
97
+ if (deleted.ok) out.branchDeleted = true;
98
+ else out.notes.push(`could not delete local ${branch}: ${deleted.stderr.trim().split("\n")[0]}`);
99
+ return out;
100
+ }
@@ -343,7 +343,13 @@ export async function createPullRequest(
343
343
  export type MergeInput = {
344
344
  pr: number | null;
345
345
  method: MergeMethod;
346
+ /** `--delete-branch`: delete, refusing a protected branch before the merge. */
346
347
  deleteBranch: boolean;
348
+ /**
349
+ * The workspace default (`deleteBranch`, no `--keep-branch`): delete when it
350
+ * is safe, otherwise skip with a note; never refuses the merge.
351
+ */
352
+ implicitDelete?: boolean;
347
353
  actor: LedgerActor;
348
354
  /**
349
355
  * Stack landing (S12): refuse unless the PR still targets this base and
@@ -388,7 +394,8 @@ export type MergeOutcome = {
388
394
  /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
389
395
  unverified: { reason: string; recorded: string } | null;
390
396
  grant: { source: AutonomySource };
391
- deletedBranch: boolean | { error: string };
397
+ /** `skipped`: the implicit default found a reason to keep the branch; the merge is unaffected. */
398
+ deletedBranch: boolean | { error: string } | { skipped: string };
392
399
  recorded: { id: string } | { error: string };
393
400
  /** The `pr.merge.verified` row confirming `recorded`, or why it was not written. */
394
401
  confirmed: { id: string } | { error: string };
@@ -590,6 +597,41 @@ function squashCommitList(
590
597
  );
591
598
  }
592
599
 
600
+ /**
601
+ * Why the implicit delete keeps `branch`, or null when it is safe to delete:
602
+ * a protected, base or default-target branch, a fork's branch, a branch another
603
+ * open PR targets (a stack child), and a PR by someone other than the
604
+ * workspace's `vcs.account`. What cannot be read keeps the branch too.
605
+ */
606
+ function implicitDeleteSkip(
607
+ cwd: string,
608
+ resolved: ResolvedForge,
609
+ doc: PrStatusDoc,
610
+ branch: string,
611
+ ): string | null {
612
+ const resolvedVcs = vcsConfig("resolve", cwd);
613
+ const defaultTarget =
614
+ resolvedVcs.ok === false ? null : String(resolvedVcs.defaultTargetBranch ?? "") || null;
615
+ const account = resolved.expectedAccount?.trim() ?? "";
616
+ const lower = branch.toLowerCase();
617
+ if (isProtectedTarget(cwd, branch))
618
+ return `${branch} is protected by the workspace branch policy`;
619
+ if (lower === doc.base.toLowerCase()) return `${branch} is the PR base`;
620
+ if (defaultTarget !== null && lower === defaultTarget.toLowerCase())
621
+ return `${branch} is the default target branch`;
622
+ if (doc.headRepo !== null) return `${branch} is a fork's branch (${doc.headRepo})`;
623
+ const meta = resolved.forge.prMeta(doc.number);
624
+ if (!meta.ok) return `could not read the PR author and head repository (${meta.error})`;
625
+ if (meta.data.fork !== false) return `${branch} is not known to be in ${resolved.forge.repo}`;
626
+ if (account && (meta.data.author ?? "").toLowerCase() !== account.toLowerCase())
627
+ return `the PR is by ${meta.data.author ?? "an unknown author"}, not ${account} (vcs.account)`;
628
+ const children = resolved.forge.openPrsInto(branch);
629
+ if (!children.ok) return `could not list the open PRs into ${branch} (${children.error})`;
630
+ if (children.data.length)
631
+ return `open PR ${children.data.map((n) => `#${n}`).join(", ")} targets ${branch} (a stack)`;
632
+ return null;
633
+ }
634
+
593
635
  export async function mergePullRequest(
594
636
  cwd: string,
595
637
  resolved: ResolvedForge,
@@ -841,7 +883,13 @@ export async function mergePullRequest(
841
883
  : { error: confirmation.error };
842
884
 
843
885
  let deletedBranch: MergeOutcome["deletedBranch"] = false;
844
- if (input.deleteBranch) {
886
+ const skipReason =
887
+ !input.deleteBranch && input.implicitDelete
888
+ ? implicitDeleteSkip(cwd, resolved, doc, branch)
889
+ : null;
890
+ if (!input.deleteBranch && input.implicitDelete && skipReason !== null)
891
+ deletedBranch = { skipped: skipReason };
892
+ else if (input.deleteBranch || input.implicitDelete) {
845
893
  const deleted = deleteRemoteBranch(cwd, resolved.remote, branch, head);
846
894
  // The forge may delete merged branches itself; gone is deleted.
847
895
  const gone = !deleted.ok && remoteRefTip(cwd, resolved.remote, `refs/heads/${branch}`);
@@ -161,6 +161,10 @@ export type PrMeta = {
161
161
  title: string;
162
162
  base: string;
163
163
  headBranch: string;
164
+ /** The author's login; null when the forge does not say. */
165
+ author: string | null;
166
+ /** The head comes from another repository than the PR's; null when unknown. */
167
+ fork: boolean | null;
164
168
  };
165
169
 
166
170
  /** What `workit pr edit` changes; unset fields are left alone. */
@@ -205,6 +209,8 @@ export interface Forge {
205
209
  updateBase(n: number, base: string): ForgeResult<void>;
206
210
  /** Title, draft flag, base and state of one PR/MR. */
207
211
  prMeta(n: number): ForgeResult<PrMeta>;
212
+ /** Numbers of the open PRs/MRs that target `branch` as their base (stack children). */
213
+ openPrsInto(branch: string): ForgeResult<number[]>;
208
214
  /** Mark a draft ready (`draft: false`) or convert it back to a draft. */
209
215
  setDraft(n: number, draft: boolean): ForgeResult<void>;
210
216
  editPr(n: number, edit: PrEdit): ForgeResult<void>;
package/src/ledger.ts CHANGED
@@ -33,7 +33,8 @@
33
33
  // `base`, the stable patch-id of `base...head` and an exact diff hash. It is
34
34
  // `fresh` (same head; verdicts on a dirty worktree are refused, so the head
35
35
  // is the judged tree), `carried` (new head, same patch-id AND same exact
36
- // diff AND, for a pass, byte-identical own files the base left alone: a
36
+ // diff AND, for a pass, byte-identical own files the base left alone, or
37
+ // a head that is exactly the judged head cleanly rebased (cleanRebase): a
37
38
  // rebase that only moved the base; or a trusted verdict whose
38
39
  // follow-up change is lane none, or test-only for a unit verdict, see
39
40
  // core/policy/carry.ts) or `stale`.
@@ -338,6 +339,32 @@ export function diffHash(cwd: string, base: string, head: string): string | null
338
339
  return `sha256:${hash.digest("hex")}`;
339
340
  }
340
341
 
342
+ /**
343
+ * Whether `to` (on `toBase`) is exactly the judged `from` (on `fromBase`)
344
+ * rebased cleanly onto the moved base: a 3-way merge of `from` onto
345
+ * `merge-base(toBase, to)` with `merge-base(fromBase, from)` as the ancestor
346
+ * has no conflict and yields `to`'s tree. Hunks are anchored to the
347
+ * ancestor, so an edit moved elsewhere in a file, a conflict resolved by
348
+ * hand, or any other change in the branch's files makes the trees differ.
349
+ * False when git cannot tell (no `merge-tree --merge-base`, before 2.40).
350
+ */
351
+ function cleanRebase(
352
+ cwd: string,
353
+ range: { from: string; to: string; fromBase: string; toBase: string },
354
+ ): boolean {
355
+ if (!Object.values(range).every(safeRef)) return false;
356
+ const fromFork = git(cwd, ["merge-base", range.fromBase, range.from]);
357
+ const toFork = git(cwd, ["merge-base", range.toBase, range.to]);
358
+ if (!fromFork || !toFork || fromFork === toFork) return false;
359
+ const merged = gitRun(
360
+ cwd,
361
+ ["merge-tree", "--write-tree", "--no-messages", `--merge-base=${fromFork}`, toFork, range.from],
362
+ GIT_TIMEOUTS.worktree,
363
+ );
364
+ const tree = merged.ok ? merged.stdout.split("\n")[0]?.trim() : null;
365
+ return Boolean(tree) && tree === git(cwd, ["rev-parse", "--verify", "-q", `${range.to}^{tree}`]);
366
+ }
367
+
341
368
  // ---------------------------------------------------------------------------
342
369
  // store root (D13)
343
370
 
@@ -1124,7 +1151,9 @@ export type Reverify = { scope: "focused"; since: string; files: string[] };
1124
1151
 
1125
1152
  /** `carried (lane none since <head>)`: how a carried verdict reads in `ledger check` and `pr status`. */
1126
1153
  export const carryLabel = (carry: CarryNote): string =>
1127
- `carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
1154
+ carry.rule === "rebase"
1155
+ ? "carried (rebase: patch unchanged)"
1156
+ : `carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
1128
1157
 
1129
1158
  export type VerdictCheck = {
1130
1159
  branch: string;
@@ -1516,31 +1545,49 @@ export function checkVerdicts(
1516
1545
  };
1517
1546
  // The same patch-id and diff hash are not enough to carry a pass (#334):
1518
1547
  // both drop hunk line numbers, so an edit moved within a file keeps them.
1519
- // The branch's own files must be identical blobs too (sameOwnChange). A
1520
- // failure keeps the looser carry: it fails closed by applying more.
1521
- const exactCache = new Map<ReadRow, boolean>();
1522
- const exactCarry = (row: ReadRow): boolean => {
1523
- if (FAILING.has(String(row.result))) return true;
1548
+ // The branch's own files must be identical blobs too (sameOwnChange), or
1549
+ // the head must be exactly the judged head cleanly rebased onto the moved
1550
+ // base (cleanRebase): a base edit elsewhere in a file the branch touches.
1551
+ // A failure keeps the looser carry: it fails closed by applying more.
1552
+ // How a pass's own files match: identical blobs, a clean rebase, or neither.
1553
+ const exactCache = new Map<ReadRow, "same" | "rebase" | null>();
1554
+ const exactMatch = (row: ReadRow): "same" | "rebase" | null => {
1524
1555
  let exact = exactCache.get(row);
1525
1556
  if (exact === undefined) {
1526
1557
  const toBase = row.base ?? fallbackBase;
1527
1558
  const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
1528
1559
  const fromBase = row.baseSha ?? toBaseSha;
1529
- exact = Boolean(
1530
- row.head &&
1531
- head &&
1532
- toBaseSha &&
1533
- fromBase &&
1534
- sameOwnChange(cwd, { from: row.head, to: head, fromBase, toBase: toBaseSha }),
1535
- );
1560
+ const range =
1561
+ row.head && head && toBaseSha && fromBase
1562
+ ? { from: row.head, to: head, fromBase, toBase: toBaseSha }
1563
+ : null;
1564
+ exact = !range
1565
+ ? null
1566
+ : sameOwnChange(cwd, range)
1567
+ ? "same"
1568
+ : cleanRebase(cwd, range)
1569
+ ? "rebase"
1570
+ : null;
1536
1571
  exactCache.set(row, exact);
1537
1572
  }
1538
1573
  return exact;
1539
1574
  };
1575
+ const exactCarry = (row: ReadRow): boolean =>
1576
+ FAILING.has(String(row.result)) || exactMatch(row) !== null;
1540
1577
  const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1541
1578
  const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
1542
1579
  return basis === "carried" && !exactCarry(row) ? "stale" : basis;
1543
1580
  };
1581
+ /** A pass the exact keys carry only through a clean rebase (the base also edited a branch file). */
1582
+ const rebaseNote = (row: ReadRow): CarryNote | null =>
1583
+ row.head &&
1584
+ head &&
1585
+ row.head !== head &&
1586
+ !FAILING.has(String(row.result)) &&
1587
+ keyBasis(row) === "carried" &&
1588
+ exactMatch(row) === "rebase"
1589
+ ? { rule: "rebase", since: row.head, files: [] }
1590
+ : null;
1544
1591
  const restacked = (row: ReadRow): boolean =>
1545
1592
  restackCarries(FAILING.has(String(row.result)) ? null : cwd, rows, branch, row.head, head);
1546
1593
  /** The follow-up carry of a verdict the exact keys and restacks do not carry. */
@@ -1646,7 +1693,7 @@ export function checkVerdicts(
1646
1693
  independent: independence.length === 0,
1647
1694
  accepted: reasons.length === 0,
1648
1695
  reasons,
1649
- carry: basis === "carried" ? carryOf(row) : null,
1696
+ carry: basis === "carried" ? (rebaseNote(row) ?? carryOf(row)) : null,
1650
1697
  verdict: row,
1651
1698
  });
1652
1699
  // The author's own failure never displaces an independent pass, but it