git-stack-cli 2.9.3 → 2.9.5

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,14 +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
32
  const state = Store.getState();
34
33
  const actions = state.actions;
34
+ const argv = state.argv;
35
+ const merge_base = state.merge_base;
35
36
  const master_branch = state.master_branch;
36
37
  const master_branch_name = master_branch.replace(/^origin\//, "");
37
38
  const commit_list = await git.get_commits(`${master_branch}..HEAD`);
38
39
 
40
+ invariant(merge_base, "merge_base must exist");
41
+
39
42
  const pr_lookup: Record<string, void | PullRequest> = {};
40
43
 
41
44
  let invalid = false;
@@ -125,6 +128,15 @@ export async function range(commit_group_map?: CommitGroupMap) {
125
128
  const group = group_value_list[i];
126
129
  const previous_group: undefined | CommitGroup = group_value_list[i - 1];
127
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
+
128
140
  if (group.id !== UNASSIGNED) {
129
141
  let pr_result = pr_lookup[group.id];
130
142
 
@@ -145,9 +157,8 @@ export async function range(commit_group_map?: CommitGroupMap) {
145
157
  if (i === 0) {
146
158
  group.base = master_branch_name;
147
159
  } else {
148
- const last_group = group_value_list[i - 1];
149
- // console.debug(" ", "last_group", last_group.pr?.title.substring(0, 40));
150
- // 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);
151
162
 
152
163
  if (group.master_base) {
153
164
  // explicitly set base to master when master_base is true
@@ -155,73 +166,93 @@ export async function range(commit_group_map?: CommitGroupMap) {
155
166
  } else if (group.id === UNASSIGNED) {
156
167
  // null out base when unassigned and after unassigned
157
168
  group.base = null;
158
- } 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) {
159
174
  // null out base when last group base is null
160
175
  group.base = null;
161
176
  } else {
162
- group.base = last_group.id;
177
+ group.base = previous_group.id;
163
178
  }
164
-
165
- // console.debug(" ", "group.base", group.base);
166
179
  }
167
180
 
168
- actions.json({ group });
181
+ actions.debug(` base=${group.base}`);
169
182
 
170
183
  if (!group.pr) {
184
+ actions.debug(` group.pr=${group.pr}`);
171
185
  group.dirty = true;
172
186
  } else {
173
- if (group.pr.baseRefName !== group.base) {
174
- actions.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");
175
195
  group.dirty = true;
176
196
  } else if (group.master_base) {
177
- actions.debug("MASTER_BASE_DIFF_COMPARE");
178
-
179
- // special case
180
- // master_base groups cannot be compared by commit sha
181
- // instead compare the literal diff local against origin
182
- // gh pr diff --color=never 110
183
- // git --no-pager diff --color=never 00c8fe0~1..00c8fe0
184
- let diff_github = await github.pr_diff(group.pr.number);
185
- diff_github = normalize_diff(diff_github);
186
-
187
- let diff_local = await git.get_diff(group.commits);
188
- diff_local = normalize_diff(diff_local);
189
-
190
- actions.json({ diff_local, diff_github });
191
-
192
- // find the first differing character index
193
- let compare_length = Math.max(diff_github.length, diff_local.length);
194
- let diff_index = -1;
195
- for (let c_i = 0; c_i < compare_length; c_i++) {
196
- if (diff_github[c_i] !== diff_local[c_i]) {
197
- diff_index = c_i;
198
- break;
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;
199
203
  }
200
204
  }
201
- if (diff_index > -1) {
202
- group.dirty = true;
203
205
 
204
- if (DEBUG) {
205
- // print preview at diff_index for both strings
206
- const preview_radius = 30;
207
- const start_index = Math.max(0, diff_index - preview_radius);
208
- const end_index = Math.min(compare_length, diff_index + preview_radius);
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.diff_commits(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
+ }
229
+ }
230
+ if (diff_index > -1) {
231
+ actions.debug(" MASTER_BASE_DIFF_MISMATCH");
232
+ group.dirty = true;
233
+
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);
209
239
 
210
- diff_github = diff_github.substring(start_index, end_index);
211
- 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);
212
242
 
213
- diff_local = diff_local.substring(start_index, end_index);
214
- 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);
215
245
 
216
- let pointer_indent = " ".repeat(diff_index - start_index + 1);
217
- actions.debug(`⚠️ git diff mismatch`);
218
- actions.debug(` ${pointer_indent}⌄`);
219
- actions.debug(`diff_github …${diff_github}…`);
220
- actions.debug(`diff_local …${diff_local}…`);
221
- actions.debug(` ${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
+ }
222
253
  }
223
254
  }
224
- } else if (!group.master_base && previous_group && previous_group.master_base) {
255
+ } else if (MASTER_BASE_BOUNDARY) {
225
256
  // special case
226
257
  // boundary between normal commits and master commits
227
258
 
@@ -239,24 +270,23 @@ export async function range(commit_group_map?: CommitGroupMap) {
239
270
 
240
271
  // compare all commits against pr commits
241
272
  if (group.pr.commits.length !== all_commits.length) {
242
- actions.debug("BOUNDARY_COMMIT_LENGTH_MISMATCH");
273
+ actions.debug(" BOUNDARY_COMMIT_LENGTH_MISMATCH");
243
274
  group.dirty = true;
244
275
  } else {
245
- actions.debug("BOUNDARY_COMMIT_SHA_COMPARISON");
246
276
  for (let i = 0; i < group.pr.commits.length; i++) {
247
277
  const pr_commit = group.pr.commits[i];
248
278
  const local_commit = all_commits[i];
249
279
 
250
280
  if (pr_commit.oid !== local_commit.sha) {
281
+ actions.debug(" BOUNDARY_COMMIT_SHA_MISMATCH");
251
282
  group.dirty = true;
252
283
  }
253
284
  }
254
285
  }
255
286
  } else if (group.pr.commits.length !== group.commits.length) {
256
- actions.debug("COMMIT_LENGTH_MISMATCH");
287
+ actions.debug(" COMMIT_LENGTH_MISMATCH");
257
288
  group.dirty = true;
258
289
  } else {
259
- actions.debug("COMMIT_SHA_COMPARISON");
260
290
  // if we still haven't marked this dirty, check each commit
261
291
  // comapre literal commit shas in group
262
292
  for (let i = 0; i < group.pr.commits.length; i++) {
@@ -264,14 +294,14 @@ export async function range(commit_group_map?: CommitGroupMap) {
264
294
  const local_commit = group.commits[i];
265
295
 
266
296
  if (pr_commit.oid !== local_commit.sha) {
267
- actions.json({ pr_commit, local_commit });
297
+ actions.debug(" COMMIT_SHA_MISMATCH");
268
298
  group.dirty = true;
269
299
  }
270
300
  }
271
301
  }
272
302
  }
273
303
 
274
- // console.debug(" ", "group.dirty", group.dirty);
304
+ actions.debug(` group.dirty=${group.dirty}`);
275
305
  }
276
306
 
277
307
  // reverse group_list to match git log
@@ -0,0 +1,36 @@
1
+ import * as React from "react";
2
+
3
+ import * as Ink from "ink-cjs";
4
+
5
+ import { FormatText } from "~/app/FormatText";
6
+ import { colors } from "~/core/colors";
7
+
8
+ type CacheMessageArgs = {
9
+ hit: boolean;
10
+ message: React.ReactNode;
11
+ extra: React.ReactNode;
12
+ };
13
+
14
+ export function cache_message(args: CacheMessageArgs) {
15
+ const status = args.hit ? (
16
+ <Ink.Text bold color={colors.green}>
17
+ HIT
18
+ </Ink.Text>
19
+ ) : (
20
+ <Ink.Text bold color={colors.red}>
21
+ MISS
22
+ </Ink.Text>
23
+ );
24
+
25
+ return (
26
+ <FormatText
27
+ wrapper={<Ink.Text dimColor />}
28
+ message="{message} {status} {extra}"
29
+ values={{
30
+ message: args.message,
31
+ status,
32
+ extra: args.extra,
33
+ }}
34
+ />
35
+ );
36
+ }
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));
@@ -0,0 +1,193 @@
1
+ import * as React from "react";
2
+
3
+ import path from "node:path";
4
+
5
+ import * as Ink from "ink-cjs";
6
+
7
+ import { Store } from "~/app/Store";
8
+ import * as Metadata from "~/core/Metadata";
9
+ import { cache_message } from "~/core/cache_message";
10
+ import { cli } from "~/core/cli";
11
+ import { colors } from "~/core/colors";
12
+ import { invariant } from "~/core/invariant";
13
+ import { safe_exists } from "~/core/safe_exists";
14
+
15
+ type CommitList = Awaited<ReturnType<typeof get_commits>>;
16
+
17
+ export type Commit = CommitList[0];
18
+
19
+ export async function get_commits(dot_range: string) {
20
+ const log_result = await cli(`git log ${dot_range} --format=${FORMAT} --color=never`);
21
+
22
+ if (!log_result.stdout) {
23
+ return [];
24
+ }
25
+
26
+ const commit_list = [];
27
+
28
+ for (let record of log_result.stdout.split(SEP.record)) {
29
+ record = record.replace(/^\n/, "");
30
+ record = record.replace(/\n$/, "");
31
+
32
+ if (!record) continue;
33
+
34
+ const [sha, full_message] = record.split(SEP.field);
35
+
36
+ // ensure sha is a hex string, otherwise we should throw an error
37
+ if (!RE.git_sha.test(sha)) {
38
+ const actions = Store.getState().actions;
39
+ const sep_values = JSON.stringify(Object.values(SEP));
40
+ const message = `unable to parse git commits, maybe commit message contained ${sep_values}`;
41
+ actions.error(message);
42
+ actions.exit(19);
43
+ }
44
+
45
+ const metadata = Metadata.read(full_message);
46
+ const branch_id = metadata.id;
47
+ const subject_line = metadata.subject || "";
48
+ const title = metadata.title;
49
+ const master_base = metadata.base === "master";
50
+
51
+ const commit = {
52
+ sha,
53
+ full_message,
54
+ subject_line,
55
+ branch_id,
56
+ title,
57
+ master_base,
58
+ };
59
+
60
+ commit_list.push(commit);
61
+ }
62
+
63
+ commit_list.reverse();
64
+
65
+ return commit_list;
66
+ }
67
+
68
+ type WorktreeAddArgs = {
69
+ name?: string;
70
+ commit_list: CommitList;
71
+ };
72
+
73
+ type WorktreeAddResult = {
74
+ worktree_path: string;
75
+ merge_base: string;
76
+ };
77
+
78
+ export async function worktree_add(args: WorktreeAddArgs): Promise<WorktreeAddResult> {
79
+ const state = Store.getState();
80
+ const actions = state.actions;
81
+ const merge_base = state.merge_base;
82
+ invariant(merge_base, "merge_base must exist");
83
+ const repo_path = state.repo_path;
84
+ invariant(repo_path, "repo_path must exist");
85
+
86
+ const worktree_name = args.name || "push_master_group";
87
+ const worktree_path = path.join(
88
+ process.env.HOME,
89
+ ".cache",
90
+ "git-stack",
91
+ "worktrees",
92
+ repo_path,
93
+ worktree_name,
94
+ );
95
+
96
+ if (!(await safe_exists(worktree_path))) {
97
+ actions.output(
98
+ <Ink.Text color={colors.white}>
99
+ Creating <Ink.Text color={colors.yellow}>{worktree_path}</Ink.Text>
100
+ </Ink.Text>,
101
+ );
102
+ actions.output(
103
+ <Ink.Text color={colors.gray}>(this may take a moment the first time…)</Ink.Text>,
104
+ );
105
+ await cli(`git worktree add -f --detach ${worktree_path} ${merge_base}`);
106
+ }
107
+
108
+ // ensure worktree is clean + on the right base before applying commits
109
+ // - abort any in-progress cherry-pick/rebase
110
+ // - drop local changes/untracked files to fresh state
111
+ const quiet_ignore = { quiet: true, ignoreExitCode: true };
112
+ await cli(`git -C ${worktree_path} cherry-pick --abort`, quiet_ignore);
113
+ await cli(`git -C ${worktree_path} rebase --abort`, quiet_ignore);
114
+ await cli(`git -C ${worktree_path} merge --abort`, quiet_ignore);
115
+ await cli(`git -C ${worktree_path} checkout -f --detach ${merge_base}`);
116
+ await cli(`git -C ${worktree_path} clean -fd`);
117
+
118
+ // cherry-pick the group commits onto that base
119
+ const cp_commit_list = args.commit_list.map((c) => c.sha).join(" ");
120
+ await cli(`git -C ${worktree_path} cherry-pick ${cp_commit_list}`);
121
+
122
+ return { worktree_path, merge_base };
123
+ }
124
+
125
+ export async function diff_commits(commit_list: CommitList) {
126
+ const state = Store.getState();
127
+ const actions = state.actions;
128
+ const merge_base = state.merge_base;
129
+ invariant(merge_base, "merge_base must exist");
130
+
131
+ const cache_key = `${merge_base}:${commit_list.map((c) => c.sha).join(",")}`;
132
+
133
+ const cache = state.cache_diff[cache_key];
134
+
135
+ if (cache) {
136
+ if (actions.isDebug()) {
137
+ actions.debug(
138
+ cache_message({
139
+ hit: true,
140
+ message: "git diff_commits cache",
141
+ extra: cache_key,
142
+ }),
143
+ );
144
+ }
145
+
146
+ return cache;
147
+ }
148
+
149
+ if (actions.isDebug()) {
150
+ actions.debug(
151
+ cache_message({
152
+ hit: false,
153
+ message: "git diff_commits cache",
154
+ extra: cache_key,
155
+ }),
156
+ );
157
+ }
158
+
159
+ const { worktree_path } = await worktree_add({ commit_list });
160
+ const cli_result = await cli(
161
+ `git -C ${worktree_path} --no-pager diff --color=never ${merge_base}`,
162
+ { quiet: true },
163
+ );
164
+
165
+ if (cli_result.code !== 0) {
166
+ throw new Error(cli_result.output);
167
+ }
168
+
169
+ const diff = cli_result.stdout;
170
+
171
+ actions.set((state) => {
172
+ state.cache_diff[cache_key] = diff;
173
+ });
174
+
175
+ return diff;
176
+ }
177
+
178
+ // Why these separators?
179
+ // - Rare in human written text
180
+ // - Supported in git %xNN to write bytes
181
+ // - Supported in javascript \xNN to write bytes
182
+ // - Used historically as separators in unicode
183
+ // https://en.wikipedia.org/wiki/C0_and_C1_control_codes#Field_separators
184
+ const SEP = {
185
+ record: "\x1e",
186
+ field: "\x1f",
187
+ };
188
+
189
+ const FORMAT = `%H${SEP.field}%B${SEP.record}`;
190
+
191
+ const RE = {
192
+ git_sha: /^[0-9a-fA-F]{40}$/,
193
+ };