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