git-stack-cli 1.13.2 → 1.15.0

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": "1.13.2",
3
+ "version": "1.15.0",
4
4
  "description": "",
5
5
  "author": "magus",
6
6
  "license": "MIT",
package/src/app/App.tsx CHANGED
@@ -8,6 +8,7 @@ import { DetectInitialPR } from "~/app/DetectInitialPR";
8
8
  import { DirtyCheck } from "~/app/DirtyCheck";
9
9
  import { GatherMetadata } from "~/app/GatherMetadata";
10
10
  import { GithubApiError } from "~/app/GithubApiError";
11
+ import { HandleCtrlCSigint } from "~/app/HandleCtrlCSigint";
11
12
  import { LocalCommitStatus } from "~/app/LocalCommitStatus";
12
13
  import { Main } from "~/app/Main";
13
14
  import { Output } from "~/app/Output";
@@ -65,6 +66,8 @@ export function App() {
65
66
  </DependencyCheck>
66
67
  </VerboseDebugInfo>
67
68
  </AutoUpdate>
69
+
70
+ <HandleCtrlCSigint />
68
71
  </Providers>
69
72
  );
70
73
  }
package/src/app/Debug.tsx CHANGED
@@ -8,6 +8,7 @@ import * as Ink from "ink-cjs";
8
8
  import { Store } from "~/app/Store";
9
9
  import { colors } from "~/core/colors";
10
10
  import * as json from "~/core/json";
11
+ import { pretty_json } from "~/core/pretty_json";
11
12
  import { safe_rm } from "~/core/safe_rm";
12
13
 
13
14
  export function Debug() {
@@ -41,7 +42,7 @@ export function Debug() {
41
42
  await safe_rm(output_file);
42
43
 
43
44
  const serialized = json.serialize(state);
44
- const content = JSON.stringify(serialized, null, 2);
45
+ const content = pretty_json(serialized);
45
46
  await fs.writeFile(output_file, content);
46
47
  }
47
48
  },
@@ -15,7 +15,7 @@ type Props = {
15
15
  };
16
16
 
17
17
  type State = {
18
- status: "init" | "prompt" | "done";
18
+ status: "init" | "prompt" | "stash" | "done";
19
19
  };
20
20
 
21
21
  function reducer(state: State, patch: Partial<State>) {
@@ -35,34 +35,42 @@ export function DirtyCheck(props: Props) {
35
35
 
36
36
  case "prompt":
37
37
  return (
38
- <YesNoPrompt
39
- message={
40
- <Ink.Box flexDirection="column">
41
- <FormatText
42
- wrapper={<Ink.Text color={colors.yellow} />}
43
- message="{git} repo has uncommitted changes."
44
- values={{
45
- git: <Command>git</Command>,
46
- git_stack: <Command>git stack</Command>,
47
- }}
48
- />
38
+ <Ink.Box flexDirection="column">
39
+ <FormatText
40
+ wrapper={<Ink.Text color={colors.yellow} />}
41
+ message="⚠️ Uncommitted changes detected. {git_stack} needs a clean working tree."
42
+ values={{
43
+ git: <Command>git</Command>,
44
+ git_stack: <Command>git stack</Command>,
45
+ }}
46
+ />
47
+
48
+ <YesNoPrompt
49
+ message={
49
50
  <FormatText
50
51
  wrapper={<Ink.Text color={colors.yellow} />}
51
- message="Changes may be lost during {git_stack}, are you sure you want to proceed?"
52
+ message="{git_stash} changes to proceed?"
52
53
  values={{
53
- git: <Command>git</Command>,
54
- git_stack: <Command>git stack</Command>,
54
+ git_stash: <Command>git stash</Command>,
55
55
  }}
56
56
  />
57
- </Ink.Box>
58
- }
59
- onYes={async () => {
60
- patch({ status: "done" });
61
- }}
62
- onNo={async () => {
63
- actions.exit(0);
64
- }}
65
- />
57
+ }
58
+ onYes={async () => {
59
+ await cli("git stash --include-untracked");
60
+
61
+ actions.output(<Ink.Text>📦 Changes saved to stash</Ink.Text>);
62
+
63
+ actions.set((state) => {
64
+ state.is_dirty_check_stash = true;
65
+ });
66
+
67
+ patch({ status: "done" });
68
+ }}
69
+ onNo={async () => {
70
+ actions.exit(0);
71
+ }}
72
+ />
73
+ </Ink.Box>
66
74
  );
67
75
 
68
76
  default:
@@ -84,8 +92,11 @@ export function DirtyCheck(props: Props) {
84
92
  try {
85
93
  const git_dirty = (await cli(`git status --porcelain`)).stdout;
86
94
 
87
- const status = git_dirty ? "prompt" : "done";
88
- patch({ status });
95
+ if (!git_dirty) {
96
+ patch({ status: "done" });
97
+ } else {
98
+ patch({ status: "prompt" });
99
+ }
89
100
  } catch (err) {
90
101
  actions.error("Must be run from within a git repository.");
91
102
 
package/src/app/Exit.tsx CHANGED
@@ -1,6 +1,11 @@
1
1
  import * as React from "react";
2
2
 
3
+ import * as Ink from "ink-cjs";
4
+
3
5
  import { Store } from "~/app/Store";
6
+ import { cli } from "~/core/cli";
7
+ import { colors } from "~/core/colors";
8
+ import { sleep } from "~/core/sleep";
4
9
 
5
10
  type Props = {
6
11
  clear: boolean;
@@ -8,17 +13,49 @@ type Props = {
8
13
  };
9
14
 
10
15
  export function Exit(props: Props) {
11
- const actions = Store.useActions();
12
-
13
16
  React.useEffect(() => {
14
- if (props.clear) {
15
- actions.clear();
16
- }
17
+ // immediately handle exit on mount
18
+ handle_exit().catch((err) => {
19
+ // eslint-disable-next-line no-console
20
+ console.error(err);
21
+ });
22
+
23
+ async function handle_exit() {
24
+ const state = Store.getState();
25
+ const actions = state.actions;
26
+
27
+ actions.debug(`[Exit] handle_exit ${JSON.stringify(props)}`);
28
+
29
+ let exit_code = props.code;
17
30
 
18
- actions.unmount();
31
+ // run abort_handler if it exists
32
+ if (state.abort_handler) {
33
+ exit_code = await state.abort_handler();
34
+ }
19
35
 
20
- process.exitCode = props.code;
21
- process.exit();
36
+ // restore git stash if necessary
37
+ if (state.is_dirty_check_stash) {
38
+ await cli("git stash pop");
39
+ actions.output(
40
+ <Ink.Text color={colors.green}>
41
+ ✅ Changes restored from stash
42
+ </Ink.Text>
43
+ );
44
+ }
45
+
46
+ // ensure output has a chance to render
47
+ await sleep(1);
48
+
49
+ // finally handle the actual app and process exit
50
+ if (props.clear) {
51
+ actions.clear();
52
+ }
53
+
54
+ actions.unmount();
55
+
56
+ process.exitCode = exit_code;
57
+ process.exit();
58
+ }
22
59
  }, [props.clear, props.code]);
23
60
 
24
61
  return null;
@@ -0,0 +1,47 @@
1
+ import * as React from "react";
2
+
3
+ import * as Ink from "ink-cjs";
4
+
5
+ import { FormatText } from "~/app/FormatText";
6
+ import { Store } from "~/app/Store";
7
+ import { colors } from "~/core/colors";
8
+ import { sleep } from "~/core/sleep";
9
+
10
+ export function HandleCtrlCSigint() {
11
+ const actions = Store.useActions();
12
+
13
+ const [exiting, set_exiting] = React.useState(false);
14
+
15
+ Ink.useInput((input, key) => {
16
+ handle_input().catch((err) => {
17
+ // eslint-disable-next-line no-console
18
+ console.error(err);
19
+ });
20
+
21
+ async function handle_input() {
22
+ if (input === "c" && key.ctrl) {
23
+ actions.clear();
24
+
25
+ actions.output(
26
+ <Ink.Text color={colors.red}>
27
+ <FormatText message="🚨 Ctrl+C detected" />
28
+ </Ink.Text>
29
+ );
30
+
31
+ set_exiting(true);
32
+ await sleep(1);
33
+ actions.exit(235);
34
+ }
35
+ }
36
+ });
37
+
38
+ if (exiting) {
39
+ return (
40
+ <Ink.Text color={colors.red}>
41
+ <FormatText message="🚨 Exiting…" />
42
+ </Ink.Text>
43
+ );
44
+ }
45
+
46
+ return null;
47
+ }
@@ -15,35 +15,15 @@ import { invariant } from "~/core/invariant";
15
15
  import { short_id } from "~/core/short_id";
16
16
 
17
17
  export function ManualRebase() {
18
- const abort_handler = React.useRef(() => {});
19
-
20
- React.useEffect(function listen_sigint() {
21
- process.once("SIGINT", sigint_handler);
22
-
23
- return function cleanup() {
24
- process.removeListener("SIGINT", sigint_handler);
25
- };
26
-
27
- async function sigint_handler() {
28
- abort_handler.current();
29
- }
30
- }, []);
31
-
32
18
  return (
33
19
  <Await
34
20
  fallback={<Ink.Text color={colors.yellow}>Rebasing commits…</Ink.Text>}
35
- function={async function () {
36
- await run({ abort_handler });
37
- }}
21
+ function={run}
38
22
  />
39
23
  );
40
24
  }
41
25
 
42
- type Args = {
43
- abort_handler: React.MutableRefObject<() => void>;
44
- };
45
-
46
- async function run(args: Args) {
26
+ async function run() {
47
27
  const state = Store.getState();
48
28
  const actions = state.actions;
49
29
  const argv = state.argv;
@@ -57,11 +37,12 @@ async function run(args: Args) {
57
37
  invariant(commit_map, "commit_map must exist");
58
38
  invariant(repo_root, "repo_root must exist");
59
39
 
60
- // always listen for SIGINT event and restore git state
61
- args.abort_handler.current = function sigint_handler() {
40
+ // immediately register abort_handler in case of ctrl+c exit
41
+ actions.register_abort_handler(async function abort_manual_rebase() {
62
42
  actions.output(<Ink.Text color={colors.red}>🚨 Abort</Ink.Text>);
63
- handle_exit(15);
64
- };
43
+ handle_exit();
44
+ return 15;
45
+ });
65
46
 
66
47
  const temp_branch_name = `${branch_name}_${short_id()}`;
67
48
 
@@ -137,6 +118,8 @@ async function run(args: Args) {
137
118
 
138
119
  restore_git();
139
120
 
121
+ actions.unregister_abort_handler();
122
+
140
123
  if (argv.sync) {
141
124
  actions.set((state) => {
142
125
  state.step = "sync-github";
@@ -157,7 +140,8 @@ async function run(args: Args) {
157
140
  actions.error("Try again with `--verbose` to see more information.");
158
141
  }
159
142
 
160
- handle_exit(16);
143
+ handle_exit();
144
+ actions.exit(16);
161
145
  }
162
146
 
163
147
  // cleanup git operations if cancelled during manual rebase
@@ -185,7 +169,7 @@ async function run(args: Args) {
185
169
  cli.sync(`pwd`, spawn_options);
186
170
  }
187
171
 
188
- function handle_exit(code: number) {
172
+ function handle_exit() {
189
173
  actions.output(
190
174
  <Ink.Text color={colors.yellow}>
191
175
  Restoring <Brackets>{branch_name}</Brackets>…
@@ -199,7 +183,5 @@ async function run(args: Args) {
199
183
  Restored <Brackets>{branch_name}</Brackets>.
200
184
  </Ink.Text>
201
185
  );
202
-
203
- actions.exit(code);
204
186
  }
205
187
  }
@@ -387,8 +387,21 @@ function SelectCommitRangesInternal(props: Props) {
387
387
  </Ink.Box>
388
388
  );
389
389
 
390
+ function get_group_id() {
391
+ let branch_prefix = "";
392
+
393
+ // branch prefix via cli flag or env var
394
+ // cli flag takes precedence since it is more explicit
395
+ if (argv["branch-prefix"]) {
396
+ branch_prefix = argv["branch-prefix"];
397
+ } else if (process.env.GIT_STACK_BRANCH_PREFIX) {
398
+ branch_prefix = process.env.GIT_STACK_BRANCH_PREFIX;
399
+ }
400
+
401
+ return `${branch_prefix}${gs_short_id()}`;
402
+ }
390
403
  function submit_group_input(title: string) {
391
- const id = gs_short_id();
404
+ const id = get_group_id();
392
405
 
393
406
  actions.output(
394
407
  <FormatText
package/src/app/Store.tsx CHANGED
@@ -7,6 +7,7 @@ import { immer } from "zustand/middleware/immer";
7
7
  import { Exit } from "~/app/Exit";
8
8
  import { LogTimestamp } from "~/app/LogTimestamp";
9
9
  import { colors } from "~/core/colors";
10
+ import { pretty_json } from "~/core/pretty_json";
10
11
 
11
12
  import type { Instance as InkInstance } from "ink-cjs";
12
13
  import type { Argv } from "~/command";
@@ -29,6 +30,9 @@ type SyncGithubState = {
29
30
  rebase_group_index: number;
30
31
  };
31
32
 
33
+ // async function that returns exit code
34
+ type AbortHandler = () => Promise<number>;
35
+
32
36
  export type State = {
33
37
  // set immediately in `index.tsx` so no `null` scenario
34
38
  process_argv: Array<string>;
@@ -47,6 +51,8 @@ export type State = {
47
51
  pr_templates: Array<string>;
48
52
  pr_template_body: null | string;
49
53
  sync_github: null | SyncGithubState;
54
+ is_dirty_check_stash: boolean;
55
+ abort_handler: null | AbortHandler;
50
56
 
51
57
  step:
52
58
  | "github-api-error"
@@ -71,7 +77,7 @@ export type State = {
71
77
  clear(): void;
72
78
  unmount(): void;
73
79
  newline(): void;
74
- json(value: object): void;
80
+ json(value: pretty_json.JSONValue): void;
75
81
  error(message: string): void;
76
82
  output(node: React.ReactNode): void;
77
83
  debug(node: React.ReactNode, id?: string): void;
@@ -79,6 +85,8 @@ export type State = {
79
85
  isDebug(): boolean;
80
86
 
81
87
  reset_pr(): void;
88
+ register_abort_handler(abort_handler: AbortHandler): void;
89
+ unregister_abort_handler(): void;
82
90
 
83
91
  set(setter: Setter): void;
84
92
  };
@@ -113,6 +121,8 @@ const BaseStore = createStore<State>()(
113
121
  pr_templates: [],
114
122
  pr_template_body: null,
115
123
  sync_github: null,
124
+ is_dirty_check_stash: false,
125
+ abort_handler: null,
116
126
 
117
127
  step: "loading",
118
128
 
@@ -146,7 +156,7 @@ const BaseStore = createStore<State>()(
146
156
 
147
157
  json(value) {
148
158
  set((state) => {
149
- const node = JSON.stringify(value, null, 2);
159
+ const node = pretty_json(value);
150
160
  state.mutate.output(state, { node });
151
161
  });
152
162
  },
@@ -189,6 +199,18 @@ const BaseStore = createStore<State>()(
189
199
  });
190
200
  },
191
201
 
202
+ register_abort_handler(abort_handler) {
203
+ set((state) => {
204
+ state.abort_handler = abort_handler;
205
+ });
206
+ },
207
+
208
+ unregister_abort_handler() {
209
+ set((state) => {
210
+ state.abort_handler = null;
211
+ });
212
+ },
213
+
192
214
  set(setter) {
193
215
  set((state) => {
194
216
  setter(state);
@@ -14,35 +14,15 @@ import { invariant } from "~/core/invariant";
14
14
  import type * as CommitMetadata from "~/core/CommitMetadata";
15
15
 
16
16
  export function SyncGithub() {
17
- const abort_handler = React.useRef(() => {});
18
-
19
- React.useEffect(function listen_sigint() {
20
- process.once("SIGINT", sigint_handler);
21
-
22
- return function cleanup() {
23
- process.removeListener("SIGINT", sigint_handler);
24
- };
25
-
26
- function sigint_handler() {
27
- abort_handler.current();
28
- }
29
- }, []);
30
-
31
17
  return (
32
18
  <Await
33
19
  fallback={<Ink.Text color={colors.yellow}>Syncing…</Ink.Text>}
34
- function={async function () {
35
- await run({ abort_handler });
36
- }}
20
+ function={run}
37
21
  />
38
22
  );
39
23
  }
40
24
 
41
- type Args = {
42
- abort_handler: React.MutableRefObject<() => void>;
43
- };
44
-
45
- async function run(args: Args) {
25
+ async function run() {
46
26
  const state = Store.getState();
47
27
  const actions = state.actions;
48
28
  const argv = state.argv;
@@ -60,11 +40,12 @@ async function run(args: Args) {
60
40
  const commit_range = sync_github.commit_range;
61
41
  const rebase_group_index = sync_github.rebase_group_index;
62
42
 
63
- // always listen for SIGINT event and restore pr state
64
- args.abort_handler.current = function sigint_handler() {
43
+ // immediately register abort_handler in case of ctrl+c exit
44
+ actions.register_abort_handler(async function abort_sync_github() {
65
45
  actions.output(<Ink.Text color={colors.red}>🚨 Abort</Ink.Text>);
66
- handle_exit(17);
67
- };
46
+ handle_exit();
47
+ return 17;
48
+ });
68
49
 
69
50
  let DEFAULT_PR_BODY = "";
70
51
  if (state.pr_template_body) {
@@ -151,6 +132,8 @@ async function run(args: Args) {
151
132
 
152
133
  await Promise.all(update_pr_body_tasks);
153
134
 
135
+ actions.unregister_abort_handler();
136
+
154
137
  actions.set((state) => {
155
138
  state.step = "post-rebase-status";
156
139
  });
@@ -164,7 +147,8 @@ async function run(args: Args) {
164
147
  actions.error("Try again with `--verbose` to see more information.");
165
148
  }
166
149
 
167
- await handle_exit(18);
150
+ handle_exit();
151
+ actions.exit(18);
168
152
  }
169
153
 
170
154
  function get_push_group_list() {
@@ -305,7 +289,7 @@ async function run(args: Args) {
305
289
  }
306
290
  }
307
291
 
308
- function handle_exit(code: number) {
292
+ function handle_exit() {
309
293
  actions.output(
310
294
  <Ink.Text color={colors.yellow}>Restoring PR state…</Ink.Text>
311
295
  );
@@ -329,8 +313,6 @@ async function run(args: Args) {
329
313
  actions.output(
330
314
  <Ink.Text color={colors.yellow}>Restored PR state.</Ink.Text>
331
315
  );
332
-
333
- actions.exit(code);
334
316
  }
335
317
  }
336
318
 
package/src/command.ts CHANGED
@@ -111,11 +111,10 @@ const DefaultOptions = {
111
111
  description: "Open all PRs as drafts",
112
112
  },
113
113
 
114
- "write-state-json": {
115
- hidden: true,
116
- type: "boolean",
117
- default: false,
118
- description: "Write state to local json file for debugging",
114
+ "branch-prefix": {
115
+ type: "string",
116
+ default: "",
117
+ description: "Prefix for generated branch names, e.g. dev/magus/",
119
118
  },
120
119
 
121
120
  "template": {
@@ -125,6 +124,13 @@ const DefaultOptions = {
125
124
  "Use automatic Github PR template, e.g. .github/pull_request_template.md, disable with --no-template",
126
125
  },
127
126
 
127
+ "write-state-json": {
128
+ hidden: true,
129
+ type: "boolean",
130
+ default: false,
131
+ description: "Write state to local json file for debugging",
132
+ },
133
+
128
134
  "mock-metadata": {
129
135
  hidden: true,
130
136
  type: "boolean",
@@ -97,7 +97,7 @@ async function run() {
97
97
  if (diff_cmd.code) {
98
98
  save_stash = true;
99
99
 
100
- await cli("git stash -q");
100
+ await cli("git stash --include-untracked");
101
101
 
102
102
  actions.output(<Ink.Text>📦 Changes saved to stash</Ink.Text>);
103
103
  }
@@ -118,7 +118,7 @@ async function run() {
118
118
  await cli("git reset --soft HEAD~1");
119
119
  } finally {
120
120
  if (save_stash) {
121
- await cli("git stash pop -q");
121
+ await cli("git stash pop");
122
122
 
123
123
  actions.output(
124
124
  <Ink.Text color={colors.green}>✅ Changes restored from stash</Ink.Text>
@@ -16,35 +16,15 @@ import { invariant } from "~/core/invariant";
16
16
  import { short_id } from "~/core/short_id";
17
17
 
18
18
  export function Rebase() {
19
- const abort_handler = React.useRef(() => {});
20
-
21
- React.useEffect(function listen_sigint() {
22
- process.once("SIGINT", sigint_handler);
23
-
24
- return function cleanup() {
25
- process.removeListener("SIGINT", sigint_handler);
26
- };
27
-
28
- function sigint_handler() {
29
- abort_handler.current();
30
- }
31
- }, []);
32
-
33
19
  return (
34
20
  <Await
35
21
  fallback={<Ink.Text color={colors.yellow}>Rebasing commits…</Ink.Text>}
36
- function={async function () {
37
- await Rebase.run({ abort_handler });
38
- }}
22
+ function={Rebase.run}
39
23
  />
40
24
  );
41
25
  }
42
26
 
43
- type Args = {
44
- abort_handler: React.MutableRefObject<() => void>;
45
- };
46
-
47
- Rebase.run = async function run(args: Args) {
27
+ Rebase.run = async function run() {
48
28
  const state = Store.getState();
49
29
  const actions = state.actions;
50
30
  const branch_name = state.branch_name;
@@ -57,11 +37,12 @@ Rebase.run = async function run(args: Args) {
57
37
  invariant(commit_range, "commit_range must exist");
58
38
  invariant(repo_root, "repo_root must exist");
59
39
 
60
- // always listen for SIGINT event and restore git state
61
- args.abort_handler.current = async function sigint_handler() {
40
+ // immediately register abort_handler in case of ctrl+c exit
41
+ actions.register_abort_handler(async function abort_rebase() {
62
42
  actions.output(<Ink.Text color={colors.red}>🚨 Abort</Ink.Text>);
63
- handle_exit(19);
64
- };
43
+ handle_exit();
44
+ return 19;
45
+ });
65
46
 
66
47
  const temp_branch_name = `${branch_name}_${short_id()}`;
67
48
 
@@ -153,6 +134,8 @@ Rebase.run = async function run(args: Args) {
153
134
  />
154
135
  );
155
136
 
137
+ actions.unregister_abort_handler();
138
+
156
139
  actions.set((state) => {
157
140
  state.commit_range = next_commit_range;
158
141
  state.step = "status";
@@ -166,7 +149,8 @@ Rebase.run = async function run(args: Args) {
166
149
  }
167
150
  }
168
151
 
169
- handle_exit(20);
152
+ handle_exit();
153
+ actions.exit(20);
170
154
  }
171
155
 
172
156
  // cleanup git operations if cancelled during manual rebase
@@ -194,7 +178,7 @@ Rebase.run = async function run(args: Args) {
194
178
  cli.sync(`pwd`, spawn_options);
195
179
  }
196
180
 
197
- function handle_exit(code: number) {
181
+ function handle_exit() {
198
182
  actions.output(
199
183
  <Ink.Text color={colors.yellow}>
200
184
  Restoring <Brackets>{branch_name}</Brackets>…
@@ -208,7 +192,5 @@ Rebase.run = async function run(args: Args) {
208
192
  Restored <Brackets>{branch_name}</Brackets>.
209
193
  </Ink.Text>
210
194
  );
211
-
212
- actions.exit(code);
213
195
  }
214
196
  };