git-stack-cli 1.9.0 → 1.11.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.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "",
5
5
  "author": "magus",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "chalk": "^5.3.0",
43
43
  "immer": "^10.0.3",
44
44
  "ink-cjs": "4.4.1",
45
+ "lodash": "^4.17.21",
45
46
  "luxon": "^3.4.4",
46
47
  "react": "^18.2.0",
47
48
  "react-devtools-core": "^4.19.1",
@@ -57,6 +58,7 @@
57
58
  "@rollup/plugin-replace": "^5.0.5",
58
59
  "@rollup/plugin-typescript": "^11.1.6",
59
60
  "@types/chalk": "^2.2.0",
61
+ "@types/lodash": "^4.17.7",
60
62
  "@types/luxon": "^3.4.2",
61
63
  "@types/node": "^20.8.7",
62
64
  "@types/react": "^18.2.33",
package/src/app/App.tsx CHANGED
@@ -4,6 +4,7 @@ import { AutoUpdate } from "~/app/AutoUpdate";
4
4
  import { CherryPickCheck } from "~/app/CherryPickCheck";
5
5
  import { Debug } from "~/app/Debug";
6
6
  import { DependencyCheck } from "~/app/DependencyCheck";
7
+ import { DetectInitialPR } from "~/app/DetectInitialPR";
7
8
  import { DirtyCheck } from "~/app/DirtyCheck";
8
9
  import { GatherMetadata } from "~/app/GatherMetadata";
9
10
  import { GithubApiError } from "~/app/GithubApiError";
@@ -15,6 +16,7 @@ import { RebaseCheck } from "~/app/RebaseCheck";
15
16
  import { Store } from "~/app/Store";
16
17
  import { Fixup } from "~/commands/Fixup";
17
18
  import { Log } from "~/commands/Log";
19
+ import { Rebase } from "~/commands/Rebase";
18
20
 
19
21
  export function App() {
20
22
  const actions = Store.useActions();
@@ -72,6 +74,14 @@ function MaybeMain() {
72
74
  return <Fixup />;
73
75
  } else if (positional_list.has("log")) {
74
76
  return <Log />;
77
+ } else if (positional_list.has("rebase")) {
78
+ return (
79
+ <GatherMetadata>
80
+ <LocalCommitStatus>
81
+ <Rebase />
82
+ </LocalCommitStatus>
83
+ </GatherMetadata>
84
+ );
75
85
  }
76
86
 
77
87
  return (
@@ -80,7 +90,9 @@ function MaybeMain() {
80
90
 
81
91
  <GatherMetadata>
82
92
  <LocalCommitStatus>
83
- <Main />
93
+ <DetectInitialPR>
94
+ <Main />
95
+ </DetectInitialPR>
84
96
  </LocalCommitStatus>
85
97
  </GatherMetadata>
86
98
  </DirtyCheck>
@@ -57,17 +57,17 @@ export function CherryPickCheck(props: Props) {
57
57
  default:
58
58
  return (
59
59
  <Await
60
+ function={run}
60
61
  fallback={
61
62
  <Ink.Text color={colors.yellow}>
62
63
  Checking for <Command>git cherry-pick</Command>…
63
64
  </Ink.Text>
64
65
  }
65
- function={cherry_pick_check}
66
66
  />
67
67
  );
68
68
  }
69
69
 
70
- async function cherry_pick_check() {
70
+ async function run() {
71
71
  const actions = Store.getState().actions;
72
72
 
73
73
  try {
@@ -88,7 +88,7 @@ export function CherryPickCheck(props: Props) {
88
88
  }
89
89
  }
90
90
 
91
- actions.exit(9);
91
+ actions.exit(11);
92
92
  }
93
93
  }
94
94
  }
@@ -0,0 +1,189 @@
1
+ import * as React from "react";
2
+
3
+ import * as Ink from "ink-cjs";
4
+ import cloneDeep from "lodash/cloneDeep";
5
+
6
+ import { Await } from "~/app/Await";
7
+ import { Brackets } from "~/app/Brackets";
8
+ import { Command } from "~/app/Command";
9
+ import { FormatText } from "~/app/FormatText";
10
+ import { Store } from "~/app/Store";
11
+ import { Url } from "~/app/Url";
12
+ import { YesNoPrompt } from "~/app/YesNoPrompt";
13
+ import * as CommitMetadata from "~/core/CommitMetadata";
14
+ import { GitReviseTodo } from "~/core/GitReviseTodo";
15
+ import { cli } from "~/core/cli";
16
+ import { colors } from "~/core/colors";
17
+ import * as github from "~/core/github";
18
+ import { invariant } from "~/core/invariant";
19
+
20
+ type Props = {
21
+ children: React.ReactNode;
22
+ };
23
+
24
+ type PullRequest = NonNullable<Awaited<ReturnType<typeof github.pr_status>>>;
25
+
26
+ type State = {
27
+ status: "init" | "prompt" | "revise" | "done";
28
+ pr: null | PullRequest;
29
+ };
30
+
31
+ function reducer(state: State, patch: Partial<State>) {
32
+ return { ...state, ...patch };
33
+ }
34
+
35
+ export function DetectInitialPR(props: Props) {
36
+ const actions = Store.useActions();
37
+ const branch_name = Store.useState((state) => state.branch_name);
38
+
39
+ const [state, patch] = React.useReducer(reducer, {
40
+ status: "init",
41
+ pr: null,
42
+ });
43
+
44
+ switch (state.status) {
45
+ case "done":
46
+ return props.children;
47
+
48
+ case "revise":
49
+ return (
50
+ <Await
51
+ function={run_revise}
52
+ fallback={
53
+ <Ink.Text color={colors.yellow}>
54
+ Synchronizing local commit metadata with remote branch on Github…
55
+ </Ink.Text>
56
+ }
57
+ />
58
+ );
59
+
60
+ case "prompt":
61
+ return (
62
+ <YesNoPrompt
63
+ message={
64
+ <Ink.Box flexDirection="column">
65
+ <FormatText
66
+ wrapper={<Ink.Text color={colors.yellow} />}
67
+ message="{branch_name} exists on Github and was not generated with {git_stack}."
68
+ values={{
69
+ branch_name: <Brackets>{branch_name}</Brackets>,
70
+ git_stack: <Command>git stack</Command>,
71
+ }}
72
+ />
73
+ <Ink.Text> </Ink.Text>
74
+ <FormatText
75
+ message=" {url}"
76
+ values={{
77
+ url: <Url>{state.pr?.url}</Url>,
78
+ }}
79
+ />
80
+ <Ink.Text> </Ink.Text>
81
+ <FormatText
82
+ wrapper={<Ink.Text color={colors.yellow} />}
83
+ message="In order to synchronize we need to rename your local branch, would you like to proceed?"
84
+ />
85
+ </Ink.Box>
86
+ }
87
+ onYes={async () => {
88
+ patch({ status: "revise" });
89
+ }}
90
+ onNo={async () => {
91
+ actions.exit(0);
92
+ }}
93
+ />
94
+ );
95
+
96
+ default:
97
+ return (
98
+ <Await
99
+ function={run}
100
+ fallback={
101
+ <Ink.Text color={colors.yellow}>
102
+ Checking for existing PR on Github…
103
+ </Ink.Text>
104
+ }
105
+ />
106
+ );
107
+ }
108
+
109
+ async function run() {
110
+ const actions = Store.getState().actions;
111
+ const branch_name = Store.getState().branch_name;
112
+ const commit_range = Store.getState().commit_range;
113
+
114
+ invariant(branch_name, "branch_name must exist");
115
+ invariant(commit_range, "branch_name must exist");
116
+
117
+ try {
118
+ let has_existing_metadata = false;
119
+ for (const commit of commit_range.commit_list) {
120
+ if (commit.branch_id) {
121
+ has_existing_metadata = true;
122
+ break;
123
+ }
124
+ }
125
+
126
+ if (!has_existing_metadata) {
127
+ // check for pr with matching branch name to initialize group
128
+ const pr = await github.pr_status(branch_name);
129
+ if (pr) {
130
+ return patch({ status: "prompt", pr });
131
+ }
132
+ }
133
+
134
+ patch({ status: "done" });
135
+ } catch (err) {
136
+ actions.error("Must be run from within a git repository.");
137
+
138
+ if (err instanceof Error) {
139
+ if (actions.isDebug()) {
140
+ actions.error(err.message);
141
+ }
142
+ }
143
+
144
+ actions.exit(9);
145
+ }
146
+ }
147
+
148
+ async function run_revise() {
149
+ const actions = Store.getState().actions;
150
+ const master_branch = Store.getState().master_branch;
151
+ const branch_name = Store.getState().branch_name;
152
+ const commit_range = cloneDeep(Store.getState().commit_range);
153
+
154
+ invariant(branch_name, "branch_name must exist");
155
+ invariant(commit_range, "branch_name must exist");
156
+
157
+ for (const group of commit_range.group_list) {
158
+ group.id = branch_name;
159
+ group.title = state.pr?.title || "-";
160
+ }
161
+
162
+ // get latest merge_base relative to local master
163
+ const rebase_group_index = 0;
164
+
165
+ const rebase_merge_base = (
166
+ await cli(`git merge-base HEAD ${master_branch}`)
167
+ ).stdout;
168
+
169
+ await GitReviseTodo.execute({
170
+ rebase_group_index,
171
+ rebase_merge_base,
172
+ commit_range,
173
+ });
174
+
175
+ const new_branch_name = `${branch_name}-sync`;
176
+ await cli(`git checkout -b ${new_branch_name}`);
177
+
178
+ await cli(`git branch -D ${branch_name}`);
179
+
180
+ const commit_range_new = await CommitMetadata.range();
181
+
182
+ actions.set((state) => {
183
+ state.branch_name = new_branch_name;
184
+ state.commit_range = commit_range_new;
185
+ });
186
+
187
+ patch({ status: "done" });
188
+ }
189
+ }
@@ -68,17 +68,17 @@ export function DirtyCheck(props: Props) {
68
68
  default:
69
69
  return (
70
70
  <Await
71
+ function={run}
71
72
  fallback={
72
73
  <Ink.Text color={colors.yellow}>
73
74
  Ensuring <Command>git status --porcelain</Command>…
74
75
  </Ink.Text>
75
76
  }
76
- function={rebase_check}
77
77
  />
78
78
  );
79
79
  }
80
80
 
81
- async function rebase_check() {
81
+ async function run() {
82
82
  const actions = Store.getState().actions;
83
83
 
84
84
  try {
@@ -95,7 +95,7 @@ export function DirtyCheck(props: Props) {
95
95
  }
96
96
  }
97
97
 
98
- actions.exit(9);
98
+ actions.exit(12);
99
99
  }
100
100
  }
101
101
  }
@@ -5,7 +5,7 @@ import { FormattedMessage } from "react-intl";
5
5
 
6
6
  type Props = {
7
7
  message: string;
8
- values: React.ComponentProps<typeof FormattedMessage>["values"];
8
+ values?: React.ComponentProps<typeof FormattedMessage>["values"];
9
9
  wrapper?: React.ReactNode;
10
10
  };
11
11
 
@@ -20,13 +20,13 @@ export function GatherMetadata(props: Props) {
20
20
  );
21
21
 
22
22
  return (
23
- <Await fallback={fallback} function={gather_metadata}>
23
+ <Await fallback={fallback} function={run}>
24
24
  {props.children}
25
25
  </Await>
26
26
  );
27
27
  }
28
28
 
29
- async function gather_metadata() {
29
+ async function run() {
30
30
  const actions = Store.getState().actions;
31
31
  const argv = Store.getState().argv;
32
32
 
@@ -28,7 +28,7 @@ export function LocalCommitStatus(props: Props) {
28
28
  }
29
29
 
30
30
  return (
31
- <Await fallback={fallback} function={gather_metadata}>
31
+ <Await fallback={fallback} function={run}>
32
32
  {props.children}
33
33
  </Await>
34
34
  );
@@ -41,12 +41,11 @@ async function mock_metadata() {
41
41
 
42
42
  Store.setState((state) => {
43
43
  Object.assign(state, deserialized);
44
-
45
44
  state.step = "status";
46
45
  });
47
46
  }
48
47
 
49
- async function gather_metadata() {
48
+ async function run() {
50
49
  const actions = Store.getState().actions;
51
50
 
52
51
  try {
@@ -1,193 +1,7 @@
1
1
  import * as React from "react";
2
2
 
3
- import fs from "node:fs";
4
-
5
- import * as Ink from "ink-cjs";
6
-
7
- import { Await } from "~/app/Await";
8
- import { Brackets } from "~/app/Brackets";
9
- import { FormatText } from "~/app/FormatText";
10
- import { Parens } from "~/app/Parens";
11
- import { Store } from "~/app/Store";
12
- import * as CommitMetadata from "~/core/CommitMetadata";
13
- import { cli } from "~/core/cli";
14
- import { colors } from "~/core/colors";
15
- import { invariant } from "~/core/invariant";
16
- import { short_id } from "~/core/short_id";
3
+ import { Rebase } from "~/commands/Rebase";
17
4
 
18
5
  export function LocalMergeRebase() {
19
- return (
20
- <Await
21
- fallback={<Ink.Text color={colors.yellow}>Rebasing commits…</Ink.Text>}
22
- function={run}
23
- />
24
- );
25
- }
26
-
27
- async function run() {
28
- const state = Store.getState();
29
- const actions = state.actions;
30
- const branch_name = state.branch_name;
31
- const commit_range = state.commit_range;
32
- const master_branch = state.master_branch;
33
- const cwd = state.cwd;
34
- const repo_root = state.repo_root;
35
-
36
- invariant(branch_name, "branch_name must exist");
37
- invariant(commit_range, "commit_range must exist");
38
- invariant(repo_root, "repo_root must exist");
39
-
40
- // always listen for SIGINT event and restore git state
41
- process.once("SIGINT", handle_exit);
42
-
43
- const temp_branch_name = `${branch_name}_${short_id()}`;
44
-
45
- try {
46
- // actions.debug(`commit_range=${JSON.stringify(commit_range, null, 2)}`);
47
-
48
- // must perform rebase from repo root for applying git patch
49
- process.chdir(repo_root);
50
- await cli(`pwd`);
51
-
52
- // update local master to match remote
53
- await cli(
54
- `git fetch --no-tags -v origin ${master_branch}:${master_branch}`
55
- );
56
-
57
- const master_sha = (await cli(`git rev-parse ${master_branch}`)).stdout;
58
- const rebase_merge_base = master_sha;
59
-
60
- // create temporary branch based on merge base
61
- await cli(`git checkout -b ${temp_branch_name} ${rebase_merge_base}`);
62
-
63
- const picked_commit_list = [];
64
-
65
- for (let i = 0; i < commit_range.commit_list.length; i++) {
66
- const commit = commit_range.commit_list[i];
67
- const commit_pr = commit_range.pr_lookup[commit.branch_id || ""];
68
-
69
- // drop commits that are in groups of merged PRs
70
- const merged_pr = commit_pr?.state === "MERGED";
71
-
72
- if (merged_pr) {
73
- if (actions.isDebug()) {
74
- actions.output(
75
- <FormatText
76
- wrapper={<Ink.Text color={colors.yellow} wrap="truncate-end" />}
77
- message="Dropping {commit_message} {pr_status}"
78
- values={{
79
- commit_message: <Brackets>{commit.subject_line}</Brackets>,
80
- pr_status: <Parens>MERGED</Parens>,
81
- }}
82
- />
83
- );
84
- }
85
-
86
- continue;
87
- }
88
-
89
- if (actions.isDebug()) {
90
- actions.output(
91
- <FormatText
92
- wrapper={<Ink.Text color={colors.yellow} wrap="truncate-end" />}
93
- message="Picking {commit_message}"
94
- values={{
95
- commit_message: <Brackets>{commit.subject_line}</Brackets>,
96
- }}
97
- />
98
- );
99
- }
100
-
101
- picked_commit_list.push(commit);
102
- }
103
-
104
- if (picked_commit_list.length > 0) {
105
- // ensure clean base to avoid conflicts when applying patch
106
- await cli(`git clean -fd`);
107
-
108
- // create list of sha for cherry-pick
109
- const sha_list = picked_commit_list.map((commit) => commit.sha).join(" ");
110
-
111
- await cli(`git cherry-pick --keep-redundant-commits ${sha_list}`);
112
- }
113
-
114
- // after all commits have been cherry-picked and amended
115
- // move the branch pointer to the newly created temporary branch
116
- // now we are locally in sync with github and on the original branch
117
- await cli(`git branch -f ${branch_name} ${temp_branch_name}`);
118
-
119
- restore_git();
120
-
121
- const next_commit_range = await CommitMetadata.range();
122
-
123
- actions.set((state) => {
124
- state.commit_range = next_commit_range;
125
- state.step = "status";
126
- });
127
- } catch (err) {
128
- actions.error("Unable to rebase.");
129
-
130
- if (err instanceof Error) {
131
- if (actions.isDebug()) {
132
- actions.error(err.message);
133
- }
134
- }
135
-
136
- handle_exit();
137
- }
138
-
139
- // cleanup git operations if cancelled during manual rebase
140
- function restore_git() {
141
- // signint handler MUST run synchronously
142
- // trying to use `await cli(...)` here will silently fail since
143
- // all children processes receive the SIGINT signal
144
- const spawn_options = { ignoreExitCode: true };
145
-
146
- // always clean up any patch files
147
- cli.sync(`rm ${PATCH_FILE}`, spawn_options);
148
-
149
- // always hard reset and clean to allow subsequent checkout
150
- // if there are files checkout will fail and cascade fail subsequent commands
151
- cli.sync(`git reset --hard`, spawn_options);
152
- cli.sync(`git clean -df`, spawn_options);
153
-
154
- // always put self back in original branch
155
- cli.sync(`git checkout ${branch_name}`, spawn_options);
156
-
157
- // ...and cleanup temporary branch
158
- cli.sync(`git branch -D ${temp_branch_name}`, spawn_options);
159
-
160
- if (commit_range) {
161
- // ...and cleanup pr group branches
162
- for (const group of commit_range.group_list) {
163
- cli.sync(`git branch -D ${group.id}`, spawn_options);
164
- }
165
- }
166
-
167
- // restore back to original dir
168
- if (fs.existsSync(cwd)) {
169
- process.chdir(cwd);
170
- }
171
- cli.sync(`pwd`, spawn_options);
172
- }
173
-
174
- function handle_exit() {
175
- actions.output(
176
- <Ink.Text color={colors.yellow}>
177
- Restoring <Brackets>{branch_name}</Brackets>…
178
- </Ink.Text>
179
- );
180
-
181
- restore_git();
182
-
183
- actions.output(
184
- <Ink.Text color={colors.yellow}>
185
- Restored <Brackets>{branch_name}</Brackets>.
186
- </Ink.Text>
187
- );
188
-
189
- actions.exit(6);
190
- }
6
+ return <Rebase />;
191
7
  }
192
-
193
- const PATCH_FILE = "git-stack-cli-patch.patch";
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
7
  import * as Ink from "ink-cjs";
8
+ import cloneDeep from "lodash/cloneDeep";
8
9
 
9
10
  import { Await } from "~/app/Await";
10
11
  import { Brackets } from "~/app/Brackets";
@@ -34,18 +35,37 @@ async function run() {
34
35
  const actions = state.actions;
35
36
  const argv = state.argv;
36
37
  const branch_name = state.branch_name;
38
+ const original_commit_range = cloneDeep(state.commit_range);
37
39
  const commit_map = state.commit_map;
38
40
  const master_branch = state.master_branch;
39
41
  const cwd = state.cwd;
40
42
  const repo_root = state.repo_root;
41
43
 
42
44
  invariant(branch_name, "branch_name must exist");
45
+ invariant(original_commit_range, "original_commit_range must exist");
43
46
  invariant(commit_map, "commit_map must exist");
44
47
  invariant(repo_root, "repo_root must exist");
45
48
 
46
49
  // always listen for SIGINT event and restore git state
47
50
  process.once("SIGINT", handle_exit);
48
51
 
52
+ // get latest merge_base relative to local master
53
+ const merge_base = (await cli(`git merge-base HEAD ${master_branch}`)).stdout;
54
+
55
+ // immediately paint all commit to preserve selected commit ranges
56
+ original_commit_range.group_list.reverse();
57
+ for (const commit of original_commit_range.commit_list) {
58
+ const group_from_map = commit_map[commit.sha];
59
+ commit.branch_id = group_from_map.id;
60
+ commit.title = group_from_map.title;
61
+ }
62
+
63
+ await GitReviseTodo.execute({
64
+ rebase_group_index: 0,
65
+ rebase_merge_base: merge_base,
66
+ commit_range: original_commit_range,
67
+ });
68
+
49
69
  let DEFAULT_PR_BODY = "";
50
70
  if (state.pr_template_body) {
51
71
  DEFAULT_PR_BODY = state.pr_template_body;
@@ -58,9 +78,6 @@ async function run() {
58
78
  // reverse commit list so that we can cherry-pick in order
59
79
  commit_range.group_list.reverse();
60
80
 
61
- // get latest merge_base relative to local master
62
- const merge_base = (await cli(`git merge-base HEAD ${master_branch}`)).stdout;
63
-
64
81
  let rebase_merge_base = merge_base;
65
82
  let rebase_group_index = 0;
66
83
 
@@ -150,19 +167,11 @@ async function run() {
150
167
  // create temporary branch
151
168
  await cli(`git checkout -b ${temp_branch_name}`);
152
169
 
153
- const git_revise_todo = GitReviseTodo({ rebase_group_index, commit_range });
154
-
155
- // execute cli with temporary git sequence editor script
156
- // revise from merge base to pick correct commits
157
- await cli(
158
- [
159
- `GIT_EDITOR="${tmp_git_sequence_editor_path}"`,
160
- `GIT_REVISE_TODO="${git_revise_todo}"`,
161
- `git`,
162
- `revise --edit -i ${rebase_merge_base}`,
163
- ],
164
- { stdio: ["ignore", "ignore", "ignore"] }
165
- );
170
+ await GitReviseTodo.execute({
171
+ rebase_group_index,
172
+ rebase_merge_base,
173
+ commit_range,
174
+ });
166
175
 
167
176
  // early return since we do not need to sync
168
177
  if (!argv.sync) {
@@ -57,17 +57,17 @@ export function RebaseCheck(props: Props) {
57
57
  default:
58
58
  return (
59
59
  <Await
60
+ function={run}
60
61
  fallback={
61
62
  <Ink.Text color={colors.yellow}>
62
63
  Checking for <Command>git rebase</Command>…
63
64
  </Ink.Text>
64
65
  }
65
- function={rebase_check}
66
66
  />
67
67
  );
68
68
  }
69
69
 
70
- async function rebase_check() {
70
+ async function run() {
71
71
  const actions = Store.getState().actions;
72
72
 
73
73
  try {
@@ -88,7 +88,7 @@ export function RebaseCheck(props: Props) {
88
88
  }
89
89
  }
90
90
 
91
- actions.exit(9);
91
+ actions.exit(13);
92
92
  }
93
93
  }
94
94
  }
@@ -9,8 +9,8 @@ import { Parens } from "~/app/Parens";
9
9
  import { Store } from "~/app/Store";
10
10
  import { TextInput } from "~/app/TextInput";
11
11
  import { colors } from "~/core/colors";
12
+ import { gs_short_id } from "~/core/gs_short_id";
12
13
  import { invariant } from "~/core/invariant";
13
- import { short_id } from "~/core/short_id";
14
14
  import { wrap_index } from "~/core/wrap_index";
15
15
 
16
16
  import type { State } from "~/app/Store";
@@ -388,7 +388,7 @@ function SelectCommitRangesInternal(props: Props) {
388
388
  );
389
389
 
390
390
  function submit_group_input(title: string) {
391
- const id = `gs-${short_id()}`;
391
+ const id = gs_short_id();
392
392
 
393
393
  actions.output(
394
394
  <FormatText