git-stack-cli 1.13.0 → 1.13.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.
@@ -0,0 +1,338 @@
1
+ import * as React from "react";
2
+
3
+ import * as Ink from "ink-cjs";
4
+ import last from "lodash/last";
5
+
6
+ import { Await } from "~/app/Await";
7
+ import { Store } from "~/app/Store";
8
+ import * as StackSummaryTable from "~/core/StackSummaryTable";
9
+ import { cli } from "~/core/cli";
10
+ import { colors } from "~/core/colors";
11
+ import * as github from "~/core/github";
12
+ import { invariant } from "~/core/invariant";
13
+
14
+ import type * as CommitMetadata from "~/core/CommitMetadata";
15
+
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
+ return (
32
+ <Await
33
+ fallback={<Ink.Text color={colors.yellow}>Syncing…</Ink.Text>}
34
+ function={async function () {
35
+ await run({ abort_handler });
36
+ }}
37
+ />
38
+ );
39
+ }
40
+
41
+ type Args = {
42
+ abort_handler: React.MutableRefObject<() => void>;
43
+ };
44
+
45
+ async function run(args: Args) {
46
+ const state = Store.getState();
47
+ const actions = state.actions;
48
+ const argv = state.argv;
49
+ const branch_name = state.branch_name;
50
+ const commit_map = state.commit_map;
51
+ const master_branch = state.master_branch;
52
+ const repo_root = state.repo_root;
53
+ const sync_github = state.sync_github;
54
+
55
+ invariant(branch_name, "branch_name must exist");
56
+ invariant(commit_map, "commit_map must exist");
57
+ invariant(repo_root, "repo_root must exist");
58
+ invariant(sync_github, "sync_github must exist");
59
+
60
+ const commit_range = sync_github.commit_range;
61
+ const rebase_group_index = sync_github.rebase_group_index;
62
+
63
+ // always listen for SIGINT event and restore pr state
64
+ args.abort_handler.current = function sigint_handler() {
65
+ actions.output(<Ink.Text color={colors.red}>🚨 Abort</Ink.Text>);
66
+ handle_exit(17);
67
+ };
68
+
69
+ let DEFAULT_PR_BODY = "";
70
+ if (state.pr_template_body) {
71
+ DEFAULT_PR_BODY = state.pr_template_body;
72
+ }
73
+
74
+ const push_group_list = get_push_group_list();
75
+
76
+ // for all push targets in push_group_list
77
+ // things that can be done in parallel are grouped by numbers
78
+ //
79
+ // -----------------------------------
80
+ // 1 (before_push) temp mark draft
81
+ // --------------------------------------
82
+ // 2 push simultaneously to github
83
+ // --------------------------------------
84
+ // 2 create PR / edit PR
85
+ // 2 (after_push) undo temp mark draft
86
+ // --------------------------------------
87
+
88
+ try {
89
+ const before_push_tasks = [];
90
+ for (const group of push_group_list) {
91
+ before_push_tasks.push(before_push({ group }));
92
+ }
93
+
94
+ await Promise.all(before_push_tasks);
95
+
96
+ const git_push_command = [`git push -f origin`];
97
+
98
+ if (argv.verify === false) {
99
+ git_push_command.push("--no-verify");
100
+ }
101
+
102
+ for (const group of push_group_list) {
103
+ const last_commit = last(group.commits);
104
+ invariant(last_commit, "last_commit must exist");
105
+
106
+ // explicit refs/heads head branch to avoid push failing
107
+ //
108
+ // ❯ git push -f origin --no-verify f6e249051b4820a03deb957ddebc19acfd7dfd7c:gs-ED2etrzv2
109
+ // error: The destination you provided is not a full refname (i.e.,
110
+ // starting with "refs/"). We tried to guess what you meant by:
111
+ //
112
+ // - Looking for a ref that matches 'gs-ED2etrzv2' on the remote side.
113
+ // - Checking if the <src> being pushed ('f6e249051b4820a03deb957ddebc19acfd7dfd7c')
114
+ // is a ref in "refs/{heads,tags}/". If so we add a corresponding
115
+ // refs/{heads,tags}/ prefix on the remote side.
116
+ //
117
+ // Neither worked, so we gave up. You must fully qualify the ref.
118
+ // hint: The <src> part of the refspec is a commit object.
119
+ // hint: Did you mean to create a new branch by pushing to
120
+ // hint: 'f6e249051b4820a03deb957ddebc19acfd7dfd7c:refs/heads/gs-ED2etrzv2'?
121
+ // error: failed to push some refs to 'github.com:magus/git-multi-diff-playground.git'
122
+ //
123
+ const target = `${last_commit.sha}:refs/heads/${group.id}`;
124
+ git_push_command.push(target);
125
+ }
126
+
127
+ await cli(git_push_command);
128
+
129
+ const pr_url_list = commit_range.group_list.map(get_group_url);
130
+
131
+ const after_push_tasks = [];
132
+ for (const group of push_group_list) {
133
+ after_push_tasks.push(after_push({ group, pr_url_list }));
134
+ }
135
+
136
+ await Promise.all(after_push_tasks);
137
+
138
+ // finally, ensure all prs have the updated stack table from updated pr_url_list
139
+ // this step must come after the after_push since that step may create new PRs
140
+ // we need the urls for all prs at this step so we run it after the after_push
141
+ const update_pr_body_tasks = [];
142
+ for (let i = 0; i < commit_range.group_list.length; i++) {
143
+ const group = commit_range.group_list[i];
144
+
145
+ // use the updated pr_url_list to get the actual selected_url
146
+ const selected_url = pr_url_list[i];
147
+
148
+ const task = update_pr_body({ group, selected_url, pr_url_list });
149
+ update_pr_body_tasks.push(task);
150
+ }
151
+
152
+ await Promise.all(update_pr_body_tasks);
153
+
154
+ actions.set((state) => {
155
+ state.step = "post-rebase-status";
156
+ });
157
+ } catch (err) {
158
+ if (err instanceof Error) {
159
+ actions.error(err.message);
160
+ }
161
+
162
+ actions.error("Unable to sync.");
163
+ if (!argv.verbose) {
164
+ actions.error("Try again with `--verbose` to see more information.");
165
+ }
166
+
167
+ await handle_exit(18);
168
+ }
169
+
170
+ function get_push_group_list() {
171
+ // start from HEAD and work backward to rebase_group_index
172
+ const push_group_list = [];
173
+
174
+ for (let i = 0; i < commit_range.group_list.length; i++) {
175
+ const index = commit_range.group_list.length - 1 - i;
176
+
177
+ // do not go past rebase_group_index
178
+ if (index < rebase_group_index) {
179
+ break;
180
+ }
181
+
182
+ const group = commit_range.group_list[index];
183
+
184
+ push_group_list.unshift(group);
185
+ }
186
+
187
+ return push_group_list;
188
+ }
189
+
190
+ async function before_push(args: { group: CommitMetadataGroup }) {
191
+ const { group } = args;
192
+
193
+ invariant(group.base, "group.base must exist");
194
+
195
+ // we may temporarily mark PR as a draft before editing it
196
+ // if it is not already a draft PR, to avoid notification spam
197
+ let is_temp_draft = !group.pr?.isDraft;
198
+
199
+ // before pushing reset base to master temporarily
200
+ // avoid accidentally pointing to orphaned parent commit
201
+ // should hopefully fix issues where a PR includes a bunch of commits after pushing
202
+ if (group.pr) {
203
+ if (!group.pr.isDraft) {
204
+ is_temp_draft = true;
205
+ }
206
+
207
+ if (is_temp_draft) {
208
+ await github.pr_draft({
209
+ branch: group.id,
210
+ draft: true,
211
+ });
212
+ }
213
+
214
+ await github.pr_edit({
215
+ branch: group.id,
216
+ base: master_branch,
217
+ });
218
+ }
219
+ }
220
+
221
+ async function after_push(args: {
222
+ group: CommitMetadataGroup;
223
+ pr_url_list: Array<string>;
224
+ }) {
225
+ const { group, pr_url_list } = args;
226
+
227
+ invariant(group.base, "group.base must exist");
228
+
229
+ const selected_url = get_group_url(group);
230
+
231
+ if (group.pr) {
232
+ // ensure base matches pr in github
233
+ await github.pr_edit({
234
+ branch: group.id,
235
+ base: group.base,
236
+ body: StackSummaryTable.write({
237
+ body: group.pr.body,
238
+ pr_url_list,
239
+ selected_url,
240
+ }),
241
+ });
242
+
243
+ // we may temporarily mark PR as a draft before editing it
244
+ // if it is not already a draft PR, to avoid notification spam
245
+ let is_temp_draft = !group.pr?.isDraft;
246
+
247
+ if (is_temp_draft) {
248
+ // mark pr as ready for review again
249
+ await github.pr_draft({
250
+ branch: group.id,
251
+ draft: false,
252
+ });
253
+ }
254
+ } else {
255
+ // create pr in github
256
+ const pr_url = await github.pr_create({
257
+ branch: group.id,
258
+ base: group.base,
259
+ title: group.title,
260
+ body: DEFAULT_PR_BODY,
261
+ draft: argv.draft,
262
+ });
263
+
264
+ if (!pr_url) {
265
+ throw new Error("unable to create pr");
266
+ }
267
+
268
+ // update pr_url_list with created pr_url
269
+ for (let i = 0; i < pr_url_list.length; i++) {
270
+ const url = pr_url_list[i];
271
+ if (url === selected_url) {
272
+ pr_url_list[i] = pr_url;
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ async function update_pr_body(args: {
279
+ group: CommitMetadataGroup;
280
+ selected_url: string;
281
+ pr_url_list: Array<string>;
282
+ }) {
283
+ const { group, selected_url, pr_url_list } = args;
284
+
285
+ invariant(group.base, "group.base must exist");
286
+
287
+ const body = group.pr?.body || DEFAULT_PR_BODY;
288
+
289
+ const update_body = StackSummaryTable.write({
290
+ body,
291
+ pr_url_list,
292
+ selected_url,
293
+ });
294
+
295
+ if (update_body === body) {
296
+ actions.debug(`Skipping body update for ${selected_url}`);
297
+ } else {
298
+ actions.debug(`Update body for ${selected_url}`);
299
+
300
+ await github.pr_edit({
301
+ branch: group.id,
302
+ base: group.base,
303
+ body: update_body,
304
+ });
305
+ }
306
+ }
307
+
308
+ function handle_exit(code: number) {
309
+ actions.output(
310
+ <Ink.Text color={colors.yellow}>Restoring PR state…</Ink.Text>
311
+ );
312
+
313
+ for (const group of push_group_list) {
314
+ // we may temporarily mark PR as a draft before editing it
315
+ // if it is not already a draft PR, to avoid notification spam
316
+ let is_temp_draft = !group.pr?.isDraft;
317
+
318
+ // restore PR to non-draft state
319
+ if (is_temp_draft) {
320
+ github
321
+ .pr_draft({
322
+ branch: group.id,
323
+ draft: false,
324
+ })
325
+ .catch(actions.error);
326
+ }
327
+ }
328
+
329
+ actions.output(
330
+ <Ink.Text color={colors.yellow}>Restored PR state.</Ink.Text>
331
+ );
332
+
333
+ actions.exit(code);
334
+ }
335
+ }
336
+
337
+ type CommitMetadataGroup = CommitMetadata.CommitRange["group_list"][number];
338
+ const get_group_url = (group: CommitMetadataGroup) => group.pr?.url || group.id;
@@ -16,15 +16,35 @@ 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
+
19
33
  return (
20
34
  <Await
21
- function={Rebase.run}
22
35
  fallback={<Ink.Text color={colors.yellow}>Rebasing commits…</Ink.Text>}
36
+ function={async function () {
37
+ await Rebase.run({ abort_handler });
38
+ }}
23
39
  />
24
40
  );
25
41
  }
26
42
 
27
- Rebase.run = async function run() {
43
+ type Args = {
44
+ abort_handler: React.MutableRefObject<() => void>;
45
+ };
46
+
47
+ Rebase.run = async function run(args: Args) {
28
48
  const state = Store.getState();
29
49
  const actions = state.actions;
30
50
  const branch_name = state.branch_name;
@@ -38,7 +58,10 @@ Rebase.run = async function run() {
38
58
  invariant(repo_root, "repo_root must exist");
39
59
 
40
60
  // always listen for SIGINT event and restore git state
41
- process.once("SIGINT", handle_exit);
61
+ args.abort_handler.current = async function sigint_handler() {
62
+ actions.output(<Ink.Text color={colors.red}>🚨 Abort</Ink.Text>);
63
+ handle_exit(19);
64
+ };
42
65
 
43
66
  const temp_branch_name = `${branch_name}_${short_id()}`;
44
67
 
@@ -143,7 +166,7 @@ Rebase.run = async function run() {
143
166
  }
144
167
  }
145
168
 
146
- handle_exit();
169
+ handle_exit(20);
147
170
  }
148
171
 
149
172
  // cleanup git operations if cancelled during manual rebase
@@ -171,7 +194,7 @@ Rebase.run = async function run() {
171
194
  cli.sync(`pwd`, spawn_options);
172
195
  }
173
196
 
174
- function handle_exit() {
197
+ function handle_exit(code: number) {
175
198
  actions.output(
176
199
  <Ink.Text color={colors.yellow}>
177
200
  Restoring <Brackets>{branch_name}</Brackets>…
@@ -186,6 +209,6 @@ Rebase.run = async function run() {
186
209
  </Ink.Text>
187
210
  );
188
211
 
189
- actions.exit(6);
212
+ actions.exit(code);
190
213
  }
191
214
  };
@@ -123,7 +123,14 @@ type CreatePullRequestArgs = {
123
123
 
124
124
  export async function pr_create(args: CreatePullRequestArgs) {
125
125
  const title = safe_quote(args.title);
126
- let command = `gh pr create --fill --head ${args.branch} --base ${args.base} --title="${title}" --body="${args.body}"`;
126
+
127
+ // explicit refs/heads head branch to avoid creation failing
128
+ //
129
+ // ❯ gh pr create --head origin/gs-ED2etrzv2 --base gs-6LAx-On45 --title="2024-01-05 test" --body=""
130
+ // pull request create failed: GraphQL: Head sha can't be blank, Base sha can't be blank, No commits between gs-6LAx-On45 and origin/gs-ED2etrzv2, Head ref must be a branch (createPullRequest)
131
+ //
132
+ // https://github.com/cli/cli/issues/5465
133
+ let command = `gh pr create --head refs/heads/${args.branch} --base ${args.base} --title="${title}" --body="${args.body}"`;
127
134
 
128
135
  if (args.draft) {
129
136
  command += " --draft";
package/src/index.tsx CHANGED
@@ -10,7 +10,10 @@ import { command } from "~/command";
10
10
 
11
11
  command()
12
12
  .then((argv) => {
13
- const ink = Ink.render(<App />);
13
+ const ink = Ink.render(<App />, {
14
+ // If true, each update will be rendered as a separate output, without replacing the previous one.
15
+ // debug: true,
16
+ });
14
17
 
15
18
  Store.setState((state) => {
16
19
  state.ink = ink;