git-stack-cli 2.7.8 → 2.8.1

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.8",
3
+ "version": "2.8.1",
4
4
  "description": "",
5
5
  "author": "magus",
6
6
  "license": "MIT",
@@ -67,10 +67,11 @@ async function run_build() {
67
67
  const result = await Bun.build(BUILD_CONFIG);
68
68
 
69
69
  const duration_ms = Date.now() - start;
70
+ const status = result.success ? "✅" : "❌";
70
71
 
71
- log(`✅ Build (${duration_ms}ms)`);
72
+ log(`${status} build (${duration_ms}ms)`);
72
73
 
73
- if (VERBOSE) {
74
+ if (VERBOSE || !result.success) {
74
75
  log({ result });
75
76
  }
76
77
  }
@@ -6,6 +6,9 @@ import { spawn } from "~/core/spawn";
6
6
 
7
7
  process.env.NODE_ENV = "production";
8
8
 
9
+ // ensure npm is authenticated
10
+ await spawn(`npm whoami`);
11
+
9
12
  // get paths relative to this script
10
13
  const REPO_ROOT = (await spawn.sync("git rev-parse --show-toplevel")).stdout;
11
14
  const DIST_DIR = path.join(REPO_ROOT, "dist");
@@ -18,10 +21,9 @@ process.chdir(REPO_ROOT);
18
21
 
19
22
  // require clean git status besides changes to package.json version
20
23
  const git_status = await spawn.sync("git status --porcelain");
21
- if (!/^M\s+package.json/.test(git_status.stdout)) {
22
- console.error(
23
- "please commit local changes and only update package.json version before running release",
24
- );
24
+ if (!/^M\s+package.json$/.test(git_status.stdout)) {
25
+ console.error("please commit local changes");
26
+ console.error("only update package.json version before running release");
25
27
  process.exit(4);
26
28
  }
27
29
 
@@ -57,6 +57,7 @@ async function run() {
57
57
  const group_from_map = commit_map[commit.sha];
58
58
  commit.branch_id = group_from_map.id;
59
59
  commit.title = group_from_map.title;
60
+ commit.master_base = group_from_map.master_base;
60
61
  }
61
62
 
62
63
  // // capture commit_range for GitReviseTodo test
@@ -78,21 +79,12 @@ async function run() {
78
79
  let rebase_merge_base = merge_base;
79
80
  let rebase_group_index = 0;
80
81
 
81
- for (let i = 0; i < commit_range.group_list.length; i++) {
82
- const group = commit_range.group_list[i];
82
+ inplace_order_commit_range_groups(commit_range);
83
83
 
84
- if (!group.dirty) {
85
- continue;
86
- }
87
-
88
- if (i > 0) {
89
- const prev_group = commit_range.group_list[i - 1];
90
- const prev_commit = prev_group.commits[prev_group.commits.length - 1];
91
- rebase_merge_base = prev_commit.sha;
92
- rebase_group_index = i;
93
- }
94
-
95
- break;
84
+ const dirty = find_first_dirty_group(commit_range);
85
+ if (dirty) {
86
+ rebase_merge_base = dirty.sha;
87
+ rebase_group_index = dirty.index;
96
88
  }
97
89
 
98
90
  actions.debug(`rebase_merge_base = ${rebase_merge_base}`);
@@ -185,3 +177,66 @@ async function run() {
185
177
  );
186
178
  }
187
179
  }
180
+
181
+ function inplace_order_commit_range_groups(commit_range: CommitMetadata.CommitRange) {
182
+ const state = Store.getState();
183
+ const actions = state.actions;
184
+
185
+ // order groups with group.master_base first
186
+ const group_list_master: CommitGroupList = [];
187
+ const group_list_others: CommitGroupList = [];
188
+ for (const group of commit_range.group_list) {
189
+ if (group.master_base) {
190
+ group_list_master.push(group);
191
+ } else {
192
+ group_list_others.push(group);
193
+ }
194
+ }
195
+
196
+ const ordered_group_list = [...group_list_master, ...group_list_others];
197
+
198
+ // detect if group list order differs
199
+ let differs = false;
200
+ for (let i = 0; i < commit_range.group_list.length; i++) {
201
+ const original_group = commit_range.group_list[i];
202
+ const ordered_group = ordered_group_list[i];
203
+ if (original_group.id !== ordered_group.id) {
204
+ ordered_group.dirty = true;
205
+ const debug = JSON.stringify({ original_group, ordered_group });
206
+ actions.debug(`inplace_order_commit_range_groups ${debug}`);
207
+
208
+ differs = true;
209
+ break;
210
+ }
211
+ }
212
+
213
+ if (differs) {
214
+ commit_range.group_list = ordered_group_list;
215
+ }
216
+
217
+ return differs;
218
+ }
219
+
220
+ function find_first_dirty_group(commit_range: CommitMetadata.CommitRange) {
221
+ for (let i = 0; i < commit_range.group_list.length; i++) {
222
+ const group = commit_range.group_list[i];
223
+
224
+ if (!group.dirty) {
225
+ continue;
226
+ }
227
+
228
+ if (i > 0) {
229
+ const prev_group = commit_range.group_list[i - 1];
230
+ const prev_commit = prev_group.commits[prev_group.commits.length - 1];
231
+ const sha = prev_commit.sha;
232
+ const index = i;
233
+ return { sha, index };
234
+ }
235
+
236
+ break;
237
+ }
238
+
239
+ return null;
240
+ }
241
+
242
+ type CommitGroupList = CommitMetadata.CommitRange["group_list"];
@@ -87,6 +87,25 @@ export function MultiSelect<T>(props: Props<T>) {
87
87
  },
88
88
  );
89
89
 
90
+ // ensure current index is valid and not disabled
91
+ React.useEffect(ensure_selectable_focus, [props.items]);
92
+ function ensure_selectable_focus() {
93
+ const current = props.items[index];
94
+ if (current && !current.disabled) {
95
+ return;
96
+ }
97
+
98
+ for (let i = 0; i < props.items.length; i++) {
99
+ // search items backwards
100
+ const index = props.items.length - 1 - i;
101
+ const item = props.items[index];
102
+ if (!item.disabled) {
103
+ set_index(index);
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
90
109
  const selectRef = React.useRef(false);
91
110
 
92
111
  React.useEffect(() => {
@@ -15,6 +15,9 @@ import { short_id } from "~/core/short_id";
15
15
  import { wrap_index } from "~/core/wrap_index";
16
16
 
17
17
  import type { State } from "~/app/Store";
18
+ import type * as CommitMetadata from "~/core/CommitMetadata";
19
+
20
+ type CommitRangeGroup = NonNullable<Parameters<typeof CommitMetadata.range>[0]>[string];
18
21
 
19
22
  export function SelectCommitRanges() {
20
23
  const commit_range = Store.useState((state) => state.commit_range);
@@ -62,6 +65,23 @@ function SelectCommitRangesInternal(props: Props) {
62
65
  [],
63
66
  );
64
67
 
68
+ const [group_master_base, set_group_master_base] = React.useReducer(
69
+ (set: Set<string>, group_id: string) => {
70
+ set.has(group_id) ? set.delete(group_id) : set.add(group_id);
71
+ return new Set(set);
72
+ },
73
+ new Set<string>(),
74
+ (set) => {
75
+ for (const group of props.commit_range.group_list) {
76
+ if (group.master_base) {
77
+ set.add(group.id);
78
+ }
79
+ }
80
+
81
+ return new Set(set);
82
+ },
83
+ );
84
+
65
85
  const [commit_map, update_commit_map] = React.useReducer(
66
86
  (map: Map<string, null | string>, args: { key: string; value: null | string }) => {
67
87
  map.set(args.key, args.value);
@@ -84,18 +104,18 @@ function SelectCommitRangesInternal(props: Props) {
84
104
  Ink.useInput((input, key) => {
85
105
  const input_lower = input.toLowerCase();
86
106
 
87
- if (input_lower === SYMBOL.s) {
88
- // do not allow sync when inputting group title
89
- if (group_input) {
90
- return;
91
- }
107
+ // do not allow input when inputting group title
108
+ if (group_input) {
109
+ return;
110
+ }
92
111
 
112
+ if (input_lower === SYMBOL.s) {
93
113
  if (sync_status === "disabled") {
94
114
  return;
95
115
  }
96
116
 
97
117
  actions.set((state) => {
98
- const state_commit_map: Record<string, SimpleGroup> = {};
118
+ const state_commit_map: Record<string, CommitRangeGroup> = {};
99
119
 
100
120
  for (let [sha, id] of commit_map.entries()) {
101
121
  // console.debug({ sha, id });
@@ -104,14 +124,15 @@ function SelectCommitRangesInternal(props: Props) {
104
124
  if (!id) {
105
125
  id = props.commit_range.UNASSIGNED;
106
126
  const title = "allow_unassigned";
107
- state_commit_map[sha] = { id, title };
127
+ state_commit_map[sha] = { id, title, master_base: false };
108
128
  continue;
109
129
  }
110
130
 
111
131
  const group = group_list.find((g) => g.id === id);
112
132
  invariant(group, "group must exist");
113
133
  // console.debug({ group });
114
- state_commit_map[sha] = group;
134
+ const master_base = group_master_base.has(id);
135
+ state_commit_map[sha] = { ...group, master_base };
115
136
  }
116
137
 
117
138
  state.commit_map = state_commit_map;
@@ -127,6 +148,13 @@ function SelectCommitRangesInternal(props: Props) {
127
148
  return;
128
149
  }
129
150
 
151
+ // only allow setting base branch when on a created group
152
+ if (group.id !== props.commit_range.UNASSIGNED && input_lower === SYMBOL.m) {
153
+ const group = group_list[current_index];
154
+ set_group_master_base(group.id);
155
+ return;
156
+ }
157
+
130
158
  if (key.leftArrow) {
131
159
  const new_index = wrap_index(current_index - 1, group_list);
132
160
  const next_group = group_list[new_index];
@@ -196,6 +224,7 @@ function SelectCommitRangesInternal(props: Props) {
196
224
  // console.debug({ sync_status });
197
225
 
198
226
  const group = group_list[current_index];
227
+ const is_master_base = group_master_base.has(group.id);
199
228
 
200
229
  const multiselect_disabled = group_input;
201
230
  const multiselect_disableSelect = group.id === props.commit_range.UNASSIGNED;
@@ -281,6 +310,10 @@ function SelectCommitRangesInternal(props: Props) {
281
310
  <Ink.Text wrap="truncate-end" bold color={colors.white}>
282
311
  {group.title}
283
312
  </Ink.Text>
313
+ {!is_master_base ? null : (
314
+ // show base master
315
+ <Ink.Text color={colors.yellow}> (base: master)</Ink.Text>
316
+ )}
284
317
  </Ink.Box>
285
318
  </React.Fragment>
286
319
  )}
@@ -445,6 +478,27 @@ function SelectCommitRangesInternal(props: Props) {
445
478
  }}
446
479
  />
447
480
  </Ink.Box>
481
+
482
+ {group.id === props.commit_range.UNASSIGNED ? null : (
483
+ <Ink.Box>
484
+ <FormatText
485
+ wrapper={<Ink.Text color={colors.gray} />}
486
+ message={
487
+ is_master_base
488
+ ? "Press {m} to {reset} current PR base to stack position"
489
+ : "Press {m} to set current PR base to master"
490
+ }
491
+ values={{
492
+ m: (
493
+ <Ink.Text bold color={colors.green}>
494
+ {SYMBOL.m}
495
+ </Ink.Text>
496
+ ),
497
+ reset: <Ink.Text color={colors.yellow}>reset</Ink.Text>,
498
+ }}
499
+ />
500
+ </Ink.Box>
501
+ )}
448
502
  </Ink.Box>
449
503
  );
450
504
 
@@ -518,6 +572,7 @@ const SYMBOL = {
518
572
  enter: "Enter",
519
573
  c: "c",
520
574
  s: "s",
575
+ m: "m",
521
576
  };
522
577
 
523
578
  const S_TO_SYNC_VALUES = {
@@ -54,6 +54,17 @@ async function run() {
54
54
  // 2 create PR / edit PR
55
55
  // --------------------------------------
56
56
 
57
+ function create_git_push_command(base: string, target: string) {
58
+ const command = [`${base} push -f origin`];
59
+
60
+ if (argv.verify === false) {
61
+ command.push("--no-verify");
62
+ }
63
+
64
+ command.push(target);
65
+ return command;
66
+ }
67
+
57
68
  try {
58
69
  const before_push_tasks = [];
59
70
  for (const group of push_group_list) {
@@ -62,16 +73,21 @@ async function run() {
62
73
 
63
74
  await Promise.all(before_push_tasks);
64
75
 
65
- const git_push_command = [`git push -f origin`];
76
+ const git_push_target_list: Array<string> = [];
66
77
 
67
- if (argv.verify === false) {
68
- git_push_command.push("--no-verify");
69
- }
70
-
71
- for (const group of push_group_list) {
78
+ for (let i = 0; i < push_group_list.length; i++) {
79
+ const group = push_group_list[i];
72
80
  const last_commit = last(group.commits);
73
81
  invariant(last_commit, "last_commit must exist");
74
82
 
83
+ // push group in isolation if master_base is set
84
+ // for the first group (i > 0) we can skip this
85
+ // since it'll be based off master anyway
86
+ if (group.master_base && i > 0) {
87
+ await push_master_group(group);
88
+ continue;
89
+ }
90
+
75
91
  // explicit refs/heads head branch to avoid push failing
76
92
  //
77
93
  // ❯ git push -f origin --no-verify f6e249051b4820a03deb957ddebc19acfd7dfd7c:gs-ED2etrzv2
@@ -90,29 +106,49 @@ async function run() {
90
106
  // error: failed to push some refs to 'github.com:magus/git-multi-diff-playground.git'
91
107
  //
92
108
  const target = `${last_commit.sha}:refs/heads/${group.id}`;
93
- git_push_command.push(target);
109
+ git_push_target_list.push(target);
94
110
  }
95
111
 
96
- await cli(git_push_command);
112
+ if (git_push_target_list.length > 0) {
113
+ const push_target = git_push_target_list.join(" ");
114
+ const git_push_command = create_git_push_command("git", push_target);
115
+ await cli(git_push_command);
116
+ }
97
117
 
98
- const pr_url_list = push_group_list.map(get_group_url);
118
+ const pr_url_by_group_id: Record<string, string> = {};
99
119
 
100
120
  const after_push_tasks = [];
101
121
  for (const group of push_group_list) {
102
- after_push_tasks.push(after_push({ group, pr_url_list }));
122
+ after_push_tasks.push(after_push({ group, pr_url_by_group_id }));
103
123
  }
104
124
 
105
125
  await Promise.all(after_push_tasks);
106
126
 
107
- // finally, ensure all prs have the updated stack table from updated pr_url_list
127
+ // finally, ensure all prs have the updated stack table from updated pr_url_by_group_id
108
128
  // this step must come after the after_push since that step may create new PRs
109
129
  // we need the urls for all prs at this step so we run it after the after_push
130
+ const all_pr_groups: Array<CommitMetadataGroup> = [];
131
+ // collect all groups and existing pr urls
132
+ for (const group of commit_range.group_list) {
133
+ if (group.id !== commit_range.UNASSIGNED) {
134
+ // collect all groups
135
+ all_pr_groups.push(group);
136
+
137
+ if (group.pr) {
138
+ pr_url_by_group_id[group.id] = group.pr.url;
139
+ }
140
+ }
141
+ }
142
+
143
+ // get pr url list for all pr groups
144
+ const pr_url_list = all_pr_groups.map((g) => pr_url_by_group_id[g.id]);
145
+
146
+ // update PR body for all pr groups (not just push_group_list)
110
147
  const update_pr_body_tasks = [];
111
- for (let i = 0; i < push_group_list.length; i++) {
112
- const group = push_group_list[i];
148
+ for (let i = 0; i < all_pr_groups.length; i++) {
149
+ const group = all_pr_groups[i];
113
150
 
114
- // use the updated pr_url_list to get the actual selected_url
115
- const selected_url = pr_url_list[i];
151
+ const selected_url = pr_url_by_group_id[group.id];
116
152
 
117
153
  const task = update_pr_body({ group, selected_url, pr_url_list });
118
154
  update_pr_body_tasks.push(task);
@@ -183,7 +219,7 @@ async function run() {
183
219
  // Unable to sync.
184
220
  // ```
185
221
  //
186
- if (`origin/${group.pr.baseRefName}` !== master_branch) {
222
+ if (!is_master_base(group)) {
187
223
  await github.pr_edit({
188
224
  branch: group.id,
189
225
  base: master_branch,
@@ -192,34 +228,20 @@ async function run() {
192
228
  }
193
229
  }
194
230
 
195
- async function after_push(args: { group: CommitMetadataGroup; pr_url_list: Array<string> }) {
196
- const { group, pr_url_list } = args;
231
+ async function after_push(args: {
232
+ group: CommitMetadataGroup;
233
+ pr_url_by_group_id: Record<string, string>;
234
+ }) {
235
+ const { group } = args;
197
236
 
198
237
  invariant(group.base, "group.base must exist");
199
238
 
200
- const selected_url = get_group_url(group);
201
-
202
239
  if (group.pr) {
203
- if (`origin/${group.pr.baseRefName}` !== master_branch) {
240
+ if (!is_master_base(group)) {
204
241
  // ensure base matches pr in github
205
- await github.pr_edit({
206
- branch: group.id,
207
- base: group.base,
208
- body: StackSummaryTable.write({
209
- body: group.pr.body,
210
- pr_url_list,
211
- selected_url,
212
- }),
213
- });
242
+ await github.pr_edit({ branch: group.id, base: group.base });
214
243
  } else {
215
- await github.pr_edit({
216
- branch: group.id,
217
- body: StackSummaryTable.write({
218
- body: group.pr.body,
219
- pr_url_list,
220
- selected_url,
221
- }),
222
- });
244
+ await github.pr_edit({ branch: group.id });
223
245
  }
224
246
  } else {
225
247
  // create pr in github
@@ -235,13 +257,8 @@ async function run() {
235
257
  throw new Error("unable to create pr");
236
258
  }
237
259
 
238
- // update pr_url_list with created pr_url
239
- for (let i = 0; i < pr_url_list.length; i++) {
240
- const url = pr_url_list[i];
241
- if (url === selected_url) {
242
- pr_url_list[i] = pr_url;
243
- }
244
- }
260
+ // update pr_url_by_group_id with created pr_url
261
+ args.pr_url_by_group_id[group.id] = pr_url;
245
262
  }
246
263
  }
247
264
 
@@ -262,10 +279,12 @@ async function run() {
262
279
  selected_url,
263
280
  });
264
281
 
282
+ const debug_meta = `${group.id} ${selected_url}`;
283
+
265
284
  if (update_body === body) {
266
- actions.debug(`Skipping body update for ${selected_url}`);
285
+ actions.debug(`Skipping body update ${debug_meta}`);
267
286
  } else {
268
- actions.debug(`Update body for ${selected_url}`);
287
+ actions.debug(`Update body ${debug_meta}`);
269
288
 
270
289
  await github.pr_edit({
271
290
  branch: group.id,
@@ -274,7 +293,40 @@ async function run() {
274
293
  });
275
294
  }
276
295
  }
296
+
297
+ function is_master_base(group: CommitMetadataGroup) {
298
+ if (!group.pr) {
299
+ return false;
300
+ }
301
+
302
+ return group.master_base || `origin/${group.pr.baseRefName}` === master_branch;
303
+ }
304
+
305
+ async function push_master_group(group: CommitMetadataGroup) {
306
+ const worktree_path = `.git/git-stack-worktrees/push_master_group`;
307
+
308
+ // ensure previous instance of worktree is removed
309
+ await cli(`git worktree remove --force ${worktree_path}`, { ignoreExitCode: true });
310
+
311
+ // create temp worktree at master (or group.base if you prefer)
312
+ await cli(`git worktree add -f ${worktree_path} ${master_branch}`);
313
+
314
+ try {
315
+ // cherry-pick the group commits onto that base
316
+ const cp_commit_list = group.commits.map((c) => c.sha);
317
+ await cli(`git -C ${worktree_path} cherry-pick ${cp_commit_list}`);
318
+
319
+ `git -C ${worktree_path} push -f origin HEAD:refs/heads/${group.id}`;
320
+
321
+ const push_target = `HEAD:refs/heads/${group.id}`;
322
+ const git_push_command = create_git_push_command(`git -C ${worktree_path}`, push_target);
323
+
324
+ await cli(git_push_command);
325
+ } finally {
326
+ // clean up even if push fails
327
+ await cli(`git worktree remove --force ${worktree_path}`);
328
+ }
329
+ }
277
330
  }
278
331
 
279
332
  type CommitMetadataGroup = CommitMetadata.CommitRange["group_list"][number];
280
- const get_group_url = (group: CommitMetadataGroup) => group.pr?.url || group.id;