git-stack-cli 2.7.8 → 2.8.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": "2.7.8",
3
+ "version": "2.8.0",
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
  }
@@ -18,10 +18,9 @@ process.chdir(REPO_ROOT);
18
18
 
19
19
  // require clean git status besides changes to package.json version
20
20
  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
- );
21
+ if (!/^M\s+package.json$/.test(git_status.stdout)) {
22
+ console.error("please commit local changes");
23
+ console.error("only update package.json version before running release");
25
24
  process.exit(4);
26
25
  }
27
26
 
@@ -55,6 +54,9 @@ for (const filepath of package_json.files) {
55
54
  }
56
55
  }
57
56
 
57
+ // ensure npm is authenticated
58
+ await spawn(`npm whoami`);
59
+
58
60
  process.chdir(REPO_ROOT);
59
61
 
60
62
  await spawn.sync(`git commit -a -m ${version}`);
@@ -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,18 @@ async function run() {
62
73
 
63
74
  await Promise.all(before_push_tasks);
64
75
 
65
- const git_push_command = [`git push -f origin`];
66
-
67
- if (argv.verify === false) {
68
- git_push_command.push("--no-verify");
69
- }
76
+ const git_push_target_list: Array<string> = [];
70
77
 
71
78
  for (const group of push_group_list) {
72
79
  const last_commit = last(group.commits);
73
80
  invariant(last_commit, "last_commit must exist");
74
81
 
82
+ // push group in isolation if master_base is set
83
+ if (group.master_base) {
84
+ await push_master_group(group);
85
+ continue;
86
+ }
87
+
75
88
  // explicit refs/heads head branch to avoid push failing
76
89
  //
77
90
  // ❯ git push -f origin --no-verify f6e249051b4820a03deb957ddebc19acfd7dfd7c:gs-ED2etrzv2
@@ -90,29 +103,49 @@ async function run() {
90
103
  // error: failed to push some refs to 'github.com:magus/git-multi-diff-playground.git'
91
104
  //
92
105
  const target = `${last_commit.sha}:refs/heads/${group.id}`;
93
- git_push_command.push(target);
106
+ git_push_target_list.push(target);
94
107
  }
95
108
 
96
- await cli(git_push_command);
109
+ if (git_push_target_list.length > 0) {
110
+ const push_target = git_push_target_list.join(" ");
111
+ const git_push_command = create_git_push_command("git", push_target);
112
+ await cli(git_push_command);
113
+ }
97
114
 
98
- const pr_url_list = push_group_list.map(get_group_url);
115
+ const pr_url_by_group_id: Record<string, string> = {};
99
116
 
100
117
  const after_push_tasks = [];
101
118
  for (const group of push_group_list) {
102
- after_push_tasks.push(after_push({ group, pr_url_list }));
119
+ after_push_tasks.push(after_push({ group, pr_url_by_group_id }));
103
120
  }
104
121
 
105
122
  await Promise.all(after_push_tasks);
106
123
 
107
- // finally, ensure all prs have the updated stack table from updated pr_url_list
124
+ // finally, ensure all prs have the updated stack table from updated pr_url_by_group_id
108
125
  // this step must come after the after_push since that step may create new PRs
109
126
  // we need the urls for all prs at this step so we run it after the after_push
127
+ const all_pr_groups: Array<CommitMetadataGroup> = [];
128
+ // collect all groups and existing pr urls
129
+ for (const group of commit_range.group_list) {
130
+ if (group.id !== commit_range.UNASSIGNED) {
131
+ // collect all groups
132
+ all_pr_groups.push(group);
133
+
134
+ if (group.pr) {
135
+ pr_url_by_group_id[group.id] = group.pr.url;
136
+ }
137
+ }
138
+ }
139
+
140
+ // get pr url list for all pr groups
141
+ const pr_url_list = all_pr_groups.map((g) => pr_url_by_group_id[g.id]);
142
+
143
+ // update PR body for all pr groups (not just push_group_list)
110
144
  const update_pr_body_tasks = [];
111
- for (let i = 0; i < push_group_list.length; i++) {
112
- const group = push_group_list[i];
145
+ for (let i = 0; i < all_pr_groups.length; i++) {
146
+ const group = all_pr_groups[i];
113
147
 
114
- // use the updated pr_url_list to get the actual selected_url
115
- const selected_url = pr_url_list[i];
148
+ const selected_url = pr_url_by_group_id[group.id];
116
149
 
117
150
  const task = update_pr_body({ group, selected_url, pr_url_list });
118
151
  update_pr_body_tasks.push(task);
@@ -183,7 +216,7 @@ async function run() {
183
216
  // Unable to sync.
184
217
  // ```
185
218
  //
186
- if (`origin/${group.pr.baseRefName}` !== master_branch) {
219
+ if (!is_master_base(group)) {
187
220
  await github.pr_edit({
188
221
  branch: group.id,
189
222
  base: master_branch,
@@ -192,34 +225,20 @@ async function run() {
192
225
  }
193
226
  }
194
227
 
195
- async function after_push(args: { group: CommitMetadataGroup; pr_url_list: Array<string> }) {
196
- const { group, pr_url_list } = args;
228
+ async function after_push(args: {
229
+ group: CommitMetadataGroup;
230
+ pr_url_by_group_id: Record<string, string>;
231
+ }) {
232
+ const { group } = args;
197
233
 
198
234
  invariant(group.base, "group.base must exist");
199
235
 
200
- const selected_url = get_group_url(group);
201
-
202
236
  if (group.pr) {
203
- if (`origin/${group.pr.baseRefName}` !== master_branch) {
237
+ if (!is_master_base(group)) {
204
238
  // 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
- });
239
+ await github.pr_edit({ branch: group.id, base: group.base });
214
240
  } 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
- });
241
+ await github.pr_edit({ branch: group.id });
223
242
  }
224
243
  } else {
225
244
  // create pr in github
@@ -235,13 +254,8 @@ async function run() {
235
254
  throw new Error("unable to create pr");
236
255
  }
237
256
 
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
- }
257
+ // update pr_url_by_group_id with created pr_url
258
+ args.pr_url_by_group_id[group.id] = pr_url;
245
259
  }
246
260
  }
247
261
 
@@ -262,10 +276,12 @@ async function run() {
262
276
  selected_url,
263
277
  });
264
278
 
279
+ const debug_meta = `${group.id} ${selected_url}`;
280
+
265
281
  if (update_body === body) {
266
- actions.debug(`Skipping body update for ${selected_url}`);
282
+ actions.debug(`Skipping body update ${debug_meta}`);
267
283
  } else {
268
- actions.debug(`Update body for ${selected_url}`);
284
+ actions.debug(`Update body ${debug_meta}`);
269
285
 
270
286
  await github.pr_edit({
271
287
  branch: group.id,
@@ -274,7 +290,40 @@ async function run() {
274
290
  });
275
291
  }
276
292
  }
293
+
294
+ function is_master_base(group: CommitMetadataGroup) {
295
+ if (!group.pr) {
296
+ return false;
297
+ }
298
+
299
+ return group.master_base || `origin/${group.pr.baseRefName}` === master_branch;
300
+ }
301
+
302
+ async function push_master_group(group: CommitMetadataGroup) {
303
+ const worktree_path = `.git/git-stack-worktrees/push_master_group`;
304
+
305
+ // ensure previous instance of worktree is removed
306
+ await cli(`git worktree remove --force ${worktree_path}`, { ignoreExitCode: true });
307
+
308
+ // create temp worktree at master (or group.base if you prefer)
309
+ await cli(`git worktree add -f ${worktree_path} ${master_branch}`);
310
+
311
+ try {
312
+ // cherry-pick the group commits onto that base
313
+ const cp_commit_list = group.commits.map((c) => c.sha);
314
+ await cli(`git -C ${worktree_path} cherry-pick ${cp_commit_list}`);
315
+
316
+ `git -C ${worktree_path} push -f origin HEAD:refs/heads/${group.id}`;
317
+
318
+ const push_target = `HEAD:refs/heads/${group.id}`;
319
+ const git_push_command = create_git_push_command(`git -C ${worktree_path}`, push_target);
320
+
321
+ await cli(git_push_command);
322
+ } finally {
323
+ // clean up even if push fails
324
+ await cli(`git worktree remove --force ${worktree_path}`);
325
+ }
326
+ }
277
327
  }
278
328
 
279
329
  type CommitMetadataGroup = CommitMetadata.CommitRange["group_list"][number];
280
- const get_group_url = (group: CommitMetadataGroup) => group.pr?.url || group.id;
@@ -14,10 +14,16 @@ type CommitGroup = {
14
14
  base: null | string;
15
15
  dirty: boolean;
16
16
  commits: Array<git.Commit>;
17
+ master_base: boolean;
17
18
  };
18
19
 
19
- export type SimpleGroup = { id: string; title: string };
20
- type CommitGroupMap = { [sha: string]: SimpleGroup };
20
+ type CommitRangeGroup = {
21
+ id: string;
22
+ title: string;
23
+ master_base: boolean;
24
+ };
25
+
26
+ type CommitGroupMap = { [sha: string]: CommitRangeGroup };
21
27
 
22
28
  export async function range(commit_group_map?: CommitGroupMap) {
23
29
  // gather all open prs in repo first
@@ -25,6 +31,7 @@ export async function range(commit_group_map?: CommitGroupMap) {
25
31
  await github.pr_list();
26
32
 
27
33
  const master_branch = Store.getState().master_branch;
34
+ const master_branch_name = master_branch.replace(/^origin\//, "");
28
35
  const commit_list = await git.get_commits(`${master_branch}..HEAD`);
29
36
 
30
37
  const pr_lookup: Record<string, void | PullRequest> = {};
@@ -37,6 +44,7 @@ export async function range(commit_group_map?: CommitGroupMap) {
37
44
  for (const commit of commit_list) {
38
45
  let id = commit.branch_id;
39
46
  let title = commit.title || id;
47
+ let master_base = commit.master_base;
40
48
 
41
49
  // console.debug({ commit, id });
42
50
 
@@ -47,6 +55,7 @@ export async function range(commit_group_map?: CommitGroupMap) {
47
55
  if (group) {
48
56
  id = group.id;
49
57
  title = group.title;
58
+ master_base = group.master_base;
50
59
  }
51
60
  }
52
61
 
@@ -75,6 +84,7 @@ export async function range(commit_group_map?: CommitGroupMap) {
75
84
  const group = group_map.get(id) || {
76
85
  id,
77
86
  title,
87
+ master_base,
78
88
  pr: null,
79
89
  base: null,
80
90
  dirty: false,
@@ -130,17 +140,20 @@ export async function range(commit_group_map?: CommitGroupMap) {
130
140
  }
131
141
 
132
142
  if (i === 0) {
133
- const master_branch_name = master_branch.replace(/^origin\//, "");
134
143
  group.base = master_branch_name;
135
144
  } else {
136
145
  const last_group = group_value_list[i - 1];
137
146
  // console.debug(" ", "last_group", last_group.pr?.title.substring(0, 40));
138
147
  // console.debug(" ", "last_group.id", last_group.id);
139
148
 
140
- // null out base when unassigned and after unassigned
141
- if (group.id === UNASSIGNED) {
149
+ if (group.master_base) {
150
+ // explicitly set base to master when master_base is true
151
+ group.base = master_branch_name;
152
+ } else if (group.id === UNASSIGNED) {
153
+ // null out base when unassigned and after unassigned
142
154
  group.base = null;
143
155
  } else if (last_group.base === null) {
156
+ // null out base when last group base is null
144
157
  group.base = null;
145
158
  } else {
146
159
  group.base = last_group.id;
@@ -155,7 +168,19 @@ export async function range(commit_group_map?: CommitGroupMap) {
155
168
  group.dirty = true;
156
169
  } else if (group.pr.baseRefName !== group.base) {
157
170
  group.dirty = true;
171
+ } else if (group.master_base) {
172
+ group.pr.number;
173
+ // master_base groups cannot be compared by commit sha
174
+ // instead compare the literal diff local against origin
175
+ // gh pr diff --color=never 110
176
+ // git --no-pager diff --color=never 00c8fe0~1..00c8fe0 | pbcopy
177
+ const diff_github = await github.pr_diff(group.pr.number);
178
+ const diff_local = await git.get_diff(group.commits);
179
+ if (diff_github !== diff_local) {
180
+ group.dirty = true;
181
+ }
158
182
  } else {
183
+ // comapre literal commit shas in group
159
184
  for (let i = 0; i < group.pr.commits.length; i++) {
160
185
  const pr_commit = group.pr.commits[i];
161
186
  const local_commit = group.commits[i];