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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nugit-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI for stacked GitHub PRs (.nugit/stack.json)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api-client.js CHANGED
@@ -159,18 +159,6 @@ export function decodeGithubFileContent(item) {
159
159
  return Buffer.from(item.content.replace(/\s/g, ""), "base64").toString("utf8");
160
160
  }
161
161
 
162
- export async function fetchRemoteStackJson(repoFullName, ref) {
163
- const [owner, repo] = repoFullName.split("/");
164
- if (!owner || !repo) {
165
- throw new Error("repoFullName must be owner/repo");
166
- }
167
- const item = await getGithubContents(owner, repo, ".nugit/stack.json", ref);
168
- const text = decodeGithubFileContent(item);
169
- if (!text) {
170
- throw new Error("Could not read .nugit/stack.json from GitHub");
171
- }
172
- return JSON.parse(text);
173
- }
174
162
 
175
163
  export async function getPull(owner, repo, number) {
176
164
  return githubGetPull(owner, repo, number);
@@ -245,6 +245,41 @@ export async function githubSearchRepositories(q, perPage = 20, page = 1) {
245
245
  return githubRestJson("GET", `/search/repositories?${query.toString()}`);
246
246
  }
247
247
 
248
+ /**
249
+ * List branches in a repository (one page).
250
+ * @param {string} owner
251
+ * @param {string} repo
252
+ * @param {number} [page] 1-based
253
+ * @param {number} [perPage] max 100
254
+ */
255
+ export async function githubListBranches(owner, repo, page = 1, perPage = 100) {
256
+ const pp = Math.min(100, Math.max(1, perPage));
257
+ const p = Math.max(1, page);
258
+ return githubRestJson(
259
+ "GET",
260
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches?page=${p}&per_page=${pp}`
261
+ );
262
+ }
263
+
264
+ /**
265
+ * @param {string} owner
266
+ * @param {string} repo
267
+ * @returns {Promise<unknown[]>}
268
+ */
269
+ export async function githubListAllBranches(owner, repo) {
270
+ /** @type {unknown[]} */
271
+ const all = [];
272
+ let page = 1;
273
+ for (;;) {
274
+ const chunk = await githubListBranches(owner, repo, page, 100);
275
+ if (!Array.isArray(chunk) || chunk.length === 0) break;
276
+ all.push(...chunk);
277
+ if (chunk.length < 100) break;
278
+ page += 1;
279
+ }
280
+ return all;
281
+ }
282
+
248
283
  export async function githubSearchIssues(q, perPage = 30, page = 1) {
249
284
  const pp = Math.min(100, Math.max(1, perPage));
250
285
  const p = Math.max(1, page);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Config-related actions for the `nugit config` and `nugit env` commands.
3
+ * Extracted from the former nugit-start.js.
4
+ */
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import {
8
+ expandUserPath,
9
+ getConfigPath,
10
+ inferMonorepoRootFromCli,
11
+ readUserConfig,
12
+ writeUserConfig,
13
+ buildStartEnv
14
+ } from "./user-config.js";
15
+
16
+ /**
17
+ * @param {object} opts
18
+ * @param {string} [opts.installRoot]
19
+ * @param {string} [opts.envFile]
20
+ * @param {string} [opts.workingDirectory]
21
+ */
22
+ export function runConfigInit(opts) {
23
+ const root = path.resolve(expandUserPath(opts.installRoot || inferMonorepoRootFromCli()));
24
+ const defaultEnv = path.join(root, ".env");
25
+ const envFile = expandUserPath(opts.envFile || defaultEnv);
26
+ const cfg = { installRoot: root, envFile };
27
+ if (opts.workingDirectory) cfg.workingDirectory = expandUserPath(opts.workingDirectory);
28
+ writeUserConfig(cfg);
29
+ console.error(`Wrote ${getConfigPath()}`);
30
+ console.error(` installRoot: ${root}`);
31
+ console.error(` envFile: ${envFile}`);
32
+ if (!fs.existsSync(envFile)) {
33
+ console.error(" (env file does not exist yet — create it or run: nugit config set env-file <path>)");
34
+ }
35
+ if (cfg.workingDirectory) console.error(` workingDirectory: ${cfg.workingDirectory}`);
36
+ }
37
+
38
+ export function runConfigShow() {
39
+ console.log(JSON.stringify(readUserConfig(), null, 2));
40
+ }
41
+
42
+ /**
43
+ * @param {string} key
44
+ * @param {string} value
45
+ */
46
+ export function runConfigSet(key, value) {
47
+ const c = readUserConfig();
48
+ const k = key.toLowerCase().replace(/_/g, "-");
49
+ if (k === "install-root") {
50
+ c.installRoot = path.resolve(expandUserPath(value));
51
+ } else if (k === "env-file") {
52
+ c.envFile = expandUserPath(value);
53
+ } else if (k === "working-directory" || k === "cwd") {
54
+ c.workingDirectory = expandUserPath(value);
55
+ } else {
56
+ throw new Error(`Unknown key "${key}". Use: install-root | env-file | working-directory`);
57
+ }
58
+ writeUserConfig(c);
59
+ console.error(`Updated ${getConfigPath()}`);
60
+ }
61
+
62
+ /**
63
+ * @param {"bash" | "fish"} shell
64
+ */
65
+ export function runEnvExport(shell) {
66
+ const cfg = readUserConfig();
67
+ let env;
68
+ try {
69
+ env = buildStartEnv(cfg);
70
+ } catch (e) {
71
+ console.error(String(e?.message || e));
72
+ process.exit(1);
73
+ }
74
+ const entries = Object.entries(env);
75
+ if (shell === "fish") {
76
+ for (const [k, v] of entries) {
77
+ if (v !== undefined) console.log(`set -gx ${k} ${JSON.stringify(v)};`);
78
+ }
79
+ } else {
80
+ for (const [k, v] of entries) {
81
+ if (v !== undefined) console.log(`export ${k}=${JSON.stringify(v)}`);
82
+ }
83
+ }
84
+ }
@@ -1,8 +1,8 @@
1
- import fs from "fs";
2
- import path from "path";
3
1
  import { execSync } from "child_process";
4
2
 
5
- const STACK_REL = path.join(".nugit", "stack.json");
3
+ /**
4
+ * Core stack document helpers — no file I/O, no .nugit paths.
5
+ */
6
6
 
7
7
  export function findGitRoot(cwd = process.cwd()) {
8
8
  try {
@@ -17,191 +17,12 @@ export function findGitRoot(cwd = process.cwd()) {
17
17
  }
18
18
  }
19
19
 
20
- export function stackJsonPath(root) {
21
- return path.join(root, STACK_REL);
22
- }
23
-
24
- export function readStackFile(root) {
25
- const p = stackJsonPath(root);
26
- if (!fs.existsSync(p)) {
27
- return null;
28
- }
29
- const raw = fs.readFileSync(p, "utf8");
30
- return JSON.parse(raw);
31
- }
32
-
33
- export function validateStackDoc(doc) {
34
- if (!doc || typeof doc !== "object") {
35
- throw new Error("Invalid stack document");
36
- }
37
- if (doc.version !== 1) {
38
- throw new Error("version must be 1");
39
- }
40
- if (!doc.repo_full_name || typeof doc.repo_full_name !== "string") {
41
- throw new Error("repo_full_name is required");
42
- }
43
- if (!doc.created_by || typeof doc.created_by !== "string") {
44
- throw new Error("created_by is required");
45
- }
46
- if (!Array.isArray(doc.prs)) {
47
- throw new Error("prs must be an array");
48
- }
49
- const seenN = new Set();
50
- const seenP = new Set();
51
- for (let i = 0; i < doc.prs.length; i++) {
52
- const pr = doc.prs[i];
53
- if (!pr || typeof pr !== "object") {
54
- throw new Error(`prs[${i}] invalid`);
55
- }
56
- const n = pr.pr_number;
57
- const pos = pr.position;
58
- if (typeof n !== "number" || typeof pos !== "number") {
59
- throw new Error(`prs[${i}]: pr_number and position required`);
60
- }
61
- if (seenN.has(n)) {
62
- throw new Error(`duplicate pr_number ${n}`);
63
- }
64
- if (seenP.has(pos)) {
65
- throw new Error(`duplicate position ${pos}`);
66
- }
67
- seenN.add(n);
68
- seenP.add(pos);
69
- }
70
- validateOptionalLayer(doc);
71
- return true;
72
- }
73
-
74
- /**
75
- * @param {unknown} p
76
- * @param {string} label
77
- * @param {boolean} allowBranch
78
- */
79
- function validateLayerPointer(p, label, allowBranch) {
80
- if (p == null || typeof p !== "object") {
81
- throw new Error(`${label} must be an object`);
82
- }
83
- const o = /** @type {Record<string, unknown>} */ (p);
84
- const t = o.type;
85
- if (t === "branch") {
86
- if (!allowBranch) {
87
- throw new Error(`${label}: type "branch" not allowed here`);
88
- }
89
- if (typeof o.ref !== "string") {
90
- throw new Error(`${label}: branch ref must be a string`);
91
- }
92
- return;
93
- }
94
- if (t === "stack_pr") {
95
- if (typeof o.pr_number !== "number" || !Number.isInteger(o.pr_number) || o.pr_number < 1) {
96
- throw new Error(`${label}: stack_pr pr_number invalid`);
97
- }
98
- if (typeof o.head_branch !== "string") {
99
- throw new Error(`${label}: stack_pr head_branch must be a string`);
100
- }
101
- return;
102
- }
103
- throw new Error(`${label}: type must be "branch" or "stack_pr"`);
104
- }
105
-
106
- /**
107
- * @param {unknown} tip
108
- */
109
- function validateLayerTip(tip) {
110
- if (tip == null || typeof tip !== "object") {
111
- throw new Error("layer.tip must be an object when present");
112
- }
113
- const t = /** @type {Record<string, unknown>} */ (tip);
114
- if (typeof t.pr_number !== "number" || !Number.isInteger(t.pr_number) || t.pr_number < 1) {
115
- throw new Error("layer.tip.pr_number must be a positive integer");
116
- }
117
- if (typeof t.head_branch !== "string") {
118
- throw new Error("layer.tip.head_branch must be a string");
119
- }
120
- }
121
-
122
- /**
123
- * @param {unknown} layer
124
- * @param {number} prsLength
125
- * @param {unknown[]} [prs] entries for prefix / position checks
126
- */
127
- export function validateLayerShape(layer, prsLength, prs) {
128
- if (layer == null || typeof layer !== "object") {
129
- throw new Error("layer must be an object");
130
- }
131
- const L = /** @type {Record<string, unknown>} */ (layer);
132
- if (typeof L.position !== "number" || !Number.isInteger(L.position) || L.position < 0) {
133
- throw new Error("layer.position must be a non-negative integer");
134
- }
135
- if (typeof L.stack_size !== "number" || !Number.isInteger(L.stack_size) || L.stack_size < 1) {
136
- throw new Error("layer.stack_size must be a positive integer");
137
- }
138
- const hasTip = "tip" in L && L.tip !== undefined && L.tip !== null;
139
- if (hasTip) {
140
- validateLayerTip(L.tip);
141
- if (L.stack_size < prsLength) {
142
- throw new Error(`layer.stack_size (${L.stack_size}) must be >= prs.length (${prsLength})`);
143
- }
144
- if (prsLength !== L.position + 1) {
145
- throw new Error(
146
- `with layer.tip, prs must be a bottom-up prefix: prs.length (${prsLength}) === layer.position + 1 (${L.position + 1})`
147
- );
148
- }
149
- if (prs && Array.isArray(prs) && prs.length > 0) {
150
- const sorted = [...prs].sort(
151
- (a, b) =>
152
- (/** @type {{ position?: number }} */ (a).position ?? 0) -
153
- (/** @type {{ position?: number }} */ (b).position ?? 0)
154
- );
155
- const last = sorted[sorted.length - 1];
156
- const lp = last && typeof last === "object" ? /** @type {{ position?: number }} */ (last).position : undefined;
157
- if (lp !== L.position) {
158
- throw new Error("last pr in prs must have position === layer.position");
159
- }
160
- const want = new Set();
161
- for (let i = 0; i <= L.position; i++) {
162
- want.add(i);
163
- }
164
- const have = new Set(sorted.map((p) => (p && typeof p === "object" ? p.position : -1)));
165
- if (want.size !== have.size || [...want].some((x) => !have.has(x))) {
166
- throw new Error("prs positions must be contiguous 0..layer.position");
167
- }
168
- }
169
- } else if (L.stack_size !== prsLength) {
170
- throw new Error(
171
- `without layer.tip, layer.stack_size (${L.stack_size}) must equal prs.length (${prsLength})`
172
- );
173
- }
174
- validateLayerPointer(L.below, "layer.below", true);
175
- if (L.above !== null) {
176
- validateLayerPointer(L.above, "layer.above", false);
177
- }
178
- }
179
-
180
- /**
181
- * @param {Record<string, unknown>} doc
182
- */
183
- export function validateOptionalLayer(doc) {
184
- if (!("layer" in doc) || doc.layer === undefined) {
185
- return;
186
- }
187
- if (!Array.isArray(doc.prs)) {
188
- return;
189
- }
190
- validateLayerShape(doc.layer, doc.prs.length, doc.prs);
191
- const L = /** @type {Record<string, unknown>} */ (doc.layer);
192
- const positions = new Set(
193
- doc.prs.map((p) => (p && typeof p === "object" ? /** @type {{ position?: number }} */ (p).position : undefined))
194
- );
195
- if (!positions.has(L.position)) {
196
- throw new Error("layer.position must match one of prs[].position values");
20
+ export function parseRepoFullName(s) {
21
+ const parts = s.split("/").filter(Boolean);
22
+ if (parts.length !== 2) {
23
+ throw new Error("repo must be owner/repo");
197
24
  }
198
- }
199
-
200
- export function writeStackFile(root, doc) {
201
- validateStackDoc(doc);
202
- const dir = path.join(root, ".nugit");
203
- fs.mkdirSync(dir, { recursive: true });
204
- fs.writeFileSync(stackJsonPath(root), JSON.stringify(doc, null, 2) + "\n");
25
+ return { owner: parts[0], repo: parts[1] };
205
26
  }
206
27
 
207
28
  /**
@@ -224,86 +45,28 @@ export function createInferredStackDoc(repoFullName, createdBy, prNumbersBottomT
224
45
  };
225
46
  }
226
47
 
227
- export function createInitialStackDoc(repoFullName, createdBy) {
228
- return {
229
- version: 1,
230
- repo_full_name: repoFullName,
231
- created_by: createdBy,
232
- prs: [],
233
- resolution_contexts: []
234
- };
235
- }
236
-
237
- export function parseRepoFullName(s) {
238
- const parts = s.split("/").filter(Boolean);
239
- if (parts.length !== 2) {
240
- throw new Error("repo must be owner/repo");
241
- }
242
- return { owner: parts[0], repo: parts[1] };
243
- }
244
-
245
- /** Next position after current max, or 0 if empty. */
246
- export function nextStackPosition(prs) {
247
- if (!prs || !prs.length) {
248
- return 0;
249
- }
250
- return Math.max(...prs.map((p) => p.position)) + 1;
251
- }
252
-
253
- /**
254
- * Normalize `nugit stack add --pr` values (variadic and/or comma-separated).
255
- * @param {string | string[] | undefined} optsPr
256
- * @returns {number[]}
257
- */
258
- export function parseStackAddPrNumbers(optsPr) {
259
- const raw = optsPr == null ? [] : Array.isArray(optsPr) ? optsPr : [optsPr];
260
- const tokens = [];
261
- for (const r of raw) {
262
- for (const part of String(r).split(/[\s,]+/)) {
263
- const t = part.trim();
264
- if (t) {
265
- tokens.push(t);
266
- }
267
- }
268
- }
269
- if (tokens.length === 0) {
270
- throw new Error("Pass at least one PR number: --pr <n> [n...]");
271
- }
272
- const nums = tokens.map((t) => Number.parseInt(t, 10));
273
- for (let i = 0; i < nums.length; i++) {
274
- if (!Number.isFinite(nums[i]) || nums[i] < 1) {
275
- throw new Error(`Invalid PR number: ${tokens[i]}`);
276
- }
277
- }
278
- const seen = new Set();
279
- for (const n of nums) {
280
- if (seen.has(n)) {
281
- throw new Error(`Duplicate PR #${n} in --pr list`);
282
- }
283
- seen.add(n);
284
- }
285
- return nums;
286
- }
287
-
288
48
  /**
289
- * Build a stack PR entry from GitHub GET /pulls/{n} JSON.
290
- * @param {Record<string, unknown>} pull
291
- * @param {number} position
49
+ * Basic validation for a stack document (viewer-side; not strict .nugit format).
50
+ * @param {unknown} doc
292
51
  */
293
- export function stackEntryFromGithubPull(pull, position) {
294
- const head = pull.head && typeof pull.head === "object" ? pull.head : {};
295
- const base = pull.base && typeof pull.base === "object" ? pull.base : {};
296
- let status = "open";
297
- if (pull.state === "closed") {
298
- status = pull.merged ? "merged" : "closed";
52
+ export function validateStackDoc(doc) {
53
+ if (!doc || typeof doc !== "object") throw new Error("Invalid stack document");
54
+ if (doc.version !== 1) throw new Error("version must be 1");
55
+ if (!doc.repo_full_name || typeof doc.repo_full_name !== "string") throw new Error("repo_full_name is required");
56
+ if (!doc.created_by || typeof doc.created_by !== "string") throw new Error("created_by is required");
57
+ if (!Array.isArray(doc.prs)) throw new Error("prs must be an array");
58
+ const seenN = new Set();
59
+ const seenP = new Set();
60
+ for (let i = 0; i < doc.prs.length; i++) {
61
+ const pr = doc.prs[i];
62
+ if (!pr || typeof pr !== "object") throw new Error(`prs[${i}] invalid`);
63
+ const n = pr.pr_number;
64
+ const pos = pr.position;
65
+ if (typeof n !== "number" || typeof pos !== "number") throw new Error(`prs[${i}]: pr_number and position required`);
66
+ if (seenN.has(n)) throw new Error(`duplicate pr_number ${n}`);
67
+ if (seenP.has(pos)) throw new Error(`duplicate position ${pos}`);
68
+ seenN.add(n);
69
+ seenP.add(pos);
299
70
  }
300
- return {
301
- pr_number: pull.number,
302
- position,
303
- head_branch: head.ref || "",
304
- base_branch: base.ref || "",
305
- head_sha: head.sha || "",
306
- base_sha: base.sha || "",
307
- status
308
- };
71
+ return true;
309
72
  }