nugit-cli 0.1.0 → 0.1.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +0 -12
  3. package/src/github-rest.js +35 -0
  4. package/src/nugit-config.js +84 -0
  5. package/src/nugit-stack.js +29 -266
  6. package/src/nugit.js +103 -661
  7. package/src/review-hub/review-hub-ink.js +6 -3
  8. package/src/review-hub/run-review-hub.js +34 -91
  9. package/src/services/repo-branches.js +151 -0
  10. package/src/services/stack-inference.js +90 -0
  11. package/src/split-view/run-split.js +14 -89
  12. package/src/stack-view/infer-chains-to-pick-stacks.js +10 -0
  13. package/src/stack-view/ink-app.js +3 -2118
  14. package/src/stack-view/loader.js +19 -93
  15. package/src/stack-view/loading-ink.js +2 -44
  16. package/src/stack-view/merge-alternate-pick-stacks.js +23 -1
  17. package/src/stack-view/remote-infer-doc.js +28 -45
  18. package/src/stack-view/run-stack-view.js +249 -526
  19. package/src/stack-view/run-view-entry.js +14 -18
  20. package/src/stack-view/stack-pick-ink.js +169 -131
  21. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  22. package/src/stack-view/terminal-fullscreen.js +7 -45
  23. package/src/tui/pages/home.js +122 -0
  24. package/src/tui/pages/repo-actions.js +81 -0
  25. package/src/tui/pages/repo-branches.js +259 -0
  26. package/src/tui/pages/viewer.js +2129 -0
  27. package/src/tui/router.js +40 -0
  28. package/src/tui/run-tui.js +281 -0
  29. package/src/utilities/loading.js +37 -0
  30. package/src/utilities/terminal.js +31 -0
  31. package/src/cli-output.js +0 -228
  32. package/src/nugit-start.js +0 -211
  33. package/src/stack-discover.js +0 -292
  34. package/src/stack-discovery-config.js +0 -91
  35. package/src/stack-extra-commands.js +0 -353
  36. package/src/stack-graph.js +0 -214
  37. package/src/stack-helpers.js +0 -58
  38. package/src/stack-propagate.js +0 -422
@@ -14,8 +14,9 @@ const VIEWPORT_ROWS = 14;
14
14
  * @param {object} props
15
15
  * @param {HubLine[]} props.lines
16
16
  * @param {(fullName: string) => void} props.onPickRepo
17
+ * @param {() => void} [props.onBack] Called when user presses Backspace/Esc to go back
17
18
  */
18
- export function ReviewHubInk({ lines, onPickRepo }) {
19
+ export function ReviewHubInk({ lines, onPickRepo, onBack }) {
19
20
  const { exit } = useApp();
20
21
  const { stdout } = useStdout();
21
22
  /** @type {React.MutableRefObject<{ firstListRow: number, viewStart: number, slice: HubLine[] } | null>} */
@@ -91,7 +92,9 @@ export function ReviewHubInk({ lines, onPickRepo }) {
91
92
  return;
92
93
  }
93
94
 
94
- if (input === "q" || key.escape) {
95
+ const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
96
+ if (input === "q" || key.escape || backKey) {
97
+ onBack?.();
95
98
  exit();
96
99
  return;
97
100
  }
@@ -159,7 +162,7 @@ export function ReviewHubInk({ lines, onPickRepo }) {
159
162
  Box,
160
163
  { flexDirection: "column", padding: 1 },
161
164
  React.createElement(Text, { color: "cyan", bold: true }, "nugit review — repositories"),
162
- React.createElement(Text, { dimColor: true }, "j/k · 1-9 open · Enter/Space · click · wheel · q quit"),
165
+ React.createElement(Text, { dimColor: true }, "j/k · 1-9 open · Enter/Space · click · wheel · Backspace/q back"),
163
166
  status ? React.createElement(Text, { dimColor: true }, status) : null,
164
167
  ...rendered
165
168
  );
@@ -1,18 +1,6 @@
1
- import React from "react";
2
- import { render } from "ink";
3
1
  import { authMe } from "../api-client.js";
4
- import {
5
- githubListAllUserRepos,
6
- githubSearchIssues
7
- } from "../github-rest.js";
2
+ import { githubListAllUserRepos, githubSearchIssues } from "../github-rest.js";
8
3
  import { resolveGithubToken } from "../auth-token.js";
9
- import { runStackViewCommand } from "../stack-view/run-stack-view.js";
10
- import { ReviewHubInk } from "./review-hub-ink.js";
11
- import { ReviewHubBackError } from "./review-hub-back.js";
12
- import {
13
- enterAlternateScreen,
14
- leaveAlternateScreen
15
- } from "../stack-view/terminal-fullscreen.js";
16
4
 
17
5
  /**
18
6
  * @param {string} login
@@ -30,9 +18,7 @@ async function pendingReviewsByRepo(login) {
30
18
  page
31
19
  );
32
20
  const items = Array.isArray(res.items) ? res.items : [];
33
- if (items.length === 0) {
34
- break;
35
- }
21
+ if (items.length === 0) break;
36
22
  for (const it of items) {
37
23
  if (!it || typeof it !== "object") continue;
38
24
  const u = /** @type {Record<string, unknown>} */ (it).repository_url;
@@ -42,9 +28,7 @@ async function pendingReviewsByRepo(login) {
42
28
  const full = `${m[1]}/${m[2]}`;
43
29
  map.set(full, (map.get(full) || 0) + 1);
44
30
  }
45
- if (items.length < 100) {
46
- break;
47
- }
31
+ if (items.length < 100) break;
48
32
  page += 1;
49
33
  }
50
34
  return map;
@@ -59,7 +43,10 @@ function normalizeRepo(repoObj, pendingMap) {
59
43
  const r = /** @type {Record<string, unknown>} */ (repoObj);
60
44
  const fn = typeof r.full_name === "string" ? r.full_name : "";
61
45
  if (!fn) return null;
62
- const owner = r.owner && typeof r.owner === "object" ? /** @type {Record<string, unknown>} */ (r.owner) : {};
46
+ const owner =
47
+ r.owner && typeof r.owner === "object"
48
+ ? /** @type {Record<string, unknown>} */ (r.owner)
49
+ : {};
63
50
  const ownerType = typeof owner.type === "string" ? owner.type : "User";
64
51
  const pending = pendingMap.get(fn) || 0;
65
52
  return { fullName: fn, ownerType, pending };
@@ -67,8 +54,9 @@ function normalizeRepo(repoObj, pendingMap) {
67
54
 
68
55
  /**
69
56
  * @param {{ fullName: string, ownerType: string, pending: number }[]} repos
57
+ * @returns {import("./review-hub-ink.js").HubLine[]}
70
58
  */
71
- function buildHubLines(repos) {
59
+ export function buildHubLines(repos) {
72
60
  const sorted = [...repos].sort((a, b) => {
73
61
  if (b.pending !== a.pending) return b.pending - a.pending;
74
62
  return a.fullName.localeCompare(b.fullName);
@@ -83,28 +71,22 @@ function buildHubLines(repos) {
83
71
  const key = `${bucket}:${ownerLogin}`;
84
72
  if (key !== lastBucket) {
85
73
  lastBucket = key;
86
- lines.push({
87
- kind: "header",
88
- text: `${bucket} — ${ownerLogin}`
89
- });
74
+ lines.push({ kind: "header", text: `${bucket} — ${ownerLogin}` });
90
75
  }
91
- lines.push({
92
- kind: "repo",
93
- fullName: r.fullName,
94
- pending: r.pending
95
- });
76
+ lines.push({ kind: "repo", fullName: r.fullName, pending: r.pending });
96
77
  }
97
78
  return lines;
98
79
  }
99
80
 
100
81
  /**
101
- * @param {{ noTui?: boolean, autoApply?: boolean }} opts
82
+ * Fetch all data needed for the review hub UI.
83
+ * Does NOT touch the terminal or render any UI.
84
+ * @returns {Promise<{ login: string, lines: import("./review-hub-ink.js").HubLine[] }>}
102
85
  */
103
- export async function runReviewHub(opts) {
86
+ export async function fetchReviewHubData() {
104
87
  if (!resolveGithubToken()) {
105
88
  throw new Error(
106
- "GitHub token required for nugit review. Run `nugit auth login` or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN " +
107
- "(PAT must allow repo access and search for review-requested PRs)."
89
+ "GitHub token required for the review hub. Run `nugit auth login` or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN."
108
90
  );
109
91
  }
110
92
 
@@ -119,70 +101,31 @@ export async function runReviewHub(opts) {
119
101
  pendingReviewsByRepo(login)
120
102
  ]);
121
103
 
122
- const repos = [];
104
+ const repos = /** @type {{ fullName: string, ownerType: string, pending: number }[]} */ ([]);
123
105
  for (const r of rawRepos) {
124
106
  const n = normalizeRepo(r, pendingMap);
125
107
  if (n) repos.push(n);
126
108
  }
127
109
 
128
- if (opts.noTui || !process.stdin.isTTY || !process.stdout.isTTY) {
129
- for (const r of [...repos].sort((a, b) => b.pending - a.pending || a.fullName.localeCompare(b.fullName))) {
130
- const p = r.pending ? ` (${r.pending} pending)` : "";
131
- console.log(`${r.fullName}${p}`);
132
- }
133
- return;
134
- }
110
+ const lines = buildHubLines(repos);
111
+ return { login, lines };
112
+ }
135
113
 
136
- const tty = process.stdin.isTTY && process.stdout.isTTY;
137
- if (tty) {
138
- enterAlternateScreen(process.stdout);
139
- }
140
- try {
141
- for (;;) {
142
- const lines = buildHubLines(repos);
143
- if (lines.filter((l) => l.kind === "repo").length === 0) {
144
- console.error("No repositories visible to this token.");
145
- return;
146
- }
114
+ /**
115
+ * Non-TUI (scripting / CI) path: print repo list to stdout.
116
+ * @param {{ noTui?: boolean, autoApply?: boolean }} opts
117
+ */
118
+ export async function runReviewHub(opts) {
119
+ const { login, lines } = await fetchReviewHubData();
120
+ void login;
147
121
 
148
- let picked = /** @type {string | null} */ (null);
149
- const { waitUntilExit } = render(
150
- React.createElement(ReviewHubInk, {
151
- lines,
152
- onPickRepo: (fn) => {
153
- picked = fn;
154
- }
155
- })
156
- );
157
- await waitUntilExit();
158
- if (!picked) {
159
- return;
160
- }
122
+ const repos = lines
123
+ .filter((l) => l.kind === "repo")
124
+ .map((l) => /** @type {{ kind: "repo", fullName: string, pending: number }} */ (l));
161
125
 
162
- try {
163
- await runStackViewCommand({
164
- repo: picked,
165
- noTui: false,
166
- allowBackToReviewHub: true,
167
- reviewAutoapply: !!opts.autoApply,
168
- reviewFetchOpts: {
169
- viewerLogin: login,
170
- fullReviewFetch: true
171
- },
172
- viewTitle: "nugit review",
173
- shellMode: false
174
- });
175
- break;
176
- } catch (e) {
177
- if (e instanceof ReviewHubBackError) {
178
- continue;
179
- }
180
- throw e;
181
- }
182
- }
183
- } finally {
184
- if (tty) {
185
- leaveAlternateScreen(process.stdout);
186
- }
126
+ for (const r of repos) {
127
+ const p = r.pending ? ` (${r.pending} pending)` : "";
128
+ console.log(`${r.fullName}${p}`);
187
129
  }
130
+ void opts;
188
131
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Service functions for the "Branches / PRs" TUI page.
3
+ * Fuses local git branches with remote GitHub branches into a unified model.
4
+ */
5
+
6
+ import { execFileSync } from "child_process";
7
+ import { githubListAllBranches } from "../github-rest.js";
8
+
9
+ /**
10
+ * @typedef {{ name: string, sha: string }} LocalBranch
11
+ * @typedef {{ name: string, sha: string }} RemoteBranch
12
+ * @typedef {"local_only" | "remote_only" | "in_sync" | "local_ahead" | "remote_ahead" | "diverged"} BranchStatus
13
+ * @typedef {{ name: string, local: LocalBranch | null, remote: RemoteBranch | null, status: BranchStatus, ahead: number, behind: number }} BranchRow
14
+ */
15
+
16
+ /**
17
+ * @param {string} root git working tree root
18
+ * @returns {LocalBranch[]}
19
+ */
20
+ export function listLocalBranches(root) {
21
+ try {
22
+ const out = execFileSync(
23
+ "git",
24
+ ["for-each-ref", "--format=%(refname:short) %(objectname:short)", "refs/heads/"],
25
+ { cwd: root, encoding: "utf8", stdio: "pipe" }
26
+ ).trim();
27
+ if (!out) return [];
28
+ return out.split("\n").map((line) => {
29
+ const [name, sha] = line.trim().split(/\s+/);
30
+ return { name: name || "", sha: sha || "" };
31
+ }).filter((b) => b.name);
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {string} owner
39
+ * @param {string} repo
40
+ * @returns {Promise<RemoteBranch[]>}
41
+ */
42
+ export async function listRemoteBranches(owner, repo) {
43
+ const raw = await githubListAllBranches(owner, repo);
44
+ /** @type {RemoteBranch[]} */
45
+ const result = [];
46
+ for (const b of raw) {
47
+ if (!b || typeof b !== "object") continue;
48
+ const br = /** @type {Record<string, unknown>} */ (b);
49
+ const name = typeof br.name === "string" ? br.name : "";
50
+ const commit =
51
+ br.commit && typeof br.commit === "object"
52
+ ? /** @type {Record<string, unknown>} */ (br.commit)
53
+ : {};
54
+ const sha = typeof commit.sha === "string" ? commit.sha.slice(0, 12) : "";
55
+ if (name) result.push({ name, sha });
56
+ }
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Compute ahead/behind counts between two refs using `git rev-list --left-right --count`.
62
+ * Returns { ahead, behind } where ahead = local commits not on remote, behind = remote commits not on local.
63
+ * @param {string} root
64
+ * @param {string} localRef e.g. "refs/heads/feat"
65
+ * @param {string} remoteRef e.g. "origin/feat"
66
+ * @returns {{ ahead: number, behind: number }}
67
+ */
68
+ function countAheadBehind(root, localRef, remoteRef) {
69
+ try {
70
+ const out = execFileSync(
71
+ "git",
72
+ ["rev-list", "--left-right", "--count", `${remoteRef}...${localRef}`],
73
+ { cwd: root, encoding: "utf8", stdio: "pipe" }
74
+ ).trim();
75
+ const parts = out.split(/\s+/);
76
+ const behind = Number.parseInt(parts[0], 10) || 0;
77
+ const ahead = Number.parseInt(parts[1], 10) || 0;
78
+ return { ahead, behind };
79
+ } catch {
80
+ return { ahead: 0, behind: 0 };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Merge local and remote branch lists into a unified model.
86
+ * When a branch exists in both, compute ahead/behind using git (requires local clone).
87
+ *
88
+ * @param {LocalBranch[]} localBranches
89
+ * @param {RemoteBranch[]} remoteBranches
90
+ * @param {string | null} root git root for ahead/behind computation (null = no local clone)
91
+ * @param {string} remoteName git remote name, e.g. "origin"
92
+ * @returns {Promise<BranchRow[]>}
93
+ */
94
+ export async function mergeBranchModel(localBranches, remoteBranches, root, remoteName = "origin") {
95
+ /** @type {Map<string, LocalBranch>} */
96
+ const localMap = new Map(localBranches.map((b) => [b.name, b]));
97
+ /** @type {Map<string, RemoteBranch>} */
98
+ const remoteMap = new Map(remoteBranches.map((b) => [b.name, b]));
99
+
100
+ /** @type {Set<string>} */
101
+ const allNames = new Set([...localMap.keys(), ...remoteMap.keys()]);
102
+
103
+ /** @type {BranchRow[]} */
104
+ const rows = [];
105
+
106
+ for (const name of allNames) {
107
+ const local = localMap.get(name) ?? null;
108
+ const remote = remoteMap.get(name) ?? null;
109
+
110
+ /** @type {BranchStatus} */
111
+ let status = "in_sync";
112
+ let ahead = 0;
113
+ let behind = 0;
114
+
115
+ if (local && remote) {
116
+ if (local.sha === remote.sha.slice(0, local.sha.length) || remote.sha === local.sha.slice(0, remote.sha.length)) {
117
+ status = "in_sync";
118
+ } else if (root) {
119
+ const ab = countAheadBehind(root, `refs/heads/${name}`, `${remoteName}/${name}`);
120
+ ahead = ab.ahead;
121
+ behind = ab.behind;
122
+ if (ahead > 0 && behind > 0) {
123
+ status = "diverged";
124
+ } else if (ahead > 0) {
125
+ status = "local_ahead";
126
+ } else if (behind > 0) {
127
+ status = "remote_ahead";
128
+ } else {
129
+ status = "in_sync";
130
+ }
131
+ }
132
+ } else if (local && !remote) {
133
+ status = "local_only";
134
+ } else if (!local && remote) {
135
+ status = "remote_only";
136
+ }
137
+
138
+ rows.push({ name, local, remote, status, ahead, behind });
139
+ }
140
+
141
+ // Sort: local_ahead first (actionable), then diverged, local_only, in_sync, remote_ahead, remote_only
142
+ const order = { local_ahead: 0, diverged: 1, local_only: 2, in_sync: 3, remote_ahead: 4, remote_only: 5 };
143
+ rows.sort((a, b) => {
144
+ const oa = order[a.status] ?? 99;
145
+ const ob = order[b.status] ?? 99;
146
+ if (oa !== ob) return oa - ob;
147
+ return a.name.localeCompare(b.name);
148
+ });
149
+
150
+ return rows;
151
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Inference-only stack discovery service.
3
+ * Replaces the .nugit/stack.json scanning approach with open-PR chain analysis.
4
+ */
5
+ import { githubListAllOpenPulls } from "../github-rest.js";
6
+ import { inferPrChainsFromOpenPulls } from "../stack-infer-from-prs.js";
7
+ import { inferChainsToPickStacks } from "../stack-view/infer-chains-to-pick-stacks.js";
8
+ import {
9
+ openPullNumbersFromList,
10
+ tagPickStacksMergedState
11
+ } from "../stack-view/merge-alternate-pick-stacks.js";
12
+ import { createInferredStackDoc, parseRepoFullName } from "../nugit-stack.js";
13
+ import { authMe } from "../api-client.js";
14
+
15
+ /**
16
+ * Discover stacks for a repo using open-PR chain inference (no .nugit required).
17
+ * Returns rows in the same shape as StackPickInk expects.
18
+ *
19
+ * @param {string} owner
20
+ * @param {string} repoName
21
+ * @returns {Promise<{ stacks: object[], openPullNumbers: Set<number> }>}
22
+ */
23
+ export async function discoverStacksByInference(owner, repoName) {
24
+ const pulls = await githubListAllOpenPulls(owner, repoName);
25
+ const openNums = openPullNumbersFromList(pulls);
26
+ const repoFull = `${owner}/${repoName}`;
27
+ const chains = inferPrChainsFromOpenPulls(pulls, repoFull);
28
+ const stacks = inferChainsToPickStacks(chains, pulls).map((s) => ({
29
+ ...s,
30
+ inferredOnly: true
31
+ }));
32
+ return {
33
+ stacks: tagPickStacksMergedState(stacks, openNums),
34
+ openPullNumbers: openNums
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Build an inferred stack document from open PRs, picking the best chain or
40
+ * prompting the user. Returns a viewer-ready stack doc.
41
+ *
42
+ * @param {string} repoFull
43
+ * @param {{
44
+ * preselectedChainIndex?: number,
45
+ * tuiChainPick?: (chains: number[][], pulls: unknown[]) => Promise<number>
46
+ * }} [opts]
47
+ * @returns {Promise<{ doc: Record<string, unknown>, viewerLogin: string, pulls: unknown[] }>}
48
+ */
49
+ export async function buildInferredViewerDoc(repoFull, opts = {}) {
50
+ const { owner, repo } = parseRepoFullName(repoFull);
51
+ const pulls = await githubListAllOpenPulls(owner, repo);
52
+ const chains = inferPrChainsFromOpenPulls(pulls, repoFull);
53
+
54
+ if (chains.length === 0) {
55
+ throw new Error(
56
+ `No open PR stacks found in ${repoFull}. ` +
57
+ "Open some PRs in a stacked chain (base of one PR = head of another) and try again."
58
+ );
59
+ }
60
+
61
+ const me = await authMe();
62
+ const viewerLogin = me && typeof me.login === "string" ? me.login : "viewer";
63
+
64
+ let chosen;
65
+ if (chains.length === 1) {
66
+ chosen = chains[0];
67
+ } else if (typeof opts.preselectedChainIndex === "number") {
68
+ const i = opts.preselectedChainIndex;
69
+ if (i < 0 || i >= chains.length) throw new Error("Invalid preselectedChainIndex.");
70
+ chosen = chains[i];
71
+ } else if (typeof opts.tuiChainPick === "function") {
72
+ const idx = await opts.tuiChainPick(chains, pulls);
73
+ if (idx === -2) {
74
+ const { RepoPickerBackError } = await import("../stack-view/repo-picker-back.js");
75
+ throw new RepoPickerBackError();
76
+ }
77
+ if (idx < 0 || idx >= chains.length) {
78
+ throw new Error("Stack selection cancelled.");
79
+ }
80
+ chosen = chains[idx];
81
+ } else {
82
+ chosen = chains.reduce((a, b) => (b.length > a.length ? b : a));
83
+ }
84
+
85
+ return {
86
+ doc: createInferredStackDoc(repoFull, viewerLogin, chosen),
87
+ viewerLogin,
88
+ pulls
89
+ };
90
+ }
@@ -1,15 +1,7 @@
1
1
  import React from "react";
2
2
  import { render } from "ink";
3
- import { authMe, getPull, createPullRequest } from "../api-client.js";
3
+ import { getPull, createPullRequest, authMe } from "../api-client.js";
4
4
  import { githubPostIssueComment } from "../github-pr-social.js";
5
- import {
6
- createInitialStackDoc,
7
- readStackFile,
8
- writeStackFile,
9
- validateStackDoc,
10
- stackEntryFromGithubPull
11
- } from "../nugit-stack.js";
12
- import { appendStackHistory } from "../stack-graph.js";
13
5
  import {
14
6
  assertCleanWorkingTree,
15
7
  gitExec,
@@ -28,6 +20,7 @@ import { SplitInkApp } from "./split-ink.js";
28
20
  * @param {number} ctx.prNumber
29
21
  * @param {boolean} [ctx.dryRun]
30
22
  * @param {string} [ctx.remote]
23
+ * @returns {Promise<{ newPrNumbers: number[], newBranches: string[] } | null>}
31
24
  */
32
25
  export async function runSplitCommand(ctx) {
33
26
  const { root, owner, repo, prNumber, dryRun = false, remote = "origin" } = ctx;
@@ -60,12 +53,8 @@ export async function runSplitCommand(ctx) {
60
53
  const next = exitPayload.next;
61
54
  if (!next || next.type !== "confirm") {
62
55
  console.error("Split cancelled.");
63
- try {
64
- gitExec(root, ["checkout", baseBranch]);
65
- } catch {
66
- /* ignore */
67
- }
68
- return;
56
+ try { gitExec(root, ["checkout", baseBranch]); } catch { /* ignore */ }
57
+ return null;
69
58
  }
70
59
  const { byLayer, layerCount } = next;
71
60
  for (let L = 0; L < layerCount; L++) {
@@ -81,17 +70,10 @@ export async function runSplitCommand(ctx) {
81
70
  for (let i = 0; i < layerCount; i++) {
82
71
  const b = `${prefix}-L${i}`;
83
72
  const did = commitLayerFromPaths(
84
- root,
85
- remote,
86
- b,
87
- startRef,
88
- headRef,
89
- byLayer[i],
73
+ root, remote, b, startRef, headRef, byLayer[i],
90
74
  `nugit split: PR #${prNumber} layer ${i + 1}/${layerCount}`
91
75
  );
92
- if (!did) {
93
- throw new Error(`No commit produced for layer ${i}`);
94
- }
76
+ if (!did) throw new Error(`No commit produced for layer ${i}`);
95
77
  newBranches.push(b);
96
78
  startRef = b;
97
79
  }
@@ -99,7 +81,7 @@ export async function runSplitCommand(ctx) {
99
81
  if (dryRun) {
100
82
  console.error("Dry-run: branches (not pushed):", newBranches.join(", "));
101
83
  gitExec(root, ["checkout", baseBranch]);
102
- return;
84
+ return null;
103
85
  }
104
86
 
105
87
  for (const b of newBranches) {
@@ -110,10 +92,9 @@ export async function runSplitCommand(ctx) {
110
92
  const newPrNumbers = [];
111
93
  let prevBase = baseBranch;
112
94
  for (let i = 0; i < newBranches.length; i++) {
113
- const title =
114
- pull.title != null
115
- ? `[split ${i + 1}/${newBranches.length}] ${pull.title}`
116
- : `Split of #${prNumber} (${i + 1}/${newBranches.length})`;
95
+ const title = pull.title != null
96
+ ? `[split ${i + 1}/${newBranches.length}] ${pull.title}`
97
+ : `Split of #${prNumber} (${i + 1}/${newBranches.length})`;
117
98
  const created = await createPullRequest(owner, repo, {
118
99
  title,
119
100
  head: newBranches[i],
@@ -121,74 +102,18 @@ export async function runSplitCommand(ctx) {
121
102
  body: `Split from #${prNumber} (nugit split layer ${i + 1}).\n\nOriginal: ${pull.html_url || ""}`
122
103
  });
123
104
  const num = /** @type {{ number?: number }} */ (created).number;
124
- if (typeof num !== "number") {
125
- throw new Error("GitHub did not return PR number");
126
- }
105
+ if (typeof num !== "number") throw new Error("GitHub did not return PR number");
127
106
  newPrNumbers.push(num);
128
107
  prevBase = newBranches[i];
129
108
  }
130
109
 
131
- /** @type {Record<string, unknown> | null} */
132
- let docForHistory = null;
133
- let doc = readStackFile(root);
134
- if (doc) {
135
- validateStackDoc(doc);
136
- const idx = doc.prs.findIndex((p) => p.pr_number === prNumber);
137
- if (idx >= 0) {
138
- doc.prs.splice(idx, 1);
139
- const insertAt = idx;
140
- for (let i = 0; i < newPrNumbers.length; i++) {
141
- const p2 = await getPull(owner, repo, newPrNumbers[i]);
142
- doc.prs.splice(insertAt + i, 0, stackEntryFromGithubPull(p2, insertAt + i));
143
- }
144
- for (let j = 0; j < doc.prs.length; j++) {
145
- doc.prs[j].position = j;
146
- }
147
- writeStackFile(root, doc);
148
- docForHistory = doc;
149
- } else {
150
- console.error(
151
- `Warning: PR #${prNumber} not in .nugit/stack.json — local stack file left unchanged.`
152
- );
153
- }
154
- } else {
155
- const me = await authMe();
156
- const login = me && typeof me.login === "string" ? me.login : "unknown";
157
- doc = createInitialStackDoc(`${owner}/${repo}`, login);
158
- doc.prs = [];
159
- for (let i = 0; i < newPrNumbers.length; i++) {
160
- const p2 = await getPull(owner, repo, newPrNumbers[i]);
161
- doc.prs.push(stackEntryFromGithubPull(p2, i));
162
- }
163
- writeStackFile(root, doc);
164
- docForHistory = doc;
165
- console.error(
166
- `Created .nugit/stack.json with ${newPrNumbers.length} PR(s) from this split (repo had no stack file).`
167
- );
168
- }
169
-
170
- appendStackHistory(root, {
171
- action: "split",
172
- repo_full_name: `${owner}/${repo}`,
173
- tip_pr_number: newPrNumbers[newPrNumbers.length - 1],
174
- head_branch: newBranches[newBranches.length - 1],
175
- ...(docForHistory ? { snapshot: docForHistory } : {}),
176
- from_pr: prNumber,
177
- new_prs: newPrNumbers
178
- });
179
-
180
110
  await githubPostIssueComment(
181
- owner,
182
- repo,
183
- prNumber,
111
+ owner, repo, prNumber,
184
112
  `This PR was split into: ${newPrNumbers.map((n) => `#${n}`).join(", ")}. You can close this PR when the new stack is ready.`
185
113
  );
186
114
 
187
- try {
188
- gitExec(root, ["checkout", baseBranch]);
189
- } catch {
190
- /* ignore */
191
- }
115
+ try { gitExec(root, ["checkout", baseBranch]); } catch { /* ignore */ }
192
116
 
193
117
  console.error(`Split complete. New PRs: ${newPrNumbers.join(", ")}`);
118
+ return { newPrNumbers, newBranches };
194
119
  }
@@ -56,6 +56,15 @@ export function inferChainsToPickStacks(chains, pulls) {
56
56
 
57
57
  const hasLineStats = sumAdd > 0 || sumDel > 0;
58
58
 
59
+ // base_ref: the branch that the bottom PR (chain[0]) targets
60
+ const bottomPr = chain[0];
61
+ const bottomPull = bottomPr ? byNum.get(bottomPr) : undefined;
62
+ const bottomBase =
63
+ bottomPull && bottomPull.base && typeof bottomPull.base === "object"
64
+ ? /** @type {Record<string, unknown>} */ (bottomPull.base)
65
+ : {};
66
+ const base_ref = typeof bottomBase.ref === "string" ? bottomBase.ref : "";
67
+
59
68
  return {
60
69
  tip_pr_number: tipPr,
61
70
  tip_head_branch: tipHead,
@@ -64,6 +73,7 @@ export function inferChainsToPickStacks(chains, pulls) {
64
73
  prs: prRows,
65
74
  tip_updated_at,
66
75
  inferChainIndex,
76
+ base_ref,
67
77
  ...(hasLineStats ? { inferDiffAdd: sumAdd, inferDiffDel: sumDel } : {})
68
78
  };
69
79
  });