git-stack-cli 2.7.0 → 2.7.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-stack-cli",
3
- "version": "2.7.0",
3
+ "version": "2.7.2",
4
4
  "description": "",
5
5
  "author": "magus",
6
6
  "license": "MIT",
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import * as util from "util";
4
4
 
5
5
  import * as file from "~/core/file";
6
+ import { get_local_iso } from "~/core/get_local_iso";
6
7
  import { spawn } from "~/core/spawn";
7
8
 
8
9
  const parsed_args = util.parseArgs({
@@ -25,7 +26,7 @@ const WATCH = parsed_args.values.watch;
25
26
  const VERBOSE = parsed_args.values.verbose;
26
27
 
27
28
  function log(...args: any[]) {
28
- const timestamp = new Date().toISOString();
29
+ const timestamp = get_local_iso(new Date());
29
30
  console.debug(`[${timestamp}]`, ...args);
30
31
  }
31
32
 
@@ -0,0 +1,20 @@
1
+ export function get_local_iso(date: Date) {
2
+ const d: Record<string, string> = {};
3
+ for (const part of FORMATTER.formatToParts(date)) {
4
+ d[part.type] = part.value;
5
+ }
6
+
7
+ const ms = String(date.getMilliseconds()).padStart(3, "0");
8
+ const timestamp = `${d.year}-${d.month}-${d.day}T${d.hour}:${d.minute}:${d.second}.${ms}Z`;
9
+ return timestamp;
10
+ }
11
+
12
+ const FORMATTER = new Intl.DateTimeFormat("en-CA", {
13
+ year: "numeric",
14
+ month: "2-digit",
15
+ day: "2-digit",
16
+ hour: "2-digit",
17
+ minute: "2-digit",
18
+ second: "2-digit",
19
+ hour12: false,
20
+ });
@@ -5,14 +5,15 @@ import * as Ink from "ink-cjs";
5
5
  import { Brackets } from "~/app/Brackets";
6
6
  import { Command } from "~/app/Command";
7
7
  import { FormatText } from "~/app/FormatText";
8
+ import { Url } from "~/app/Url";
8
9
  import { YesNoPrompt } from "~/app/YesNoPrompt";
9
10
  import { assertNever } from "~/core/assertNever";
10
11
  import { cli } from "~/core/cli";
11
12
  import { colors } from "~/core/colors";
12
13
  import { fetch_json } from "~/core/fetch_json";
14
+ import { get_timeout_fn } from "~/core/get_timeout_fn";
13
15
  import { is_finite_value } from "~/core/is_finite_value";
14
16
  import { semver_compare } from "~/core/semver_compare";
15
- import { sleep } from "~/core/sleep";
16
17
 
17
18
  type Props = {
18
19
  name: string;
@@ -26,10 +27,9 @@ type Props = {
26
27
  };
27
28
 
28
29
  type State = {
29
- error: null | Error;
30
+ status: "init" | "prompt" | "install" | "done";
30
31
  local_version: null | string;
31
32
  latest_version: null | string;
32
- status: "init" | "prompt" | "install" | "done";
33
33
  is_brew_bun_standalone: boolean;
34
34
  };
35
35
 
@@ -44,24 +44,94 @@ export function AutoUpdate(props: Props) {
44
44
  const [output, set_output] = React.useState<Array<React.ReactNode>>([]);
45
45
 
46
46
  const [state, patch] = React.useReducer(reducer, {
47
- error: null,
47
+ status: "init",
48
48
  local_version: null,
49
49
  latest_version: null,
50
- status: "init",
51
50
  is_brew_bun_standalone: false,
51
+
52
+ // // debugging
53
+ // status: "prompt",
54
+ // local_version: "2.5.3",
55
+ // latest_version: "2.7.0",
56
+ // is_brew_bun_standalone: true,
52
57
  });
53
58
 
54
- function handle_output(node: React.ReactNode) {
55
- if (typeof props.onOutput === "function") {
56
- props.onOutput(node);
57
- } else {
58
- set_output((current) => {
59
- return [...current, node];
60
- });
59
+ React.useEffect(handle_init_state, []);
60
+ React.useEffect(handle_status, [state.latest_version]);
61
+ React.useEffect(handle_on_done, [state.status]);
62
+
63
+ const status = render_status();
64
+
65
+ return (
66
+ <React.Fragment>
67
+ {output}
68
+ {status}
69
+ </React.Fragment>
70
+ );
71
+
72
+ function render_status() {
73
+ switch (state.status) {
74
+ case "init":
75
+ return null;
76
+
77
+ case "install":
78
+ return null;
79
+
80
+ case "done":
81
+ return props.children;
82
+
83
+ case "prompt": {
84
+ let install_command = "";
85
+ if (state.is_brew_bun_standalone) {
86
+ install_command = "brew install magus/git-stack/git-stack";
87
+ } else {
88
+ install_command = `npm install -g ${props.name}@latest`;
89
+ }
90
+
91
+ return (
92
+ <YesNoPrompt
93
+ message={
94
+ <Ink.Box flexDirection="column" gap={1}>
95
+ <Command>{install_command}</Command>
96
+ <FormatText
97
+ wrapper={<Ink.Text color={colors.yellow} />}
98
+ message="Would you like to run the above command to update?"
99
+ />
100
+ </Ink.Box>
101
+ }
102
+ onNo={() => {
103
+ patch({ status: "done" });
104
+ }}
105
+ onYes={async () => {
106
+ info(<Command>{install_command}</Command>);
107
+
108
+ patch({ status: "install" });
109
+
110
+ await cli(install_command, {
111
+ env: {
112
+ ...process.env,
113
+ HOMEBREW_COLOR: "1",
114
+ },
115
+ onOutput: (data: string) => {
116
+ info(<Ink.Text>{data}</Ink.Text>);
117
+ },
118
+ });
119
+
120
+ info(
121
+ <Ink.Text key="done">
122
+ ✅ Installed <Brackets>{state.latest_version}</Brackets>
123
+ </Ink.Text>,
124
+ );
125
+
126
+ patch({ status: "done" });
127
+ }}
128
+ />
129
+ );
130
+ }
61
131
  }
62
132
  }
63
133
 
64
- React.useEffect(() => {
134
+ function handle_on_done() {
65
135
  switch (state.status) {
66
136
  case "init":
67
137
  case "prompt":
@@ -76,199 +146,156 @@ export function AutoUpdate(props: Props) {
76
146
  default:
77
147
  assertNever(state.status);
78
148
  }
79
- }, [state.status]);
149
+ }
80
150
 
81
- React.useEffect(() => {
82
- let status: State["status"] = "init";
83
- let latest_version: string | null = null;
84
- let is_brew_bun_standalone = false;
151
+ function handle_init_state() {
152
+ init_state().catch(abort);
85
153
 
86
- const local_version = process.env.CLI_VERSION;
87
- const is_output = props_ref.current.verbose || props_ref.current.force;
154
+ async function init_state() {
155
+ if (state.latest_version !== null) return;
88
156
 
89
- async function auto_update() {
90
- if (!local_version) {
91
- throw new Error("Auto update requires process.env.CLI_VERSION to be set");
92
- }
157
+ const local_version = process.env.CLI_VERSION;
158
+ const latest_version = await get_latest_version();
159
+ const is_brew_bun_standalone = get_is_brew_bun_standalone();
160
+ patch({ local_version, latest_version, is_brew_bun_standalone });
161
+ }
93
162
 
163
+ async function get_latest_version() {
94
164
  const timeout_ms = is_finite_value(props.timeoutMs) ? props.timeoutMs : 2 * 1000;
165
+ const timeout = get_timeout_fn(timeout_ms, "AutoUpdate timeout");
166
+ const npm_json = await timeout(fetch_json(`https://registry.npmjs.org/${props.name}`));
167
+ const maybe_version = npm_json?.["dist-tags"]?.latest;
168
+ if (typeof maybe_version === "string") {
169
+ return maybe_version;
170
+ }
171
+ throw new Error("Unable to retrieve latest version from npm");
172
+ }
173
+
174
+ function get_is_brew_bun_standalone() {
175
+ const binary_path = process.argv[1];
176
+ debug(<Ink.Text dimColor>{JSON.stringify({ binary_path })}</Ink.Text>);
177
+
178
+ const is_bunfs_path = binary_path.startsWith("/$bunfs");
179
+ debug(
180
+ <Ink.Text dimColor>
181
+ {is_bunfs_path
182
+ ? "brew install detected (compiled bun standalone)"
183
+ : "npm install detected"}
184
+ </Ink.Text>,
185
+ );
186
+
187
+ return is_bunfs_path;
188
+ }
189
+ }
95
190
 
96
- const npm_json = await Promise.race([
97
- fetch_json(`https://registry.npmjs.org/${props.name}`),
191
+ function handle_status() {
192
+ const latest_version = state.latest_version;
98
193
 
99
- sleep(timeout_ms).then(() => {
100
- throw new Error("AutoUpdate timeout");
101
- }),
102
- ]);
194
+ if (latest_version === null) {
195
+ return;
196
+ }
103
197
 
104
- latest_version = npm_json?.["dist-tags"]?.latest;
198
+ const local_version = state.local_version;
105
199
 
106
- if (!latest_version) {
107
- throw new Error("Unable to retrieve latest version from npm");
108
- }
200
+ if (!local_version) {
201
+ throw new Error("Auto update requires process.env.CLI_VERSION to be set");
202
+ }
109
203
 
110
- const binary_path = process.argv[1];
204
+ debug(
205
+ <FormatText
206
+ key="versions"
207
+ wrapper={<Ink.Text dimColor />}
208
+ message="Auto update found latest version {latest_version} and current local version {local_version}"
209
+ values={{
210
+ latest_version: <Brackets>{latest_version}</Brackets>,
211
+ local_version: <Brackets>{local_version}</Brackets>,
212
+ }}
213
+ />,
214
+ );
215
+
216
+ const semver_result = semver_compare(latest_version, local_version);
217
+ debug(<Ink.Text dimColor>{JSON.stringify({ semver_result })}</Ink.Text>);
218
+
219
+ switch (semver_result) {
220
+ case 0: {
221
+ info(
222
+ <Ink.Text>
223
+ ✅ Everything up to date. <Brackets>{latest_version}</Brackets>
224
+ </Ink.Text>,
225
+ );
111
226
 
112
- if (props_ref.current.verbose) {
113
- handle_output(<Ink.Text dimColor>{JSON.stringify({ binary_path })}</Ink.Text>);
227
+ return patch({ status: "done" });
114
228
  }
115
229
 
116
- is_brew_bun_standalone = binary_path.startsWith("/$bunfs");
230
+ case 1: {
231
+ const old_tag = local_version;
232
+ const new_tag = state.latest_version;
233
+ const url = `https://github.com/magus/git-stack-cli/compare/${old_tag}...${new_tag}`;
117
234
 
118
- if (props_ref.current.verbose) {
119
- if (is_brew_bun_standalone) {
120
- handle_output(
121
- <Ink.Text dimColor>brew install detected (compiled bun standalone)</Ink.Text>,
122
- );
123
- } else {
124
- handle_output(<Ink.Text dimColor>npm install detected</Ink.Text>);
125
- }
235
+ info(
236
+ <Ink.Box flexDirection="column" gap={1} paddingTop={1} paddingBottom={1}>
237
+ <Ink.Text>
238
+ 🆕 New version available! <Brackets>{latest_version}</Brackets>
239
+ </Ink.Text>
240
+ <Ink.Box flexDirection="column">
241
+ <Ink.Text dimColor>Changelog</Ink.Text>
242
+ <Url>{url}</Url>
243
+ </Ink.Box>
244
+ </Ink.Box>,
245
+ );
246
+
247
+ return patch({ status: "prompt" });
126
248
  }
127
249
 
128
- if (props_ref.current.verbose) {
129
- handle_output(
250
+ case -1: {
251
+ info(
130
252
  <FormatText
131
- key="versions"
132
- wrapper={<Ink.Text />}
133
- message="Auto update found latest version {latest_version} and current local version {local_version}"
253
+ message="⚠️ Local version {local_version} is newer than latest version {latest_version}"
134
254
  values={{
135
- latest_version: <Brackets>{latest_version}</Brackets>,
136
255
  local_version: <Brackets>{local_version}</Brackets>,
256
+ latest_version: <Brackets>{latest_version}</Brackets>,
137
257
  }}
138
258
  />,
139
259
  );
140
- }
141
260
 
142
- const semver_result = semver_compare(latest_version, local_version);
143
- if (props_ref.current.verbose) {
144
- handle_output(<Ink.Text dimColor>{JSON.stringify({ semver_result })}</Ink.Text>);
261
+ return patch({ status: "done" });
145
262
  }
146
263
 
147
- if (semver_result === 0) {
148
- status = "done";
149
-
150
- if (is_output) {
151
- handle_output(
152
- <Ink.Text>
153
- ✅ Everything up to date. <Brackets>{latest_version}</Brackets>
154
- </Ink.Text>,
155
- );
156
- }
157
- return;
158
- }
159
-
160
- if (semver_result === 1) {
161
- // trigger yes no prompt
162
- status = "prompt";
264
+ default: {
265
+ assertNever(semver_result);
266
+ abort(new Error("AutoUpdate failed"));
163
267
  }
268
+ }
269
+ }
164
270
 
165
- throw new Error("AutoUpdate failed");
271
+ function info(node: React.ReactNode) {
272
+ if (props_ref.current.verbose || props_ref.current.force) {
273
+ handle_output(node);
166
274
  }
275
+ }
167
276
 
168
- const onError = props_ref.current.onError || (() => {});
169
-
170
- auto_update()
171
- .then(() => {
172
- patch({ status, local_version, latest_version, is_brew_bun_standalone });
173
- })
174
- .catch((error) => {
175
- if (props_ref.current.verbose) {
176
- handle_output(
177
- <Ink.Text key="error" color={colors.red}>
178
- {error?.message}
179
- </Ink.Text>,
180
- );
181
- }
277
+ function debug(node: React.ReactNode) {
278
+ if (props_ref.current.verbose) {
279
+ handle_output(node);
280
+ }
281
+ }
282
+ function abort(error: Error) {
283
+ info(
284
+ <Ink.Text key="error" color={colors.red}>
285
+ {error.message}
286
+ </Ink.Text>,
287
+ );
288
+ patch({ status: "done" });
289
+ props_ref.current.onError?.(error);
290
+ }
182
291
 
183
- // ensure we always exit
184
- status = "done";
185
- patch({ status, error, local_version, latest_version, is_brew_bun_standalone });
186
- onError(error);
292
+ function handle_output(node: React.ReactNode) {
293
+ if (typeof props.onOutput === "function") {
294
+ props.onOutput(node);
295
+ } else {
296
+ set_output((current) => {
297
+ return [...current, node];
187
298
  });
188
- }, []);
189
-
190
- const status = (function render_status() {
191
- switch (state.status) {
192
- case "init":
193
- return null;
194
-
195
- case "prompt": {
196
- let install_command = "";
197
- if (state.is_brew_bun_standalone) {
198
- install_command = "brew install magus/git-stack/git-stack";
199
- } else {
200
- install_command = `npm install -g ${props.name}@latest`;
201
- }
202
-
203
- return (
204
- <YesNoPrompt
205
- message={
206
- <Ink.Box flexDirection="column">
207
- <Ink.Box flexDirection="column">
208
- <Ink.Text color={colors.yellow}>
209
- <FormatText
210
- wrapper={<Ink.Text />}
211
- message="New version available {latest_version}"
212
- values={{
213
- latest_version: <Brackets>{state.latest_version}</Brackets>,
214
- }}
215
- />
216
- ,
217
- </Ink.Text>
218
- <Ink.Text> </Ink.Text>
219
- <Command>{install_command}</Command>
220
- <Ink.Text> </Ink.Text>
221
- </Ink.Box>
222
- <Ink.Box>
223
- <FormatText
224
- wrapper={<Ink.Text color={colors.yellow} />}
225
- message="Would you like to run the above command to update?"
226
- />
227
- </Ink.Box>
228
- </Ink.Box>
229
- }
230
- onYes={async () => {
231
- handle_output(<Command>{install_command}</Command>);
232
-
233
- patch({ status: "install" });
234
-
235
- await cli(install_command, {
236
- env: {
237
- ...process.env,
238
- HOMEBREW_COLOR: "1",
239
- },
240
- onOutput: (data: string) => {
241
- handle_output(<Ink.Text>{data}</Ink.Text>);
242
- },
243
- });
244
-
245
- handle_output(
246
- <Ink.Text key="done">
247
- ✅ Installed <Brackets>{state.latest_version}</Brackets>
248
- </Ink.Text>,
249
- );
250
-
251
- patch({ status: "done" });
252
- }}
253
- onNo={() => {
254
- patch({ status: "done" });
255
- }}
256
- />
257
- );
258
- }
259
-
260
- case "install":
261
- return null;
262
-
263
- case "done":
264
- return props.children;
265
299
  }
266
- })();
267
-
268
- return (
269
- <React.Fragment>
270
- {output}
271
- {status}
272
- </React.Fragment>
273
- );
300
+ }
274
301
  }
@@ -380,7 +380,11 @@ function SelectCommitRangesInternal(props: Props) {
380
380
  {sync_status !== "allow_unassigned" ? null : (
381
381
  <FormatText
382
382
  wrapper={<Ink.Text color={colors.gray} />}
383
- message="Press {s} to {sync} the {count} assigned commits to Github"
383
+ message={
384
+ argv.sync
385
+ ? "Press {s} to {sync} the {count} assigned commits to Github"
386
+ : "Press {s} to {sync} the {count} assigned commits locally"
387
+ }
384
388
  values={{
385
389
  ...S_TO_SYNC_VALUES,
386
390
  count: (
@@ -393,21 +397,15 @@ function SelectCommitRangesInternal(props: Props) {
393
397
  )}
394
398
  </React.Fragment>
395
399
  ) : (
396
- <React.Fragment>
397
- {argv.sync ? (
398
- <FormatText
399
- wrapper={<Ink.Text />}
400
- message="🎉 Done! Press {s} to {sync} the commits to Github"
401
- values={S_TO_SYNC_VALUES}
402
- />
403
- ) : (
404
- <FormatText
405
- wrapper={<Ink.Text />}
406
- message="🎉 Done! Press {s} to {save} the commits locally"
407
- values={S_TO_SYNC_VALUES}
408
- />
409
- )}
410
- </React.Fragment>
400
+ <FormatText
401
+ wrapper={<Ink.Text />}
402
+ message={
403
+ argv.sync
404
+ ? "🎉 Done! Press {s} to {sync} the PRs to Github"
405
+ : "🎉 Done! Press {s} to {sync} the PRs locally"
406
+ }
407
+ values={S_TO_SYNC_VALUES}
408
+ />
411
409
  )}
412
410
 
413
411
  <Ink.Box>
@@ -78,7 +78,9 @@ Rebase.run = async function run(props: Props) {
78
78
  actions.exit(20);
79
79
  }
80
80
 
81
+ actions.debug("start CommitMetadata.range");
81
82
  const next_commit_range = await CommitMetadata.range();
83
+ actions.debug("end CommitMetadata.range");
82
84
 
83
85
  actions.output(
84
86
  <FormatText
@@ -167,7 +169,9 @@ Rebase.run = async function run(props: Props) {
167
169
  // of original branch to the newly created temporary branch
168
170
  await cli(`git branch -f ${branch_name} ${temp_branch_name}`);
169
171
 
172
+ actions.debug("start restore_git()");
170
173
  restore_git();
174
+ actions.debug("end restore_git()");
171
175
  }
172
176
 
173
177
  // cleanup git operations if cancelled during manual rebase
@@ -1,9 +1,7 @@
1
1
  import { Store } from "~/app/Store";
2
- import * as Metadata from "~/core/Metadata";
3
- import { cli } from "~/core/cli";
2
+ import * as git from "~/core/git";
4
3
  import * as github from "~/core/github";
5
4
 
6
- export type CommitMetadata = Awaited<ReturnType<typeof commit>>;
7
5
  export type CommitRange = Awaited<ReturnType<typeof range>>;
8
6
 
9
7
  type GithubPRStatus = ReturnType<typeof github.pr_status>;
@@ -15,24 +13,25 @@ type CommitGroup = {
15
13
  pr: null | PullRequest;
16
14
  base: null | string;
17
15
  dirty: boolean;
18
- commits: Array<CommitMetadata>;
16
+ commits: Array<git.Commit>;
19
17
  };
20
18
 
21
19
  export type SimpleGroup = { id: string; title: string };
22
20
  type CommitGroupMap = { [sha: string]: SimpleGroup };
23
21
 
24
22
  export async function range(commit_group_map?: CommitGroupMap) {
25
- const master_branch = Store.getState().master_branch;
26
-
27
23
  // gather all open prs in repo first
28
24
  // cheaper query to populate cache
29
25
  await github.pr_list();
30
26
 
31
- const commit_list = await get_commit_list();
27
+ const master_branch = Store.getState().master_branch;
28
+ const commit_list = await git.get_commits(`${master_branch}..HEAD`);
32
29
 
33
30
  const pr_lookup: Record<string, void | PullRequest> = {};
34
31
 
35
32
  let invalid = false;
33
+ let last_group_id: null | string = null;
34
+
36
35
  const group_map = new Map<string, CommitGroup>();
37
36
 
38
37
  for (const commit of commit_list) {
@@ -57,10 +56,7 @@ export async function range(commit_group_map?: CommitGroupMap) {
57
56
  }
58
57
 
59
58
  if (id) {
60
- const group_key_list = Array.from(group_map.keys());
61
- const last_key = group_key_list[group_key_list.length - 1];
62
-
63
- if (group_map.has(id) && last_key !== id) {
59
+ if (group_map.has(id) && last_group_id !== id) {
64
60
  // if we've seen this id before and it's not
65
61
  // the last added key then we are out of order
66
62
  // console.debug("INVALID", "OUT OF ORDER");
@@ -87,6 +83,7 @@ export async function range(commit_group_map?: CommitGroupMap) {
87
83
 
88
84
  group.commits.push(commit);
89
85
  group_map.set(id, group);
86
+ last_group_id = id;
90
87
  }
91
88
 
92
89
  // check each group for dirty state and base
@@ -183,53 +180,4 @@ export async function range(commit_group_map?: CommitGroupMap) {
183
180
  return { invalid, group_list, commit_list, pr_lookup, UNASSIGNED };
184
181
  }
185
182
 
186
- async function get_commit_list() {
187
- const master_branch = Store.getState().master_branch;
188
- const log_result = await cli(
189
- `git log ${master_branch}..HEAD --oneline --format=%H --color=never`,
190
- );
191
-
192
- if (!log_result.stdout) {
193
- return [];
194
- }
195
-
196
- const sha_list = lines(log_result.stdout).reverse();
197
-
198
- const commit_metadata_list = [];
199
-
200
- for (let i = 0; i < sha_list.length; i++) {
201
- const sha = sha_list[i];
202
- const commit_metadata = await commit(sha);
203
- commit_metadata_list.push(commit_metadata);
204
- }
205
-
206
- return commit_metadata_list;
207
- }
208
-
209
- export async function commit(sha: string) {
210
- const full_message = (await cli(`git show -s --format=%B ${sha}`)).stdout;
211
- const metadata = await Metadata.read(full_message);
212
- const branch_id = metadata?.id;
213
- const subject_line = get_subject_line(full_message);
214
- const title = metadata?.title;
215
-
216
- return {
217
- sha,
218
- full_message,
219
- subject_line,
220
- branch_id,
221
- title,
222
- };
223
- }
224
-
225
- function get_subject_line(message: string) {
226
- const line_list = lines(message);
227
- const first_line = line_list[0];
228
- return Metadata.remove(first_line);
229
- }
230
-
231
- function lines(value: string) {
232
- return value.split("\n");
233
- }
234
-
235
183
  export const UNASSIGNED = "unassigned";