git-stack-cli 2.9.2 → 2.9.4

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.
@@ -3,6 +3,7 @@
3
3
  import { Store } from "~/app/Store";
4
4
  import * as git from "~/core/git";
5
5
  import * as github from "~/core/github";
6
+ import { invariant } from "~/core/invariant";
6
7
 
7
8
  export type CommitRange = Awaited<ReturnType<typeof range>>;
8
9
 
@@ -28,16 +29,16 @@ type CommitRangeGroup = {
28
29
  type CommitGroupMap = { [sha: string]: CommitRangeGroup };
29
30
 
30
31
  export async function range(commit_group_map?: CommitGroupMap) {
31
- const DEBUG = process.env.DEV && false;
32
-
33
- // gather all open prs in repo first
34
- // cheaper query to populate cache
35
- await github.pr_list();
36
-
37
- const master_branch = Store.getState().master_branch;
32
+ const state = Store.getState();
33
+ const actions = state.actions;
34
+ const argv = state.argv;
35
+ const merge_base = state.merge_base;
36
+ const master_branch = state.master_branch;
38
37
  const master_branch_name = master_branch.replace(/^origin\//, "");
39
38
  const commit_list = await git.get_commits(`${master_branch}..HEAD`);
40
39
 
40
+ invariant(merge_base, "merge_base must exist");
41
+
41
42
  const pr_lookup: Record<string, void | PullRequest> = {};
42
43
 
43
44
  let invalid = false;
@@ -127,6 +128,15 @@ export async function range(commit_group_map?: CommitGroupMap) {
127
128
  const group = group_value_list[i];
128
129
  const previous_group: undefined | CommitGroup = group_value_list[i - 1];
129
130
 
131
+ // actions.json({ group });
132
+ actions.debug(`title=${group.title}`);
133
+ actions.debug(` id=${group.id}`);
134
+ actions.debug(` master_base=${group.master_base}`);
135
+
136
+ // special case
137
+ // boundary between normal commits and master commits
138
+ const MASTER_BASE_BOUNDARY = !group.master_base && previous_group && previous_group.master_base;
139
+
130
140
  if (group.id !== UNASSIGNED) {
131
141
  let pr_result = pr_lookup[group.id];
132
142
 
@@ -147,9 +157,8 @@ export async function range(commit_group_map?: CommitGroupMap) {
147
157
  if (i === 0) {
148
158
  group.base = master_branch_name;
149
159
  } else {
150
- const last_group = group_value_list[i - 1];
151
- // console.debug(" ", "last_group", last_group.pr?.title.substring(0, 40));
152
- // console.debug(" ", "last_group.id", last_group.id);
160
+ // console.debug(" ", "previous_group", previous_group.pr?.title.substring(0, 40));
161
+ // console.debug(" ", "previous_group.id", previous_group.id);
153
162
 
154
163
  if (group.master_base) {
155
164
  // explicitly set base to master when master_base is true
@@ -157,77 +166,93 @@ export async function range(commit_group_map?: CommitGroupMap) {
157
166
  } else if (group.id === UNASSIGNED) {
158
167
  // null out base when unassigned and after unassigned
159
168
  group.base = null;
160
- } else if (last_group.base === null) {
169
+ } else if (MASTER_BASE_BOUNDARY) {
170
+ // ensure we set its base to `master`
171
+ actions.debug(` MASTER_BASE_BOUNDARY set group.base = ${master_branch_name}`);
172
+ group.base = master_branch_name;
173
+ } else if (previous_group.base === null) {
161
174
  // null out base when last group base is null
162
175
  group.base = null;
163
176
  } else {
164
- group.base = last_group.id;
177
+ group.base = previous_group.id;
165
178
  }
166
-
167
- // console.debug(" ", "group.base", group.base);
168
179
  }
169
180
 
170
- if (DEBUG) {
171
- console.debug({ group });
172
- }
181
+ actions.debug(` base=${group.base}`);
173
182
 
174
183
  if (!group.pr) {
184
+ actions.debug(` group.pr=${group.pr}`);
175
185
  group.dirty = true;
176
186
  } else {
177
- if (group.pr.baseRefName !== group.base) {
178
- // console.debug("PR_BASEREF_MISMATCH");
187
+ // actions.json(group.pr);
188
+ actions.debug(` group.pr.state=${group.pr.state}`);
189
+ actions.debug(` group.pr.baseRefName=${group.pr.baseRefName}`);
190
+
191
+ if (group.pr.state === "MERGED" || group.pr.state === "CLOSED") {
192
+ group.dirty = true;
193
+ } else if (group.pr.baseRefName !== group.base) {
194
+ actions.debug(" PR_BASEREF_MISMATCH");
179
195
  group.dirty = true;
180
196
  } else if (group.master_base) {
181
- // console.debug("MASTER_BASE_DIFF_COMPARE");
182
-
183
- // special case
184
- // master_base groups cannot be compared by commit sha
185
- // instead compare the literal diff local against origin
186
- // gh pr diff --color=never 110
187
- // git --no-pager diff --color=never 00c8fe0~1..00c8fe0
188
- let diff_github = await github.pr_diff(group.pr.number);
189
- diff_github = normalize_diff(diff_github);
190
-
191
- let diff_local = await git.get_diff(group.commits);
192
- diff_local = normalize_diff(diff_local);
193
-
194
- if (DEBUG) {
195
- console.debug({ diff_local, diff_github });
197
+ // first check if merge base has changed
198
+ let branch_compare = await github.pr_compare(group.pr.headRefName);
199
+ if (!(branch_compare instanceof Error)) {
200
+ if (branch_compare.merge_base_commit.sha !== merge_base) {
201
+ actions.debug(" MASTER_BASE_MERGE_BASE_MISMATCH");
202
+ group.dirty = true;
203
+ }
196
204
  }
197
205
 
198
- // find the first differing character index
199
- let compare_length = Math.max(diff_github.length, diff_local.length);
200
- let diff_index = -1;
201
- for (let c_i = 0; c_i < compare_length; c_i++) {
202
- if (diff_github[c_i] !== diff_local[c_i]) {
203
- diff_index = c_i;
204
- break;
206
+ // if still not dirty, check diffs
207
+ if (!group.dirty) {
208
+ actions.debug(" MASTER_BASE_DIFF_COMPARE");
209
+
210
+ // special case
211
+ // master_base groups cannot be compared by commit sha
212
+ // instead compare the literal diff local against origin
213
+ // gh pr diff --color=never 110
214
+ // git --no-pager diff --color=never 00c8fe0~1..00c8fe0
215
+ let diff_github = await github.pr_diff(group.pr.headRefName);
216
+ diff_github = normalize_diff(diff_github);
217
+
218
+ let diff_local = await git.get_diff(group.commits);
219
+ diff_local = normalize_diff(diff_local);
220
+
221
+ // find the first differing character index
222
+ let compare_length = Math.max(diff_github.length, diff_local.length);
223
+ let diff_index = -1;
224
+ for (let c_i = 0; c_i < compare_length; c_i++) {
225
+ if (diff_github[c_i] !== diff_local[c_i]) {
226
+ diff_index = c_i;
227
+ break;
228
+ }
205
229
  }
206
- }
207
- if (diff_index > -1) {
208
- group.dirty = true;
230
+ if (diff_index > -1) {
231
+ actions.debug(" MASTER_BASE_DIFF_MISMATCH");
232
+ group.dirty = true;
209
233
 
210
- if (DEBUG) {
211
- // print preview at diff_index for both strings
212
- const preview_radius = 30;
213
- const start_index = Math.max(0, diff_index - preview_radius);
214
- const end_index = Math.min(compare_length, diff_index + preview_radius);
234
+ if (argv.verbose) {
235
+ // print preview at diff_index for both strings
236
+ const preview_radius = 30;
237
+ const start_index = Math.max(0, diff_index - preview_radius);
238
+ const end_index = Math.min(compare_length, diff_index + preview_radius);
215
239
 
216
- diff_github = diff_github.substring(start_index, end_index);
217
- diff_github = JSON.stringify(diff_github).slice(1, -1);
240
+ diff_github = diff_github.substring(start_index, end_index);
241
+ diff_github = JSON.stringify(diff_github).slice(1, -1);
218
242
 
219
- diff_local = diff_local.substring(start_index, end_index);
220
- diff_local = JSON.stringify(diff_local).slice(1, -1);
243
+ diff_local = diff_local.substring(start_index, end_index);
244
+ diff_local = JSON.stringify(diff_local).slice(1, -1);
221
245
 
222
- let pointer_indent = " ".repeat(diff_index - start_index + 1);
223
- console.warn(`⚠️ git diff mismatch`);
224
- console.warn(` ${pointer_indent}⌄`);
225
- console.warn(`diff_github …${diff_github}…`);
226
- console.warn(`diff_local …${diff_local}…`);
227
- console.warn(` ${pointer_indent}⌃`);
246
+ let pointer_indent = " ".repeat(diff_index - start_index + 1);
247
+ actions.debug(` ⚠️ git diff mismatch`);
248
+ actions.debug(` ${pointer_indent}⌄`);
249
+ actions.debug(` diff_github …${diff_github}…`);
250
+ actions.debug(` diff_local …${diff_local}…`);
251
+ actions.debug(` ${pointer_indent}⌃`);
252
+ }
228
253
  }
229
254
  }
230
- } else if (!group.master_base && previous_group && previous_group.master_base) {
255
+ } else if (MASTER_BASE_BOUNDARY) {
231
256
  // special case
232
257
  // boundary between normal commits and master commits
233
258
 
@@ -245,24 +270,23 @@ export async function range(commit_group_map?: CommitGroupMap) {
245
270
 
246
271
  // compare all commits against pr commits
247
272
  if (group.pr.commits.length !== all_commits.length) {
248
- // console.debug("BOUNDARY_COMMIT_LENGTH_MISMATCH");
273
+ actions.debug(" BOUNDARY_COMMIT_LENGTH_MISMATCH");
249
274
  group.dirty = true;
250
275
  } else {
251
- // console.debug("BOUNDARY_COMMIT_SHA_COMPARISON");
252
276
  for (let i = 0; i < group.pr.commits.length; i++) {
253
277
  const pr_commit = group.pr.commits[i];
254
278
  const local_commit = all_commits[i];
255
279
 
256
280
  if (pr_commit.oid !== local_commit.sha) {
281
+ actions.debug(" BOUNDARY_COMMIT_SHA_MISMATCH");
257
282
  group.dirty = true;
258
283
  }
259
284
  }
260
285
  }
261
286
  } else if (group.pr.commits.length !== group.commits.length) {
262
- // console.debug("COMMIT_LENGTH_MISMATCH");
287
+ actions.debug(" COMMIT_LENGTH_MISMATCH");
263
288
  group.dirty = true;
264
289
  } else {
265
- // console.debug("COMMIT_SHA_COMPARISON");
266
290
  // if we still haven't marked this dirty, check each commit
267
291
  // comapre literal commit shas in group
268
292
  for (let i = 0; i < group.pr.commits.length; i++) {
@@ -270,13 +294,14 @@ export async function range(commit_group_map?: CommitGroupMap) {
270
294
  const local_commit = group.commits[i];
271
295
 
272
296
  if (pr_commit.oid !== local_commit.sha) {
297
+ actions.debug(" COMMIT_SHA_MISMATCH");
273
298
  group.dirty = true;
274
299
  }
275
300
  }
276
301
  }
277
302
  }
278
303
 
279
- // console.debug(" ", "group.dirty", group.dirty);
304
+ actions.debug(` group.dirty=${group.dirty}`);
280
305
  }
281
306
 
282
307
  // reverse group_list to match git log
package/src/core/cli.ts CHANGED
@@ -8,6 +8,7 @@ type SpawnOptions = Parameters<typeof child.spawn>[2];
8
8
  type Options = SpawnOptions & {
9
9
  ignoreExitCode?: boolean;
10
10
  onOutput?: (data: string) => void;
11
+ quiet?: boolean;
11
12
  };
12
13
 
13
14
  type Return = {
@@ -45,13 +46,15 @@ export async function cli(
45
46
 
46
47
  const id = `${++i}-${command}`;
47
48
  state.actions.debug(log.start(command));
48
- state.actions.debug(log.pending(command), id);
49
+ state.actions.debug_pending(id, log.pending(command));
49
50
 
50
51
  const timer = Timer();
51
52
 
52
53
  function write_output(value: string) {
53
54
  output += value;
54
- state.actions.debug(value, id);
55
+ if (!options.quiet) {
56
+ state.actions.debug_pending(id, value);
57
+ }
55
58
  options.onOutput?.(value);
56
59
  }
57
60
 
@@ -79,9 +82,11 @@ export async function cli(
79
82
  duration,
80
83
  };
81
84
 
82
- state.actions.set((state) => state.mutate.end_pending_output(state, id));
85
+ state.actions.debug_pending_end(id);
83
86
  state.actions.debug(log.end(result));
84
- state.actions.debug(log.output(result));
87
+ if (!options.quiet) {
88
+ state.actions.debug(log.output(result));
89
+ }
85
90
 
86
91
  if (!options.ignoreExitCode && result.code !== 0) {
87
92
  state.actions.debug(log.non_zero_exit(result));
@@ -133,7 +138,9 @@ cli.sync = function cli_sync(
133
138
  };
134
139
 
135
140
  state.actions.debug(log.end(result));
136
- state.actions.debug(log.output(result));
141
+ if (!options.quiet) {
142
+ state.actions.debug(log.output(result));
143
+ }
137
144
 
138
145
  if (!options.ignoreExitCode && result.code !== 0) {
139
146
  state.actions.debug(log.non_zero_exit(result));
package/src/core/git.ts CHANGED
@@ -61,7 +61,7 @@ export async function get_diff(commit_list: CommitList) {
61
61
  const first_commit = commit_list[0];
62
62
  const last_commit = commit_list[commit_list.length - 1];
63
63
  const sha_range = `${first_commit.sha}~1..${last_commit.sha}`;
64
- const diff_result = await cli(`git --no-pager diff --color=never ${sha_range}`);
64
+ const diff_result = await cli(`git --no-pager diff --color=never ${sha_range}`, { quiet: true });
65
65
  return diff_result.stdout;
66
66
  }
67
67
 
@@ -40,7 +40,6 @@ export async function pr_list(): Promise<Array<PullRequest>> {
40
40
  if (actions.isDebug()) {
41
41
  actions.output(
42
42
  <FormatText
43
- wrapper={<Ink.Text dimColor />}
44
43
  message="Github cache {count} open PRs from {repo_path} authored by {username}"
45
44
  values={{
46
45
  count: (
@@ -101,7 +100,8 @@ export async function pr_status(branch: string): Promise<null | PullRequest> {
101
100
  );
102
101
  }
103
102
 
104
- const pr = await gh_json<PullRequest>(`pr view ${branch} --repo ${repo_path} ${JSON_FIELDS}`);
103
+ const commmand = `pr view ${branch} --repo ${repo_path} ${JSON_FIELDS}`;
104
+ const pr = await gh_json<PullRequest>(commmand, { branch });
105
105
 
106
106
  if (pr instanceof Error) {
107
107
  return null;
@@ -241,48 +241,37 @@ export async function pr_draft(args: DraftPullRequestArgs) {
241
241
  }
242
242
  }
243
243
 
244
- export async function pr_diff(number: number) {
245
- const state = Store.getState();
246
- const actions = state.actions;
244
+ export async function pr_diff(branch: string) {
245
+ // https://cli.github.com/manual/gh_pr_diff
246
+ const result = await gh(`pr diff --color=never ${branch}`, { branch });
247
247
 
248
- const maybe_diff = state.cache_pr_diff[number];
248
+ if (result instanceof Error) {
249
+ handle_error(result.message);
250
+ }
249
251
 
250
- if (maybe_diff) {
251
- if (actions.isDebug()) {
252
- actions.debug(
253
- cache_message({
254
- hit: true,
255
- message: "Github pr_diff cache",
256
- extra: number,
257
- }),
258
- );
259
- }
252
+ return result;
253
+ }
260
254
 
261
- return maybe_diff;
262
- }
255
+ export async function pr_compare(branch: string) {
256
+ const state = Store.getState();
257
+ const master_branch = state.master_branch;
258
+ const repo_path = state.repo_path;
259
+ invariant(master_branch, "master_branch must exist");
260
+ invariant(repo_path, "repo_path must exist");
263
261
 
264
- if (actions.isDebug()) {
265
- actions.debug(
266
- cache_message({
267
- hit: false,
268
- message: "Github pr_diff cache",
269
- extra: number,
270
- }),
271
- );
272
- }
262
+ const master_branch_name = master_branch.replace(/^origin\//, "");
273
263
 
274
- // https://cli.github.com/manual/gh_pr_diff
275
- const cli_result = await cli(`gh pr diff --color=never ${number}`);
264
+ // gh api repos/openai/openai/compare/master...chrome/publish/vine-1211---4h2xmw0o3ndvnt
265
+ const result = await gh_json<BranchCompare>(
266
+ `api repos/${repo_path}/compare/${master_branch_name}...${branch}`,
267
+ { branch },
268
+ );
276
269
 
277
- if (cli_result.code !== 0) {
278
- handle_error(cli_result.output);
270
+ if (result instanceof Error) {
271
+ handle_error(result.message);
279
272
  }
280
273
 
281
- actions.set((state) => {
282
- state.cache_pr_diff[number] = cli_result.output;
283
- });
284
-
285
- return cli_result.stdout;
274
+ return result;
286
275
  }
287
276
 
288
277
  // pull request JSON fields
@@ -290,29 +279,99 @@ export async function pr_diff(number: number) {
290
279
  // prettier-ignore
291
280
  const JSON_FIELDS = "--json id,number,state,baseRefName,headRefName,commits,title,body,url,isDraft";
292
281
 
282
+ type GhCmdOptions = {
283
+ branch?: string;
284
+ };
285
+
293
286
  // consistent handle gh cli commands returning json
294
287
  // redirect to tmp file to avoid scrollback overflow causing scrollback to be cleared
295
- async function gh_json<T>(command: string): Promise<T | Error> {
288
+ async function gh_json<T>(command: string, gh_options?: GhCmdOptions): Promise<T | Error> {
289
+ const gh_result = await gh(command, gh_options);
290
+
291
+ if (gh_result instanceof Error) {
292
+ return gh_result;
293
+ }
294
+
295
+ try {
296
+ const json = JSON.parse(gh_result);
297
+ return json as T;
298
+ } catch (error) {
299
+ return new Error(`gh_json JSON.parse: ${error}`);
300
+ }
301
+ }
302
+
303
+ // consistent handle gh cli commands
304
+ // redirect to tmp file to avoid scrollback overflow causing scrollback to be cleared
305
+ async function gh(command: string, gh_options?: GhCmdOptions): Promise<string | Error> {
306
+ const state = Store.getState();
307
+ const actions = state.actions;
308
+
309
+ if (gh_options?.branch) {
310
+ const branch = gh_options.branch;
311
+
312
+ type CacheEntryByHeadRefName = (typeof state.cache_gh_cli_by_branch)[string][string];
313
+
314
+ let cache: undefined | CacheEntryByHeadRefName = undefined;
315
+
316
+ if (branch) {
317
+ if (state.cache_gh_cli_by_branch[branch]) {
318
+ cache = state.cache_gh_cli_by_branch[branch][command];
319
+ }
320
+ }
321
+
322
+ if (cache) {
323
+ if (actions.isDebug()) {
324
+ actions.debug(
325
+ cache_message({
326
+ hit: true,
327
+ message: "gh cache",
328
+ extra: command,
329
+ }),
330
+ );
331
+ }
332
+
333
+ return cache;
334
+ }
335
+
336
+ if (actions.isDebug()) {
337
+ actions.debug(
338
+ cache_message({
339
+ hit: false,
340
+ message: "gh cache",
341
+ extra: command,
342
+ }),
343
+ );
344
+ }
345
+ }
346
+
296
347
  // hash command for unique short string
297
348
  let hash = crypto.createHash("md5").update(command).digest("hex");
298
- let tmp_filename = safe_filename(`gh_json-${hash}`);
299
- const tmp_pr_json = path.join(await get_tmp_dir(), `${tmp_filename}.json`);
349
+ let tmp_filename = safe_filename(`gh-${hash}`);
350
+ const tmp_filepath = path.join(await get_tmp_dir(), `${tmp_filename}`);
300
351
 
301
352
  const options = { ignoreExitCode: true };
302
- const cli_result = await cli(`gh ${command} > ${tmp_pr_json}`, options);
353
+ const cli_result = await cli(`gh ${command} > ${tmp_filepath}`, options);
303
354
 
304
355
  if (cli_result.code !== 0) {
305
356
  return new Error(cli_result.output);
306
357
  }
307
358
 
308
359
  // read from file
309
- const json_str = String(await fs.readFile(tmp_pr_json));
310
- try {
311
- const json = JSON.parse(json_str);
312
- return json;
313
- } catch (error) {
314
- return new Error(`gh_json JSON.parse: ${error}`);
360
+ let content = String(await fs.readFile(tmp_filepath));
361
+ content = content.trim();
362
+
363
+ if (gh_options?.branch) {
364
+ const branch = gh_options.branch;
365
+
366
+ actions.set((state) => {
367
+ if (!state.cache_gh_cli_by_branch[branch]) {
368
+ state.cache_gh_cli_by_branch[branch] = {};
369
+ }
370
+ state.cache_gh_cli_by_branch[branch][command] = content;
371
+ });
315
372
  }
373
+
374
+ return content;
316
375
  }
317
376
 
318
377
  function handle_error(output: string): never {
@@ -408,6 +467,36 @@ export type PullRequest = {
408
467
  isDraft: boolean;
409
468
  };
410
469
 
470
+ type MergeBaseCommit = {
471
+ author: unknown;
472
+ comments_url: string;
473
+ commit: unknown;
474
+ committer: unknown;
475
+ html_url: string;
476
+ node_id: string;
477
+ parents: unknown;
478
+ sha: string;
479
+ url: string;
480
+ };
481
+
482
+ export type BranchCompare = {
483
+ ahead_by: number;
484
+ base_commit: unknown;
485
+ behind_by: number;
486
+ commits: unknown;
487
+ diff_url: string;
488
+
489
+ files: unknown;
490
+ html_url: string;
491
+
492
+ merge_base_commit: MergeBaseCommit;
493
+ patch_url: string;
494
+ permalink_url: string;
495
+ status: unknown;
496
+ total_commits: number;
497
+ url: string;
498
+ };
499
+
411
500
  const RE = {
412
501
  non_alphanumeric_dash: /[^a-zA-Z0-9_-]+/g,
413
502
  };
package/src/index.tsx CHANGED
@@ -5,7 +5,6 @@
5
5
  import * as React from "react";
6
6
 
7
7
  import fs from "node:fs/promises";
8
- import path from "node:path";
9
8
 
10
9
  import * as Ink from "ink-cjs";
11
10
 
@@ -61,10 +60,6 @@ import { pretty_json } from "~/core/pretty_json";
61
60
 
62
61
  actions.debug(pretty_json(argv as any));
63
62
 
64
- const PATH = process.env["PATH"];
65
- const PATH_LIST = pretty_json(PATH.split(path.delimiter));
66
- actions.debug(`process.env.PATH ${PATH_LIST}`);
67
-
68
63
  await ink.waitUntilExit();
69
64
 
70
65
  function maybe_verbose_help() {
@@ -1,6 +1,7 @@
1
1
  declare namespace NodeJS {
2
2
  interface ProcessEnv {
3
3
  PATH: string;
4
+ HOME: string;
4
5
  DEV?: "true" | "false";
5
6
  CLI_VERSION?: string;
6
7
  GIT_SEQUENCE_EDITOR_SCRIPT?: string;
@@ -1,8 +0,0 @@
1
- import * as React from "react";
2
-
3
- import * as Ink from "ink-cjs";
4
- import { DateTime } from "luxon";
5
-
6
- export function LogTimestamp() {
7
- return <Ink.Text dimColor>{DateTime.now().toFormat("[yyyy-MM-dd HH:mm:ss.SSS] ")}</Ink.Text>;
8
- }