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
package/src/nugit.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import fs from "fs";
3
2
  import chalk from "chalk";
4
3
  import { Command } from "commander";
5
4
  import {
@@ -7,148 +6,102 @@ import {
7
6
  pollDeviceFlow,
8
7
  pollDeviceFlowUntilComplete,
9
8
  savePat,
10
- listMyPulls,
11
- listOpenPullsInRepo,
12
- fetchRemoteStackJson,
13
- getPull,
14
- authMe,
15
- createPullRequest,
16
- getRepoMetadata
9
+ authMe
17
10
  } from "./api-client.js";
18
- import {
19
- findGitRoot,
20
- readStackFile,
21
- writeStackFile,
22
- createInitialStackDoc,
23
- validateStackDoc,
24
- parseRepoFullName,
25
- nextStackPosition,
26
- stackEntryFromGithubPull,
27
- stackJsonPath,
28
- parseStackAddPrNumbers
29
- } from "./nugit-stack.js";
30
- import { runStackPropagate } from "./stack-propagate.js";
31
- import { registerStackExtraCommands } from "./stack-extra-commands.js";
11
+ import { findGitRoot, parseRepoFullName } from "./nugit-stack.js";
32
12
  import { runNugitViewEntry } from "./stack-view/run-view-entry.js";
33
- import {
34
- printJson,
35
- formatWhoamiHuman,
36
- formatPrSearchHuman,
37
- formatOpenPullsHuman,
38
- formatStackDocHuman,
39
- formatStackEnrichHuman,
40
- formatStacksListHuman,
41
- formatPrCreatedHuman,
42
- formatPatOkHuman
43
- } from "./cli-output.js";
44
13
  import { getRepoFullNameFromGitRoot } from "./git-info.js";
45
- import { discoverStacksInRepo } from "./stack-discover.js";
46
- import { getStackDiscoveryOpts, effectiveMaxOpenPrs } from "./stack-discovery-config.js";
47
14
  import {
48
- tryLoadStackIndex,
49
- writeStackIndex,
50
- compileStackGraph,
51
- readStackHistoryLines
52
- } from "./stack-graph.js";
53
- import { runSplitCommand } from "./split-view/run-split.js";
54
- import { getConfigPath } from "./user-config.js";
15
+ runConfigInit,
16
+ runConfigShow,
17
+ runConfigSet,
18
+ runEnvExport
19
+ } from "./nugit-config.js";
55
20
  import { openInBrowser } from "./open-browser.js";
56
21
  import {
57
22
  writeStoredGithubToken,
58
23
  clearStoredGithubToken,
59
24
  getGithubTokenPath
60
25
  } from "./token-store.js";
61
- import {
62
- runConfigInit,
63
- runConfigShow,
64
- runConfigSet,
65
- runEnvExport,
66
- runStart,
67
- runStartHub
68
- } from "./nugit-start.js";
26
+ import { getConfigPath } from "./user-config.js";
27
+ import { runNugitTui } from "./tui/run-tui.js";
69
28
 
70
29
  const program = new Command();
71
- program.name("nugit").description("Nugit CLI stack state in .nugit/stack.json");
30
+ program.name("nugit").description("Nugit — browse and review stacked GitHub PRs");
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Default: TTY → launch the TUI; non-TTY scripting flags supported
34
+ //
35
+ // nugit → TUI (home screen)
36
+ // nugit --repo owner/repo → TUI starting from that repo's stack viewer
37
+ // nugit --no-tui --repo o/r → print stack summary (non-interactive)
38
+ // nugit --review-hub → TUI review hub
39
+ // nugit --no-tui --review-hub → print sorted repo list
40
+ // ---------------------------------------------------------------------------
72
41
 
73
42
  program
74
- .command("init")
75
- .description(
76
- "Create or reset .nugit/stack.json (empty prs[]); clears any existing stack in that file"
77
- )
78
- .option("--repo <owner/repo>", "Override repository full name")
79
- .option("--user <github-login>", "Override created_by metadata")
43
+ .option("--no-tui", "Non-interactive: print stack summary or repo list to stdout")
44
+ .option("--repo <owner/repo>", "Skip home screen and open this repo directly")
45
+ .option("--ref <branch>", "Branch or SHA for --repo (default: GitHub default branch)")
46
+ .option("--review-hub", "Open review hub instead of home screen")
47
+ .option("--auto-apply", "With --review-hub: auto-approve allowlisted PRs when stale approval + merge-only delta")
80
48
  .action(async (opts) => {
81
- const root = findGitRoot();
82
- if (!root) {
83
- throw new Error("Not inside a git repository");
84
- }
85
- const repoFull = opts.repo || getRepoFullNameFromGitRoot(root);
86
- let user = opts.user;
87
- if (!user) {
88
- const me = await authMe();
89
- user = me.login;
90
- if (!user) {
91
- throw new Error(
92
- "Could not resolve login; pass --user or run `nugit auth login` / set NUGIT_USER_TOKEN"
93
- );
49
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
50
+
51
+ if (opts.reviewHub) {
52
+ if (!opts.tui || !tty) {
53
+ const { runReviewHub } = await import("./review-hub/run-review-hub.js");
54
+ await runReviewHub({ noTui: true, autoApply: !!opts.autoApply });
55
+ return;
94
56
  }
95
- console.error(`Using GitHub login: ${user}`);
57
+ await runNugitTui({ startAt: "review_hub" });
58
+ return;
96
59
  }
97
- let cleared = 0;
98
- const p = stackJsonPath(root);
99
- if (fs.existsSync(p)) {
100
- try {
101
- const prev = JSON.parse(fs.readFileSync(p, "utf8"));
102
- if (prev && Array.isArray(prev.prs)) {
103
- cleared = prev.prs.length;
104
- }
105
- } catch {
106
- /* ignore parse errors */
60
+
61
+ if (opts.repo) {
62
+ const repoFull = String(opts.repo);
63
+ if (!opts.tui || !tty) {
64
+ await runNugitViewEntry(repoFull, opts.ref, { noTui: true });
65
+ return;
107
66
  }
67
+ await runNugitTui({ startRepo: repoFull, startRef: opts.ref });
68
+ return;
108
69
  }
109
- const doc = createInitialStackDoc(repoFull, user);
110
- writeStackFile(root, doc);
111
- console.log(`Wrote ${root}/.nugit/stack.json (${repoFull})`);
112
- if (cleared > 0) {
113
- console.error(`Cleared previous stack (${cleared} PR${cleared === 1 ? "" : "s"}).`);
70
+
71
+ if (tty) {
72
+ await runNugitTui({});
73
+ } else {
74
+ program.outputHelp();
114
75
  }
115
- console.error(
116
- "Add PRs with `nugit stack add --pr N [N...]` (bottom→top), then `nugit stack propagate` (auto-commits this file on the tip if needed)."
117
- );
118
76
  });
119
77
 
78
+ // ---------------------------------------------------------------------------
79
+ // auth
80
+ // ---------------------------------------------------------------------------
81
+
120
82
  const auth = new Command("auth").description("GitHub authentication");
121
83
 
122
84
  auth
123
85
  .command("login")
124
86
  .description(
125
- "OAuth device flow: opens browser (pre-filled code), waits for approval, saves token to ~/.config/nugit/github-token. Uses bundled OAuth App client id unless GITHUB_OAUTH_CLIENT_ID is set. NUGIT_USER_TOKEN still overrides the saved file."
87
+ "OAuth device flow: opens browser (pre-filled code), waits for approval, saves token to ~/.config/nugit/github-token. " +
88
+ "Uses bundled OAuth App client id unless GITHUB_OAUTH_CLIENT_ID is set."
126
89
  )
127
90
  .option("--no-browser", "Do not launch a browser (open the printed URL yourself)", false)
128
- .option(
129
- "--no-wait",
130
- "Only request a device code and print instructions (use nugit auth poll --device-code …)",
131
- false
132
- )
91
+ .option("--no-wait", "Only request a device code and print instructions (use nugit auth poll --device-code …)", false)
133
92
  .option("--json", "With --no-wait: raw device response. Otherwise: { login, token_path } after save", false)
134
93
  .action(async (opts) => {
135
94
  const result = await startDeviceFlow();
136
95
  const deviceCode = result.device_code;
137
- if (!deviceCode || typeof deviceCode !== "string") {
138
- throw new Error("GitHub did not return device_code");
139
- }
96
+ if (!deviceCode || typeof deviceCode !== "string") throw new Error("GitHub did not return device_code");
140
97
  const interval = Number(result.interval) || 5;
141
98
 
142
99
  if (opts.noWait) {
143
100
  if (opts.json) {
144
- printJson(result);
101
+ console.log(JSON.stringify(result, null, 2));
145
102
  } else if (result.verification_uri && result.user_code) {
146
- console.error(
147
- `Open ${result.verification_uri} and enter code ${chalk.bold(String(result.user_code))}`
148
- );
149
- console.error(
150
- `Then: ${chalk.bold(`nugit auth poll --device-code ${deviceCode}`)} (poll every ${interval}s)`
151
- );
103
+ console.error(`Open ${result.verification_uri} and enter code ${chalk.bold(String(result.user_code))}`);
104
+ console.error(`Then: ${chalk.bold(`nugit auth poll --device-code ${deviceCode}`)} (poll every ${interval}s)`);
152
105
  }
153
106
  return;
154
107
  }
@@ -156,9 +109,7 @@ auth
156
109
  const baseUri = String(result.verification_uri || "https://github.com/login/device");
157
110
  const userCode = result.user_code != null ? String(result.user_code) : "";
158
111
  const sep = baseUri.includes("?") ? "&" : "?";
159
- const verifyUrl = userCode
160
- ? `${baseUri}${sep}user_code=${encodeURIComponent(userCode)}`
161
- : baseUri;
112
+ const verifyUrl = userCode ? `${baseUri}${sep}user_code=${encodeURIComponent(userCode)}` : baseUri;
162
113
 
163
114
  if (opts.noBrowser) {
164
115
  console.error(`Open in your browser:\n ${chalk.blue.underline(verifyUrl)}`);
@@ -170,30 +121,18 @@ auth
170
121
  console.error(`Could not open a browser. Open:\n ${chalk.blue.underline(verifyUrl)}`);
171
122
  }
172
123
  }
173
- if (userCode) {
174
- console.error(`If prompted, code: ${chalk.bold(userCode)}`);
175
- }
124
+ if (userCode) console.error(`If prompted, code: ${chalk.bold(userCode)}`);
176
125
  console.error(chalk.dim("\nWaiting for you to authorize on GitHub…\n"));
177
126
 
178
127
  const final = await pollDeviceFlowUntilComplete(deviceCode, interval);
179
- if (!final.access_token) {
180
- throw new Error("No access_token from GitHub");
181
- }
128
+ if (!final.access_token) throw new Error("No access_token from GitHub");
182
129
  const me = await savePat(final.access_token);
183
130
  writeStoredGithubToken(final.access_token);
184
131
  const tokenPath = getGithubTokenPath();
185
132
  if (opts.json) {
186
- printJson({
187
- login: me.login,
188
- token_path: tokenPath,
189
- saved: true
190
- });
133
+ console.log(JSON.stringify({ login: me.login, token_path: tokenPath, saved: true }, null, 2));
191
134
  } else {
192
- console.error(
193
- chalk.green(
194
- `\nSigned in as ${chalk.bold(String(me.login))}. Token saved to ${chalk.cyan(tokenPath)}`
195
- )
196
- );
135
+ console.error(chalk.green(`\nSigned in as ${chalk.bold(String(me.login))}. Token saved to ${chalk.cyan(tokenPath)}`));
197
136
  console.error(
198
137
  chalk.dim(
199
138
  "Future `nugit` runs will use this token automatically. " +
@@ -205,9 +144,7 @@ auth
205
144
 
206
145
  auth
207
146
  .command("poll")
208
- .description(
209
- "Complete GitHub device flow (polls until authorized); or --once for a single poll"
210
- )
147
+ .description("Complete GitHub device flow (polls until authorized); or --once for a single poll")
211
148
  .requiredOption("--device-code <code>", "device_code from nugit auth login")
212
149
  .option("--interval <sec>", "Initial poll interval from login response", "5")
213
150
  .option("--once", "Single poll only (manual retry)", false)
@@ -222,16 +159,9 @@ auth
222
159
  return;
223
160
  }
224
161
  if (result.access_token) {
225
- const me = await savePat(result.access_token);
162
+ await savePat(result.access_token);
226
163
  writeStoredGithubToken(result.access_token);
227
- const t = JSON.stringify(result.access_token);
228
- console.error(
229
- `\nToken saved to ${chalk.cyan(getGithubTokenPath())} (signed in as ${chalk.bold(String(me.login))}).`
230
- );
231
- console.error(
232
- chalk.dim("Optional — use env instead of file:\n") +
233
- ` export NUGIT_USER_TOKEN=${t}\n # or: export STACKPR_USER_TOKEN=${t}`
234
- );
164
+ console.error(`\nToken saved to ${chalk.cyan(getGithubTokenPath())} (signed in as ${chalk.bold(String((await authMe()).login))}).`);
235
165
  }
236
166
  });
237
167
 
@@ -251,13 +181,10 @@ auth
251
181
  .action(async (opts) => {
252
182
  const result = await savePat(opts.token);
253
183
  if (opts.json) {
254
- printJson(result);
184
+ console.log(JSON.stringify(result, null, 2));
255
185
  } else {
256
- console.log(formatPatOkHuman(result));
257
- }
258
- if (result.access_token) {
259
- const t = JSON.stringify(result.access_token);
260
- console.error(`\nexport NUGIT_USER_TOKEN=${t}\n# or: export STACKPR_USER_TOKEN=${t}`);
186
+ const login = result.login || result.me?.login;
187
+ console.log(login ? chalk.green(`Token valid — signed in as ${chalk.bold(String(login))}`) : chalk.green("Token valid"));
261
188
  }
262
189
  });
263
190
 
@@ -268,575 +195,90 @@ auth
268
195
  .action(async (opts) => {
269
196
  const me = await authMe();
270
197
  if (opts.json) {
271
- printJson(me);
198
+ console.log(JSON.stringify(me, null, 2));
272
199
  } else {
273
- console.log(formatWhoamiHuman(/** @type {Record<string, unknown>} */ (me)));
200
+ const login = me && typeof me.login === "string" ? me.login : "(unknown)";
201
+ console.log(login);
274
202
  }
275
203
  });
276
204
 
277
205
  program.addCommand(auth);
278
206
 
207
+ // ---------------------------------------------------------------------------
208
+ // config
209
+ // ---------------------------------------------------------------------------
210
+
279
211
  const config = new Command("config").description(
280
- "Persist monorepo path + .env for `nugit start` or `eval \"$(nugit env)\"`"
212
+ "Persist install root + .env path for `eval \"$(nugit env)\"`"
281
213
  );
282
214
 
283
215
  config
284
216
  .command("init")
285
- .description(
286
- "Write ~/.config/nugit/config.json (defaults: this repo root + <root>/.env)"
287
- )
217
+ .description("Write ~/.config/nugit/config.json (defaults: this repo root + <root>/.env)")
288
218
  .option("--install-root <path>", "Nugit monorepo root (contains scripts/ and cli/)")
289
219
  .option("--env-file <path>", "Dotenv file to load (default: <install-root>/.env)")
290
- .option(
291
- "--working-directory <path>",
292
- "Default cwd when running `nugit start` (optional)"
293
- )
220
+ .option("--working-directory <path>", "Default cwd when running `nugit start` (optional)")
294
221
  .action(async (opts) => {
295
- runConfigInit({
296
- installRoot: opts.installRoot,
297
- envFile: opts.envFile,
298
- workingDirectory: opts.workingDirectory
299
- });
222
+ runConfigInit({ installRoot: opts.installRoot, envFile: opts.envFile, workingDirectory: opts.workingDirectory });
300
223
  });
301
224
 
302
225
  config
303
226
  .command("show")
304
227
  .description("Print saved config JSON")
305
- .action(() => {
306
- runConfigShow();
307
- });
228
+ .action(() => runConfigShow());
308
229
 
309
230
  config
310
231
  .command("path")
311
232
  .description("Print path to config.json")
312
- .action(() => {
313
- console.log(getConfigPath());
314
- });
233
+ .action(() => console.log(getConfigPath()));
315
234
 
316
235
  config
317
236
  .command("set")
318
237
  .description("Set install-root, env-file, or working-directory")
319
238
  .argument("<key>", "install-root | env-file | working-directory")
320
239
  .argument("<value>", "path")
321
- .action((key, value) => {
322
- runConfigSet(key, value);
323
- });
240
+ .action((key, value) => runConfigSet(key, value));
324
241
 
325
242
  program.addCommand(config);
326
243
 
244
+ // ---------------------------------------------------------------------------
245
+ // env
246
+ // ---------------------------------------------------------------------------
247
+
327
248
  program
328
- .command("start")
329
- .description(
330
- "Interactive shell with saved .env + PATH including nugit scripts (needs `nugit config init`)"
331
- )
332
- .option(
333
- "-c, --command <string>",
334
- "Run one command via shell -lc instead of opening an interactive shell"
335
- )
336
- .option(
337
- "--shell",
338
- "Open the configured shell immediately (skip the TTY hub menu: view / split / shell)",
339
- false
340
- )
249
+ .command("env")
250
+ .description("Print export lines from saved config — bash/zsh: eval \"$(nugit env)\"")
251
+ .option("--fish", "Emit fish `set -gx` instead of sh export", false)
341
252
  .action(async (opts) => {
342
- if (opts.command) {
343
- runStart({ command: opts.command });
344
- return;
345
- }
346
- const tty = process.stdin.isTTY && process.stdout.isTTY;
347
- if (opts.shell || !tty) {
348
- runStart({});
349
- return;
350
- }
351
- await runStartHub();
253
+ runEnvExport(opts.fish ? "fish" : "bash");
352
254
  });
353
255
 
256
+ // ---------------------------------------------------------------------------
257
+ // split
258
+ // ---------------------------------------------------------------------------
259
+
354
260
  program
355
261
  .command("split")
356
262
  .description(
357
- "Split one PR into layered branches and new GitHub PRs (TUI assigns files to layers; updates local stack.json when the PR is listed there)"
263
+ "Split one PR into layered branches and new GitHub PRs (TUI assigns files to layers)"
358
264
  )
359
265
  .requiredOption("--pr <n>", "PR number to split")
360
266
  .option("--dry-run", "Materialize local branches only; do not push or create PRs", false)
361
267
  .option("--remote <name>", "Git remote name", "origin")
362
268
  .action(async (opts) => {
363
269
  const root = findGitRoot();
364
- if (!root) {
365
- throw new Error("Not inside a git repository");
366
- }
270
+ if (!root) throw new Error("Not inside a git repository");
367
271
  const repoFull = getRepoFullNameFromGitRoot(root);
368
272
  const { owner, repo: repoName } = parseRepoFullName(repoFull);
369
273
  const n = Number.parseInt(String(opts.pr), 10);
370
- if (!Number.isFinite(n) || n < 1) {
371
- throw new Error("Invalid --pr");
372
- }
373
- await runSplitCommand({
374
- root,
375
- owner,
376
- repo: repoName,
377
- prNumber: n,
378
- dryRun: opts.dryRun,
379
- remote: opts.remote
380
- });
274
+ if (!Number.isFinite(n) || n < 1) throw new Error("Invalid --pr");
275
+ const { runSplitCommand } = await import("./split-view/run-split.js");
276
+ await runSplitCommand({ root, owner, repo: repoName, prNumber: n, dryRun: opts.dryRun, remote: opts.remote });
381
277
  });
382
278
 
383
- program
384
- .command("view")
385
- .description(
386
- "Interactive stack viewer (GitHub). Pass owner/repo and optional ref, or run with no args on a TTY to search/pick a repo (and [c] for this directory’s remote)."
387
- )
388
- .argument("[repo]", "owner/repo")
389
- .argument("[ref]", "branch or sha (default: GitHub default branch)")
390
- .option("--no-tui", "Print stack + comment counts to stdout (no Ink UI)", false)
391
- .option("--repo <owner/repo>", "Same as [repo] (for scripts)")
392
- .option("--ref <branch>", "Same as [ref]")
393
- .option("--file <path>", "Load stack.json from this path")
394
- .action(async (repoArg, refArg, opts) => {
395
- await runNugitViewEntry(repoArg, refArg, opts);
396
- });
397
-
398
- program
399
- .command("review")
400
- .description(
401
- "Review hub: repos you can access (OAuth or PAT), pending review requests first; open inferred PR stacks in the TUI. See docs/stack-view.md."
402
- )
403
- .option("--no-tui", "Print repo list (sorted by pending reviews) instead of Ink picker", false)
404
- .option(
405
- "--auto-apply",
406
- "After loading a stack, auto-approve allowlisted PRs when stale approval + merge-only delta (review-autoapprove.json)",
407
- false
408
- )
409
- .action(async (opts) => {
410
- const { runReviewHub } = await import("./review-hub/run-review-hub.js");
411
- await runReviewHub({ noTui: opts.noTui, autoApply: opts.autoApply });
412
- });
413
-
414
- program
415
- .command("env")
416
- .description(
417
- "Print export lines from saved config — bash/zsh: eval \"$(nugit env)\""
418
- )
419
- .option("--fish", "Emit fish `set -gx` instead of sh export", false)
420
- .action(async (opts) => {
421
- runEnvExport(opts.fish ? "fish" : "bash");
422
- });
423
-
424
- const prs = new Command("prs").description("Pull requests");
425
-
426
- prs
427
- .command("list")
428
- .description(
429
- "List open PRs in the repo (default: origin from cwd), paginated — use numbers with nugit stack add. Use --mine for only your PRs."
430
- )
431
- .option("--repo <owner/repo>", "Repository (default: github.com remote from current git repo)")
432
- .option("--mine", "Only PRs authored by you (GitHub search)", false)
433
- .option("--page <n>", "Page number (1-based)", "1")
434
- .option("--per-page <n>", "Results per page (max 100)", "20")
435
- .option("--json", "Print raw API response", false)
436
- .action(async (opts) => {
437
- const page = Number.parseInt(String(opts.page), 10) || 1;
438
- const perPage = Math.min(100, Math.max(1, Number.parseInt(String(opts.perPage), 10) || 20));
439
- const root = findGitRoot();
440
- const repoFull =
441
- opts.repo || (root ? getRepoFullNameFromGitRoot(root) : null);
442
- if (!repoFull) {
443
- throw new Error("Pass --repo owner/repo or run inside a git clone with a github.com origin");
444
- }
445
- const { owner, repo } = parseRepoFullName(repoFull);
446
-
447
- if (opts.mine) {
448
- const result = await listMyPulls({
449
- repo: repoFull,
450
- page,
451
- perPage
452
- });
453
- if (opts.json) {
454
- printJson(result);
455
- } else {
456
- console.log(
457
- formatPrSearchHuman(/** @type {{ total_count?: number, items?: unknown[] }} */ (result), {
458
- page,
459
- perPage
460
- })
461
- );
462
- }
463
- return;
464
- }
465
-
466
- const result = await listOpenPullsInRepo(owner, repo, { page, perPage });
467
- if (opts.json) {
468
- printJson(result);
469
- } else {
470
- console.log(
471
- formatOpenPullsHuman(
472
- /** @type {{ pulls: unknown[], page: number, per_page: number, repo_full_name: string, has_more: boolean }} */ (
473
- result
474
- )
475
- )
476
- );
477
- }
478
- });
479
-
480
- prs
481
- .command("create")
482
- .description("Open a GitHub PR (push the head branch first). Repo defaults to origin.")
483
- .requiredOption("--head <branch>", "Head branch name (exists on GitHub)")
484
- .requiredOption("--title <title>", "PR title")
485
- .option("--base <branch>", "Base branch (default: repo default_branch from GitHub)")
486
- .option("--body <markdown>", "PR body")
487
- .option("--repo <owner/repo>", "Override; default from git remote origin")
488
- .option("--draft", "Create as draft", false)
489
- .option("--json", "Print full API response", false)
490
- .action(async (opts) => {
491
- const root = findGitRoot();
492
- if (!opts.repo && !root) {
493
- throw new Error("Not in a git repo: pass --repo owner/repo or run from a clone");
494
- }
495
- const repoFull = opts.repo || getRepoFullNameFromGitRoot(root);
496
- const { owner, repo } = parseRepoFullName(repoFull);
497
- let base = opts.base;
498
- if (!base) {
499
- const meta = await getRepoMetadata(owner, repo);
500
- base = meta.default_branch;
501
- if (!base) {
502
- throw new Error("Could not determine default branch; pass --base");
503
- }
504
- console.error(`Using base branch: ${base}`);
505
- }
506
- const created = await createPullRequest(owner, repo, {
507
- title: opts.title,
508
- head: opts.head,
509
- base,
510
- body: opts.body,
511
- draft: opts.draft
512
- });
513
- if (opts.json) {
514
- printJson(created);
515
- } else {
516
- console.log(formatPrCreatedHuman(/** @type {Record<string, unknown>} */ (created)));
517
- }
518
- if (created.number) {
519
- console.error(`\nAdd to stack: nugit stack add --pr ${created.number}`);
520
- }
521
- });
522
-
523
- program.addCommand(prs);
524
-
525
- const stack = new Command("stack").description("Local .nugit/stack.json");
526
-
527
- stack
528
- .command("show")
529
- .description("Print local .nugit/stack.json")
530
- .option("--json", "Raw JSON", false)
531
- .action(async (opts) => {
532
- const root = findGitRoot();
533
- if (!root) {
534
- throw new Error("Not inside a git repository");
535
- }
536
- const doc = readStackFile(root);
537
- if (!doc) {
538
- throw new Error("No .nugit/stack.json in this repo");
539
- }
540
- validateStackDoc(doc);
541
- if (opts.json) {
542
- printJson(doc);
543
- } else {
544
- console.log(formatStackDocHuman(/** @type {Record<string, unknown>} */ (doc)));
545
- }
546
- });
547
-
548
- stack
549
- .command("fetch")
550
- .description("Fetch .nugit/stack.json from GitHub (needs token)")
551
- .option("--repo <owner/repo>", "Default: git remote origin when run inside a repo")
552
- .option("--ref <ref>", "branch or sha", "")
553
- .option("--json", "Raw JSON", false)
554
- .action(async (opts) => {
555
- const root = findGitRoot();
556
- const repoFull =
557
- opts.repo || (root ? getRepoFullNameFromGitRoot(root) : null);
558
- if (!repoFull) {
559
- throw new Error("Pass --repo owner/repo or run from a git clone with github.com origin");
560
- }
561
- const doc = await fetchRemoteStackJson(repoFull, opts.ref || undefined);
562
- validateStackDoc(doc);
563
- if (opts.json) {
564
- printJson(doc);
565
- } else {
566
- console.log(formatStackDocHuman(/** @type {Record<string, unknown>} */ (doc)));
567
- }
568
- });
569
-
570
- stack
571
- .command("enrich")
572
- .description("Print stack with PR titles from GitHub (local file + API)")
573
- .option("--json", "Raw JSON", false)
574
- .action(async (opts) => {
575
- const root = findGitRoot();
576
- if (!root) {
577
- throw new Error("Not inside a git repository");
578
- }
579
- const doc = readStackFile(root);
580
- if (!doc) {
581
- throw new Error("No .nugit/stack.json");
582
- }
583
- validateStackDoc(doc);
584
- const { owner, repo } = parseRepoFullName(doc.repo_full_name);
585
- const ordered = [...doc.prs].sort((a, b) => a.position - b.position);
586
- const out = [];
587
- for (const pr of ordered) {
588
- try {
589
- const g = await getPull(owner, repo, pr.pr_number);
590
- out.push({
591
- ...pr,
592
- title: g.title,
593
- html_url: g.html_url,
594
- state: g.state
595
- });
596
- } catch (e) {
597
- out.push({ ...pr, error: String(e.message || e) });
598
- }
599
- }
600
- if (opts.json) {
601
- printJson({ ...doc, prs: out });
602
- } else {
603
- console.log(
604
- formatStackEnrichHuman(/** @type {Record<string, unknown>} */ (doc), /** @type {Record<string, unknown>[]} */ (out))
605
- );
606
- }
607
- });
608
-
609
- stack
610
- .command("list")
611
- .description(
612
- "Discover nugit stacks in the repo: scan open PR heads for .nugit/stack.json, dedupe by stack tip (for review / triage)"
613
- )
614
- .option("--repo <owner/repo>", "Default: github.com origin from cwd")
615
- .option(
616
- "--max-open-prs <n>",
617
- "Max open PRs to scan (0 = all pages). Default: config / discovery mode"
618
- )
619
- .option(
620
- "--fetch-concurrency <n>",
621
- "Parallel GitHub API calls. Default: config (see stack-discovery-fetch-concurrency)"
622
- )
623
- .option(
624
- "--full",
625
- "Full scan for lazy mode (same as NUGIT_STACK_DISCOVERY_FULL=1)",
626
- false
627
- )
628
- .option("--no-enrich", "Skip loading PR titles from the API (faster)", false)
629
- .option("--json", "Machine-readable result", false)
630
- .action(async (opts) => {
631
- const root = findGitRoot();
632
- const repoFull =
633
- opts.repo || (root ? getRepoFullNameFromGitRoot(root) : null);
634
- if (!repoFull) {
635
- throw new Error(
636
- "Pass --repo owner/repo or run inside a git clone with github.com origin"
637
- );
638
- }
639
- const { owner, repo } = parseRepoFullName(repoFull);
640
- const discovery = getStackDiscoveryOpts();
641
- const maxOpenPrs =
642
- opts.maxOpenPrs != null && String(opts.maxOpenPrs).length
643
- ? Number.parseInt(String(opts.maxOpenPrs), 10)
644
- : effectiveMaxOpenPrs(discovery, opts.full);
645
- const fetchConcurrency =
646
- opts.fetchConcurrency != null && String(opts.fetchConcurrency).length
647
- ? Math.max(1, Math.min(32, Number.parseInt(String(opts.fetchConcurrency), 10) || 8))
648
- : discovery.fetchConcurrency;
649
- const result = await discoverStacksInRepo(owner, repo, {
650
- maxOpenPrs: Number.isNaN(maxOpenPrs) ? discovery.maxOpenPrs : maxOpenPrs,
651
- enrich: !opts.noEnrich,
652
- fetchConcurrency
653
- });
654
- if (opts.json) {
655
- printJson(result);
656
- } else {
657
- console.log(formatStacksListHuman(result));
658
- }
659
- });
660
-
661
- stack
662
- .command("index")
663
- .description("Write .nugit/stack-index.json from GitHub discovery (for lazy/manual modes)")
664
- .option("--repo <owner/repo>", "Default: github.com origin from cwd")
665
- .option("--max-open-prs <n>", "Max open PRs to scan (default: config)")
666
- .option("--no-enrich", "Skip PR title fetch", false)
667
- .action(async (opts) => {
668
- const root = findGitRoot();
669
- if (!root) {
670
- throw new Error("Not inside a git repository");
671
- }
672
- const repoFull =
673
- opts.repo || (root ? getRepoFullNameFromGitRoot(root) : null);
674
- if (!repoFull) {
675
- throw new Error("Pass --repo owner/repo or run from a clone with github.com origin");
676
- }
677
- const { owner, repo } = parseRepoFullName(repoFull);
678
- const discovery = getStackDiscoveryOpts();
679
- const max =
680
- opts.maxOpenPrs != null && String(opts.maxOpenPrs).length
681
- ? Number.parseInt(String(opts.maxOpenPrs), 10)
682
- : discovery.maxOpenPrs;
683
- const result = await discoverStacksInRepo(owner, repo, {
684
- maxOpenPrs: Number.isNaN(max) ? discovery.maxOpenPrs : max,
685
- enrich: !opts.noEnrich,
686
- fetchConcurrency: discovery.fetchConcurrency
687
- });
688
- writeStackIndex(root, result);
689
- console.error(`Wrote ${root}/.nugit/stack-index.json (${result.stacks_found} stack(s))`);
690
- });
691
-
692
- stack
693
- .command("graph")
694
- .description("Print compiled stack graph from stack-index.json + .nugit/stack-history.jsonl")
695
- .option("--live", "Rediscover from GitHub if index missing", false)
696
- .option("--json", "Machine-readable", false)
697
- .action(async (opts) => {
698
- const root = findGitRoot();
699
- if (!root) {
700
- throw new Error("Not inside a git repository");
701
- }
702
- const repoFull = getRepoFullNameFromGitRoot(root);
703
- let discovered = tryLoadStackIndex(root, repoFull);
704
- if (!discovered && opts.live) {
705
- const { owner, repo } = parseRepoFullName(repoFull);
706
- const d = getStackDiscoveryOpts();
707
- discovered = await discoverStacksInRepo(owner, repo, {
708
- maxOpenPrs: d.maxOpenPrs,
709
- enrich: false,
710
- fetchConcurrency: d.fetchConcurrency
711
- });
712
- }
713
- if (!discovered) {
714
- throw new Error("No stack-index.json — run: nugit stack index (or use --live)");
715
- }
716
- const hist = readStackHistoryLines(root);
717
- const graph = compileStackGraph(discovered, hist);
718
- if (opts.json) {
719
- printJson(graph);
720
- } else {
721
- console.log(JSON.stringify(graph, null, 2));
722
- }
723
- });
724
-
725
- stack
726
- .command("add")
727
- .description("Append one or more PRs to the stack (metadata from GitHub), bottom→top order")
728
- .requiredOption(
729
- "--pr <n...>",
730
- "Pull request number(s): stack order bottom first — space- or comma-separated, or repeat --pr"
731
- )
732
- .option("--json", "Print entries as JSON", false)
733
- .action(async (opts) => {
734
- const root = findGitRoot();
735
- if (!root) {
736
- throw new Error("Not inside a git repository");
737
- }
738
- const doc = readStackFile(root);
739
- if (!doc) {
740
- throw new Error("No .nugit/stack.json — run nugit init first");
741
- }
742
- validateStackDoc(doc);
743
- const prNums = parseStackAddPrNumbers(opts.pr);
744
- const { owner, repo } = parseRepoFullName(doc.repo_full_name);
745
- /** @type {ReturnType<typeof stackEntryFromGithubPull>[]} */
746
- const added = [];
747
- for (const prNum of prNums) {
748
- if (doc.prs.some((p) => p.pr_number === prNum)) {
749
- throw new Error(`PR #${prNum} is already in the stack`);
750
- }
751
- const pull = await getPull(owner, repo, prNum);
752
- const position = nextStackPosition(doc.prs);
753
- const entry = stackEntryFromGithubPull(pull, position);
754
- doc.prs.push(entry);
755
- added.push(entry);
756
- }
757
- writeStackFile(root, doc);
758
- if (opts.json) {
759
- printJson(added.length === 1 ? added[0] : added);
760
- } else {
761
- for (const e of added) {
762
- console.log(
763
- chalk.bold(`PR #${e.pr_number}`) +
764
- chalk.dim(` ${e.head_branch} ← ${e.base_branch}`)
765
- );
766
- }
767
- }
768
- console.error(
769
- `\nUpdated stack (${doc.prs.length} PRs). Run \`nugit stack propagate --push\` to commit the stack on the tip if needed, write prefix metadata on each head, merge lower→upper, and push.`
770
- );
771
- });
772
-
773
- function addPropagateOptions(cmd) {
774
- return cmd
775
- .option(
776
- "-m, --message <msg>",
777
- "Commit message for each branch",
778
- "nugit: propagate stack metadata"
779
- )
780
- .option("--push", "Run git push for each head after committing", false)
781
- .option("--dry-run", "Print git actions without changing branches or committing", false)
782
- .option("--remote <name>", "Remote name (default: origin)", "origin")
783
- .option(
784
- "--no-merge-lower",
785
- "Do not merge each lower stacked head into the current head before writing stack.json (can break PR chains; not recommended)",
786
- false
787
- )
788
- .option(
789
- "--no-bootstrap",
790
- "Do not auto-commit a dirty .nugit/stack.json on the tip before propagating (you must commit it yourself)",
791
- false
792
- );
793
- }
794
-
795
- addPropagateOptions(
796
- stack
797
- .command("propagate")
798
- .description(
799
- "Commit .nugit/stack.json on each stacked head: prs prefix through that layer, plus layer (with tip). Auto-commits tip stack file if it is the only dirty path; merges each lower head into the next so PRs stay mergeable."
800
- )
801
- ).action(async (opts) => {
802
- const root = findGitRoot();
803
- if (!root) {
804
- throw new Error("Not inside a git repository");
805
- }
806
- await runStackPropagate({
807
- root,
808
- message: opts.message,
809
- push: opts.push,
810
- dryRun: opts.dryRun,
811
- remote: opts.remote,
812
- noMergeLower: opts.noMergeLower,
813
- bootstrapCommit: !opts.noBootstrap
814
- });
815
- });
816
-
817
- addPropagateOptions(
818
- stack
819
- .command("commit")
820
- .description("Alias for `nugit stack propagate` — prefix stack metadata on each stacked head")
821
- ).action(async (opts) => {
822
- const root = findGitRoot();
823
- if (!root) {
824
- throw new Error("Not inside a git repository");
825
- }
826
- await runStackPropagate({
827
- root,
828
- message: opts.message,
829
- push: opts.push,
830
- dryRun: opts.dryRun,
831
- remote: opts.remote,
832
- noMergeLower: opts.noMergeLower,
833
- bootstrapCommit: !opts.noBootstrap
834
- });
835
- });
836
-
837
- registerStackExtraCommands(stack);
838
-
839
- program.addCommand(stack);
279
+ // ---------------------------------------------------------------------------
280
+ // Parse
281
+ // ---------------------------------------------------------------------------
840
282
 
841
283
  program.parseAsync().catch((error) => {
842
284
  console.error(error.message || error);