@brainervirus/workit-core 8.31.1 → 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 +1 -1
- package/src/core/workspaces.ts +12 -0
- package/src/forge/github.ts +20 -2
- package/src/forge/gitlab.ts +22 -0
- package/src/forge/merge-cleanup.ts +100 -0
- package/src/forge/pr-ops.ts +50 -2
- package/src/forge/types.ts +6 -0
package/package.json
CHANGED
package/src/core/workspaces.ts
CHANGED
|
@@ -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
|
package/src/forge/github.ts
CHANGED
|
@@ -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;
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/forge/types.ts
CHANGED
|
@@ -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>;
|