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/src/command.ts CHANGED
@@ -27,6 +27,12 @@ export async function command() {
27
27
  (yargs) => yargs.strict(false)
28
28
  )
29
29
 
30
+ .command(
31
+ "rebase",
32
+ "Update local branch via rebase with latest changes from origin master branch",
33
+ (yargs) => yargs
34
+ )
35
+
30
36
  .option("verbose", GlobalOptions.verbose)
31
37
 
32
38
  // yargs default wraps to 80 columns
@@ -0,0 +1,204 @@
1
+ import * as React from "react";
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";
17
+
18
+ export function Rebase() {
19
+ return (
20
+ <Await
21
+ function={Rebase.run}
22
+ fallback={<Ink.Text color={colors.yellow}>Rebasing commits…</Ink.Text>}
23
+ />
24
+ );
25
+ }
26
+
27
+ Rebase.run = 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.output(
124
+ <FormatText
125
+ wrapper={<Ink.Text color={colors.green} />}
126
+ message="✅ {branch_name} in sync with {origin_branch}"
127
+ values={{
128
+ branch_name: <Brackets>{branch_name}</Brackets>,
129
+ origin_branch: <Brackets>{`origin/${master_branch}`}</Brackets>,
130
+ }}
131
+ />
132
+ );
133
+
134
+ actions.set((state) => {
135
+ state.commit_range = next_commit_range;
136
+ state.step = "status";
137
+ });
138
+ } catch (err) {
139
+ actions.error("Unable to rebase.");
140
+
141
+ if (err instanceof Error) {
142
+ if (actions.isDebug()) {
143
+ actions.error(err.message);
144
+ }
145
+ }
146
+
147
+ handle_exit();
148
+ }
149
+
150
+ // cleanup git operations if cancelled during manual rebase
151
+ function restore_git() {
152
+ // signint handler MUST run synchronously
153
+ // trying to use `await cli(...)` here will silently fail since
154
+ // all children processes receive the SIGINT signal
155
+ const spawn_options = { ignoreExitCode: true };
156
+
157
+ // always clean up any patch files
158
+ cli.sync(`rm ${PATCH_FILE}`, spawn_options);
159
+
160
+ // always hard reset and clean to allow subsequent checkout
161
+ // if there are files checkout will fail and cascade fail subsequent commands
162
+ cli.sync(`git reset --hard`, spawn_options);
163
+ cli.sync(`git clean -df`, spawn_options);
164
+
165
+ // always put self back in original branch
166
+ cli.sync(`git checkout ${branch_name}`, spawn_options);
167
+
168
+ // ...and cleanup temporary branch
169
+ cli.sync(`git branch -D ${temp_branch_name}`, spawn_options);
170
+
171
+ if (commit_range) {
172
+ // ...and cleanup pr group branches
173
+ for (const group of commit_range.group_list) {
174
+ cli.sync(`git branch -D ${group.id}`, spawn_options);
175
+ }
176
+ }
177
+
178
+ // restore back to original dir
179
+ if (fs.existsSync(cwd)) {
180
+ process.chdir(cwd);
181
+ }
182
+ cli.sync(`pwd`, spawn_options);
183
+ }
184
+
185
+ function handle_exit() {
186
+ actions.output(
187
+ <Ink.Text color={colors.yellow}>
188
+ Restoring <Brackets>{branch_name}</Brackets>…
189
+ </Ink.Text>
190
+ );
191
+
192
+ restore_git();
193
+
194
+ actions.output(
195
+ <Ink.Text color={colors.yellow}>
196
+ Restored <Brackets>{branch_name}</Brackets>.
197
+ </Ink.Text>
198
+ );
199
+
200
+ actions.exit(6);
201
+ }
202
+ };
203
+
204
+ const PATCH_FILE = "git-stack-cli-patch.patch";
@@ -2,7 +2,6 @@ import { Store } from "~/app/Store";
2
2
  import * as Metadata from "~/core/Metadata";
3
3
  import { cli } from "~/core/cli";
4
4
  import * as github from "~/core/github";
5
- import { invariant } from "~/core/invariant";
6
5
 
7
6
  export type CommitMetadata = Awaited<ReturnType<typeof commit>>;
8
7
  export type CommitRange = Awaited<ReturnType<typeof range>>;
@@ -18,7 +17,7 @@ type CommitGroup = {
18
17
  commits: Array<CommitMetadata>;
19
18
  };
20
19
 
21
- type SimpleGroup = { id: string; title: string };
20
+ export type SimpleGroup = { id: string; title: string };
22
21
  type CommitGroupMap = { [sha: string]: SimpleGroup };
23
22
 
24
23
  export async function range(commit_group_map?: CommitGroupMap) {
@@ -167,10 +166,6 @@ export async function range(commit_group_map?: CommitGroupMap) {
167
166
 
168
167
  async function get_commit_list() {
169
168
  const master_branch = Store.getState().master_branch;
170
- const branch_name = Store.getState().branch_name;
171
-
172
- invariant(branch_name, "branch_name must exist");
173
-
174
169
  const log_result = await cli(
175
170
  `git log ${master_branch}..HEAD --oneline --format=%H --color=never`
176
171
  );
@@ -183,30 +178,12 @@ async function get_commit_list() {
183
178
 
184
179
  const commit_metadata_list = [];
185
180
 
186
- let has_metadata = false;
187
-
188
181
  for (let i = 0; i < sha_list.length; i++) {
189
182
  const sha = sha_list[i];
190
183
  const commit_metadata = await commit(sha);
191
-
192
- if (commit_metadata.branch_id) {
193
- has_metadata = true;
194
- }
195
-
196
184
  commit_metadata_list.push(commit_metadata);
197
185
  }
198
186
 
199
- if (!has_metadata) {
200
- // check for pr with matching branch name to initialize group
201
- const pr_result = await github.pr_status(branch_name);
202
- if (pr_result) {
203
- for (const commit_metadata of commit_metadata_list) {
204
- commit_metadata.branch_id = branch_name;
205
- commit_metadata.title = pr_result.title;
206
- }
207
- }
208
- }
209
-
210
187
  return commit_metadata_list;
211
188
  }
212
189
 
@@ -67,7 +67,7 @@ test("git-revise-todo handles double quotes in commit message", () => {
67
67
  [
68
68
  //force line break
69
69
  "++ pick f143d03c723c",
70
- '[new] invalid \\\\"by me\\\\" quotes',
70
+ '[new] invalid \\"by me\\" quotes',
71
71
  "",
72
72
  "git-stack-id: 6Ak-qn+5Z",
73
73
  'git-stack-title: [new] invalid \\"by me\\" quotes',
@@ -115,7 +115,7 @@ const SINGLE_COMMIT_EXISTING_GROUP: CommitMetadata.CommitRange = {
115
115
  full_message: "banana color\n\ngit-stack-id: AAWsYx1UU",
116
116
  subject_line: "banana color",
117
117
  branch_id: "AAWsYx1UU",
118
- title: null,
118
+ title: "banana",
119
119
  },
120
120
  ],
121
121
  },
@@ -186,28 +186,28 @@ const SINGLE_COMMIT_EXISTING_GROUP: CommitMetadata.CommitRange = {
186
186
  full_message: "lemon color\n\ngit-stack-id: E63ytp5dj",
187
187
  subject_line: "lemon color",
188
188
  branch_id: "E63ytp5dj",
189
- title: null,
189
+ title: "lemon color",
190
190
  },
191
191
  {
192
192
  sha: "d36d63499425bb46a1e62c2c9df1a4332b13004f",
193
193
  full_message: "cantaloupe color\n\ngit-stack-id: E63ytp5dj",
194
194
  subject_line: "cantaloupe color",
195
195
  branch_id: "E63ytp5dj",
196
- title: null,
196
+ title: "lemon color",
197
197
  },
198
198
  {
199
199
  sha: "4f98dd3e67d03b79d7a12480c7d1c2fcbd186ac5",
200
200
  full_message: "banana sweet\n\ngit-stack-id: E63ytp5dj",
201
201
  subject_line: "banana sweet",
202
202
  branch_id: "E63ytp5dj",
203
- title: null,
203
+ title: "lemon color",
204
204
  },
205
205
  {
206
206
  sha: "f143d03c723c9f5231a81c1e12098511611898cb",
207
207
  full_message: "apple sweet",
208
208
  subject_line: "apple sweet",
209
- branch_id: null,
210
- title: null,
209
+ branch_id: "E63ytp5dj",
210
+ title: "lemon color",
211
211
  },
212
212
  ],
213
213
  },
@@ -218,35 +218,35 @@ const SINGLE_COMMIT_EXISTING_GROUP: CommitMetadata.CommitRange = {
218
218
  full_message: "banana color\n\ngit-stack-id: AAWsYx1UU",
219
219
  subject_line: "banana color",
220
220
  branch_id: "AAWsYx1UU",
221
- title: null,
221
+ title: "banana",
222
222
  },
223
223
  {
224
224
  sha: "3cb22661ecff6c872e96ce9c40b31c824938cab7",
225
225
  full_message: "lemon color\n\ngit-stack-id: E63ytp5dj",
226
226
  subject_line: "lemon color",
227
227
  branch_id: "E63ytp5dj",
228
- title: null,
228
+ title: "lemon color",
229
229
  },
230
230
  {
231
231
  sha: "d36d63499425bb46a1e62c2c9df1a4332b13004f",
232
232
  full_message: "cantaloupe color\n\ngit-stack-id: E63ytp5dj",
233
233
  subject_line: "cantaloupe color",
234
234
  branch_id: "E63ytp5dj",
235
- title: null,
235
+ title: "lemon color",
236
236
  },
237
237
  {
238
238
  sha: "4f98dd3e67d03b79d7a12480c7d1c2fcbd186ac5",
239
239
  full_message: "banana sweet\n\ngit-stack-id: E63ytp5dj",
240
240
  subject_line: "banana sweet",
241
241
  branch_id: "E63ytp5dj",
242
- title: null,
242
+ title: "lemon color",
243
243
  },
244
244
  {
245
245
  sha: "f143d03c723c9f5231a81c1e12098511611898cb",
246
246
  full_message: "apple sweet",
247
247
  subject_line: "apple sweet",
248
- branch_id: null,
249
- title: null,
248
+ branch_id: "6Ak-qn+5Z",
249
+ title: "new group",
250
250
  },
251
251
  ],
252
252
  pr_lookup: {
@@ -376,7 +376,7 @@ const SINGLE_COMMIT_NEW_GROUP: CommitMetadata.CommitRange = {
376
376
  full_message: "banana color\n\ngit-stack-id: AAWsYx1UU",
377
377
  subject_line: "banana color",
378
378
  branch_id: "AAWsYx1UU",
379
- title: null,
379
+ title: "banana",
380
380
  },
381
381
  ],
382
382
  },
@@ -447,21 +447,21 @@ const SINGLE_COMMIT_NEW_GROUP: CommitMetadata.CommitRange = {
447
447
  full_message: "lemon color\n\ngit-stack-id: E63ytp5dj",
448
448
  subject_line: "lemon color",
449
449
  branch_id: "E63ytp5dj",
450
- title: null,
450
+ title: "lemon color",
451
451
  },
452
452
  {
453
453
  sha: "d36d63499425bb46a1e62c2c9df1a4332b13004f",
454
454
  full_message: "cantaloupe color\n\ngit-stack-id: E63ytp5dj",
455
455
  subject_line: "cantaloupe color",
456
456
  branch_id: "E63ytp5dj",
457
- title: null,
457
+ title: "lemon color",
458
458
  },
459
459
  {
460
460
  sha: "4f98dd3e67d03b79d7a12480c7d1c2fcbd186ac5",
461
461
  full_message: "banana sweet\n\ngit-stack-id: E63ytp5dj",
462
462
  subject_line: "banana sweet",
463
463
  branch_id: "E63ytp5dj",
464
- title: null,
464
+ title: "lemon color",
465
465
  },
466
466
  ],
467
467
  },
@@ -476,8 +476,8 @@ const SINGLE_COMMIT_NEW_GROUP: CommitMetadata.CommitRange = {
476
476
  sha: "f143d03c723c9f5231a81c1e12098511611898cb",
477
477
  full_message: "apple sweet",
478
478
  subject_line: "apple sweet",
479
- branch_id: null,
480
- title: null,
479
+ branch_id: "6Ak-qn+5Z",
480
+ title: "new group",
481
481
  },
482
482
  ],
483
483
  },
@@ -488,35 +488,35 @@ const SINGLE_COMMIT_NEW_GROUP: CommitMetadata.CommitRange = {
488
488
  full_message: "banana color\n\ngit-stack-id: AAWsYx1UU",
489
489
  subject_line: "banana color",
490
490
  branch_id: "AAWsYx1UU",
491
- title: null,
491
+ title: "banana",
492
492
  },
493
493
  {
494
494
  sha: "3cb22661ecff6c872e96ce9c40b31c824938cab7",
495
495
  full_message: "lemon color\n\ngit-stack-id: E63ytp5dj",
496
496
  subject_line: "lemon color",
497
497
  branch_id: "E63ytp5dj",
498
- title: null,
498
+ title: "lemon color",
499
499
  },
500
500
  {
501
501
  sha: "d36d63499425bb46a1e62c2c9df1a4332b13004f",
502
502
  full_message: "cantaloupe color\n\ngit-stack-id: E63ytp5dj",
503
503
  subject_line: "cantaloupe color",
504
504
  branch_id: "E63ytp5dj",
505
- title: null,
505
+ title: "lemon color",
506
506
  },
507
507
  {
508
508
  sha: "4f98dd3e67d03b79d7a12480c7d1c2fcbd186ac5",
509
509
  full_message: "banana sweet\n\ngit-stack-id: E63ytp5dj",
510
510
  subject_line: "banana sweet",
511
511
  branch_id: "E63ytp5dj",
512
- title: null,
512
+ title: "lemon color",
513
513
  },
514
514
  {
515
515
  sha: "f143d03c723c9f5231a81c1e12098511611898cb",
516
516
  full_message: "apple sweet",
517
517
  subject_line: "apple sweet",
518
- branch_id: null,
519
- title: null,
518
+ branch_id: "6Ak-qn+5Z",
519
+ title: "new group",
520
520
  },
521
521
  ],
522
522
  pr_lookup: {
@@ -620,8 +620,8 @@ const COMMIT_MESSAGE_WITH_QUOTES: CommitMetadata.CommitRange = {
620
620
  sha: "f143d03c723c9f5231a81c1e12098511611898cb",
621
621
  full_message: '[new] invalid "by me" quotes',
622
622
  subject_line: '[new] invalid "by me" quotes',
623
- branch_id: null,
624
- title: null,
623
+ branch_id: "6Ak-qn+5Z",
624
+ title: '[new] invalid "by me" quotes',
625
625
  },
626
626
  ],
627
627
  },
@@ -631,8 +631,8 @@ const COMMIT_MESSAGE_WITH_QUOTES: CommitMetadata.CommitRange = {
631
631
  sha: "f143d03c723c9f5231a81c1e12098511611898cb",
632
632
  full_message: '[new] invalid "by me" quotes',
633
633
  subject_line: '[new] invalid "by me" quotes',
634
- branch_id: null,
635
- title: null,
634
+ branch_id: "6Ak-qn+5Z",
635
+ title: '[new] invalid "by me" quotes',
636
636
  },
637
637
  ],
638
638
  pr_lookup: {},
@@ -1,4 +1,10 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
1
5
  import * as Metadata from "~/core/Metadata";
6
+ import { cli } from "~/core/cli";
7
+ import { invariant } from "~/core/invariant";
2
8
 
3
9
  import type * as CommitMetadata from "~/core/CommitMetadata";
4
10
 
@@ -47,7 +53,7 @@ import type * as CommitMetadata from "~/core/CommitMetadata";
47
53
  // apple sweet
48
54
  //
49
55
  export function GitReviseTodo(args: Args): string {
50
- const entry_list = [];
56
+ const commit_list = [];
51
57
 
52
58
  const group_list = args.commit_range.group_list;
53
59
 
@@ -55,31 +61,83 @@ export function GitReviseTodo(args: Args): string {
55
61
  const group = group_list[i];
56
62
 
57
63
  for (const commit of group.commits) {
58
- // update git commit message with stack id
59
- const metadata = { id: group.id, title: group.title };
60
- const unsafe_message_with_id = Metadata.write(
61
- commit.full_message,
62
- metadata
63
- );
64
+ commit_list.push(commit);
65
+ }
66
+ }
67
+
68
+ const todo = GitReviseTodo.todo({ commit_list });
69
+ return todo;
70
+ }
64
71
 
65
- let message_with_id = unsafe_message_with_id;
72
+ type CommitListArgs = {
73
+ commit_list: CommitMetadata.CommitRange["commit_list"];
74
+ };
75
+
76
+ GitReviseTodo.todo = function todo(args: CommitListArgs) {
77
+ const entry_list = [];
66
78
 
67
- message_with_id = message_with_id.replace(/[^\\]"/g, '\\"');
79
+ for (const commit of args.commit_list) {
80
+ // update git commit message with stack id
81
+ const id = commit.branch_id;
82
+ const title = commit.title;
68
83
 
69
- // get first 12 characters of commit sha
70
- const sha = commit.sha.slice(0, 12);
84
+ invariant(id, "commit.branch_id must exist");
85
+ invariant(title, "commit.title must exist");
71
86
 
72
- // generate git revise entry
73
- const entry_lines = [`++ pick ${sha}`, message_with_id];
74
- const entry = entry_lines.join("\n");
87
+ const metadata = { id, title };
75
88
 
76
- entry_list.push(entry);
77
- }
89
+ const unsafe_message_with_id = Metadata.write(
90
+ commit.full_message,
91
+ metadata
92
+ );
93
+
94
+ let message_with_id = unsafe_message_with_id;
95
+
96
+ message_with_id = message_with_id.replace(/[^\\]"/g, '\\"');
97
+
98
+ // get first 12 characters of commit sha
99
+ const sha = commit.sha.slice(0, 12);
100
+
101
+ // generate git revise entry
102
+ const entry_lines = [`++ pick ${sha}`, message_with_id];
103
+ const entry = entry_lines.join("\n");
104
+
105
+ entry_list.push(entry);
78
106
  }
79
107
 
80
108
  const todo = entry_list.join("\n\n");
81
109
  return todo;
82
- }
110
+ };
111
+
112
+ GitReviseTodo.execute = async function grt_execute(args: ExecuteArgs) {
113
+ // generate temporary directory and drop sequence editor script
114
+ const tmp_git_sequence_editor_path = path.join(
115
+ os.tmpdir(),
116
+ "git-sequence-editor.sh"
117
+ );
118
+
119
+ // ensure script is executable
120
+ fs.chmodSync(tmp_git_sequence_editor_path, "755");
121
+
122
+ const git_revise_todo = GitReviseTodo(args);
123
+
124
+ // execute cli with temporary git sequence editor script
125
+ // revise from merge base to pick correct commits
126
+ const command = [
127
+ `GIT_EDITOR="${tmp_git_sequence_editor_path}"`,
128
+ `GIT_REVISE_TODO="${git_revise_todo}"`,
129
+ `git`,
130
+ `revise --edit -i ${args.rebase_merge_base}`,
131
+ ];
132
+
133
+ await cli(command, { stdio: ["ignore", "ignore", "ignore"] });
134
+ };
135
+
136
+ type ExecuteArgs = {
137
+ rebase_group_index: number;
138
+ rebase_merge_base: string;
139
+ commit_range: CommitMetadata.CommitRange;
140
+ };
83
141
 
84
142
  type Args = {
85
143
  rebase_group_index: number;
@@ -0,0 +1,5 @@
1
+ import { short_id } from "~/core/short_id";
2
+
3
+ export function gs_short_id() {
4
+ return `gs-${short_id()}`;
5
+ }