git-review-workflow 0.1.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.
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # git review finish — extract your review edits from the current review/* branch.
4
+ #
5
+ # By default it creates review-fixes/<branch> on top of the PR tip with your
6
+ # edits staged. With --onto-source it stages your edits on the PR branch itself
7
+ # instead. Either way the result stays local; review and commit it yourself.
8
+ #
9
+ # --abort undoes the last finish and drops you back on review/<branch> exactly
10
+ # where you were editing, the same way git merge --abort backs out a merge. To
11
+ # make that possible, a fresh finish first records an undo point (the review
12
+ # branch's HEAD, index and working tree, à la ORIG_HEAD) before it mutates
13
+ # anything, so --abort is a verbatim restore regardless of whole/step mode.
14
+ #
15
+ set -eu
16
+
17
+ prog="git review finish"
18
+
19
+ usage() {
20
+ cat <<EOF
21
+ usage: $prog [--onto-source] [--resume | --abort [--force]]
22
+
23
+ Extract your review edits from the current review/* branch.
24
+
25
+ (default) create review-fixes/<branch> on top of the PR tip and stage your edits
26
+ --onto-source instead, stage your edits on the PR branch itself
27
+ --resume continue after resolving conflicts from a commit-by-commit replay
28
+ --abort undo the last finish and return to editing the review
29
+ --force with --abort, discard changes made to the finish branch since
30
+ EOF
31
+ }
32
+
33
+ onto=0
34
+ resume=0
35
+ abort=0
36
+ force=0
37
+ while [ $# -gt 0 ]; do
38
+ case "$1" in
39
+ -h | --h)
40
+ usage
41
+ exit 0
42
+ ;;
43
+ --onto-source) onto=1 ;;
44
+ --resume) resume=1 ;;
45
+ --abort) abort=1 ;;
46
+ --force) force=1 ;;
47
+ *)
48
+ echo "error: unknown option $1" >&2
49
+ usage >&2
50
+ exit 1
51
+ ;;
52
+ esac
53
+ shift
54
+ done
55
+
56
+ cur="$(git symbolic-ref --quiet --short HEAD || true)"
57
+ [ -n "$cur" ] || {
58
+ echo "error: not on a branch" >&2
59
+ exit 1
60
+ }
61
+
62
+ # worktree_tree: the tree of the current working tree captured with `git add -A`
63
+ # (so brand-new untracked files count too), via a throwaway index that leaves the
64
+ # real index untouched. Used to snapshot state and to detect later divergence.
65
+ worktree_tree() {
66
+ _gd="$(git rev-parse --git-dir)"
67
+ _ti="$_gd/finish-wt-index.$$"
68
+ cp "$(git rev-parse --git-path index)" "$_ti"
69
+ GIT_INDEX_FILE="$_ti" git add -A
70
+ GIT_INDEX_FILE="$_ti" git write-tree
71
+ rm -f "$_ti"
72
+ }
73
+
74
+ # apply_review_patch FROM TO [git-apply-args...]
75
+ # Diff FROM..TO and apply it, routing the patch through a temp file rather than a
76
+ # shell variable. Capturing a binary patch with command substitution drops its
77
+ # NUL bytes and trailing newline, so git apply later rejects it ("corrupt binary
78
+ # patch") — which broke every review edit touching a binary file. An empty diff
79
+ # is a no-op success; the function returns git apply's exit status so callers can
80
+ # branch on conflicts.
81
+ apply_review_patch() {
82
+ _from="$1"
83
+ _to="$2"
84
+ shift 2
85
+ _pf="$(git rev-parse --git-dir)/finish-apply.$$"
86
+ git diff --binary "$_from" "$_to" >"$_pf"
87
+ if [ -s "$_pf" ]; then
88
+ if git apply "$@" <"$_pf"; then _rc=0; else _rc=$?; fi
89
+ else
90
+ _rc=0
91
+ fi
92
+ rm -f "$_pf"
93
+ return "$_rc"
94
+ }
95
+
96
+ # record_exit: once a finish has produced its branch ($1), snapshot that branch's
97
+ # HEAD and working tree onto the review branch's config so a later --abort can tell
98
+ # whether you have changed it since — and tell you up front that abort will not
99
+ # silently throw that work away. Recorded only on a completed finish, never for one
100
+ # stopped mid-conflict.
101
+ record_exit() {
102
+ git config "branch.$cur.reviewundoouthead" "$(git rev-parse HEAD)"
103
+ git config "branch.$cur.reviewundoouttree" "$(worktree_tree)"
104
+ echo "note: commits you make on $1 are kept, and uncommitted edits too while you stay on $1 — git review finish --abort refuses to discard them without --force." >&2
105
+ }
106
+
107
+ # --abort restores the undo point recorded by the finish being undone. It runs
108
+ # before the review/* check on purpose: after a finish you are on
109
+ # review-fixes/<branch> (or, with --onto-source, on the PR branch), never on
110
+ # review/<branch>.
111
+ do_abort() {
112
+ # Find the review branch the undo point belongs to from wherever we landed
113
+ # after the finish.
114
+ case "$cur" in
115
+ review-fixes/*) asrc="${cur#review-fixes/}" ;;
116
+ review/*) asrc="${cur#review/}" ;;
117
+ *) asrc="$cur" ;;
118
+ esac
119
+
120
+ rb="review/$asrc"
121
+ head="$(git config "branch.$rb.reviewundohead" || true)"
122
+ [ -n "$head" ] || {
123
+ echo "error: no finish to abort (no undo point for $rb)" >&2
124
+ exit 1
125
+ }
126
+ git rev-parse --verify --quiet "refs/heads/$rb" >/dev/null || {
127
+ echo "error: $rb is gone; cannot restore the review" >&2
128
+ exit 1
129
+ }
130
+
131
+ # The pre-finish index and working tree live only in refs/review-undo/<src>/.
132
+ # If those refs were deleted by hand while the config undo record survived,
133
+ # there is nothing left to restore the review from. Resolve them up front, with
134
+ # --quiet, and bail with a clear pointer — otherwise rev-parse --verify dies
135
+ # below with an opaque "Needed a single revision", possibly only after
136
+ # reset --hard has already mutated the tree.
137
+ idxtree="$(git rev-parse --verify --quiet "refs/review-undo/$asrc/index^{tree}" || true)"
138
+ wttree="$(git rev-parse --verify --quiet "refs/review-undo/$asrc/worktree^{tree}" || true)"
139
+ if [ -z "$idxtree" ] || [ -z "$wttree" ]; then
140
+ echo "error: the undo snapshot for $rb is gone (refs/review-undo/$asrc deleted); cannot restore the review." >&2
141
+ echo "the leftover undo record is stale — run git review clean $asrc to clear it." >&2
142
+ exit 1
143
+ fi
144
+
145
+ kind="$(git config "branch.$rb.reviewundokind" || echo fixes)"
146
+ srccreated="$(git config "branch.$rb.reviewundosrccreated" || echo 0)"
147
+ editstep="$(git config "branch.$rb.reviewundoeditstep" || true)"
148
+ editold="$(git config "branch.$rb.reviewundoeditold" || true)"
149
+
150
+ # Guard: a finish leaves your edits on the branch it produced (review-fixes, or
151
+ # the PR branch with --onto-source). If you have since changed that branch — new
152
+ # commits, or an edited working tree — aborting would throw that work away. Like
153
+ # git branch -d, refuse and point at the work; --force tears it down anyway.
154
+ # Skipped when no exit state was recorded: a finish stopped mid-conflict never
155
+ # produced the branch, so --abort there discards the in-progress conflict
156
+ # resolution the way git rebase --abort does.
157
+ outhead="$(git config "branch.$rb.reviewundoouthead" || true)"
158
+ outtree="$(git config "branch.$rb.reviewundoouttree" || true)"
159
+ if [ "$force" -eq 0 ] && [ -n "$outhead" ]; then
160
+ if [ "$kind" = "onto-source" ]; then
161
+ outbranch="$asrc"
162
+ else
163
+ outbranch="review-fixes/$asrc"
164
+ fi
165
+ diverged=0
166
+ nowhead="$(git rev-parse --verify --quiet "refs/heads/$outbranch" || true)"
167
+ if [ -n "$nowhead" ] && [ "$nowhead" != "$outhead" ]; then
168
+ diverged=1
169
+ fi
170
+ # Only the working tree of the branch we are actually on is meaningful; if
171
+ # you switched away, fall back to the committed-divergence check above.
172
+ if [ "$diverged" -eq 0 ] && [ "$cur" = "$outbranch" ] && [ -n "$outtree" ] &&
173
+ [ "$(worktree_tree)" != "$outtree" ]; then
174
+ diverged=1
175
+ fi
176
+ if [ "$diverged" -eq 1 ]; then
177
+ echo "error: $outbranch has changes since the finish; aborting would discard them." >&2
178
+ echo "your work stays on $outbranch — switch there to keep it, or rerun with --force to discard." >&2
179
+ exit 1
180
+ fi
181
+ fi
182
+
183
+ # Return to the review branch and restore the exact pre-finish state: HEAD,
184
+ # then the working tree (read-tree -m -u brings back additions, edits,
185
+ # deletions and the captured untracked files), then the index on top.
186
+ # When already on the review branch — e.g. aborting a finish that stopped
187
+ # mid-conflict — skip the checkout (the index may hold unmerged entries that
188
+ # a same-branch checkout chokes on); the reset --hard below clears them.
189
+ if [ "$cur" != "$rb" ]; then
190
+ git checkout -q -f "$rb"
191
+ fi
192
+ git reset -q --hard "$head"
193
+ git read-tree -m -u "$wttree"
194
+ git read-tree "$idxtree"
195
+
196
+ # Restore the current step's banked edit ref to what it was before finish
197
+ # banked it (or remove it if finish was the one that created it).
198
+ if [ -n "$editstep" ]; then
199
+ eref="refs/review-edits/$asrc/$editstep"
200
+ if [ "$editold" = "none" ] || [ -z "$editold" ]; then
201
+ git update-ref -d "$eref" 2>/dev/null || true
202
+ else
203
+ git update-ref "$eref" "$editold"
204
+ fi
205
+ fi
206
+
207
+ # Drop the branch finish created, leaving only the review/<branch> you are
208
+ # back on. --onto-source deletes the PR branch when finish created it; when it
209
+ # already existed, finish staged onto it without committing and left it at the
210
+ # reviewed tip ($outhead), so reset it back there — this also discards any
211
+ # commits made on it since, which is what reaching here (untouched, or --force)
212
+ # means. Skipped when no exit state was recorded (mid-conflict never touched
213
+ # the PR branch).
214
+ if [ "$kind" = "onto-source" ]; then
215
+ if [ "$srccreated" = "1" ] &&
216
+ git rev-parse --verify --quiet "refs/heads/$asrc" >/dev/null; then
217
+ git branch -D "$asrc" >/dev/null
218
+ elif [ -n "$outhead" ] &&
219
+ git rev-parse --verify --quiet "refs/heads/$asrc" >/dev/null; then
220
+ git update-ref "refs/heads/$asrc" "$outhead"
221
+ fi
222
+ elif git rev-parse --verify --quiet "refs/heads/review-fixes/$asrc" >/dev/null; then
223
+ git branch -D "review-fixes/$asrc" >/dev/null
224
+ fi
225
+
226
+ # Clear a mid-conflict marker (finish may have been aborted part-way) and the
227
+ # whole undo record.
228
+ git config --unset "branch.$rb.reviewresume" 2>/dev/null || true
229
+ for k in reviewundohead reviewundokind reviewundosrccreated \
230
+ reviewundoeditstep reviewundoeditold reviewundoouthead reviewundoouttree; do
231
+ git config --unset "branch.$rb.$k" 2>/dev/null || true
232
+ done
233
+ git for-each-ref --format='%(refname)' "refs/review-undo/$asrc/" |
234
+ while read -r ref; do
235
+ git update-ref -d "$ref"
236
+ done
237
+
238
+ echo "aborted finish; back on $rb — keep editing, then run git review finish"
239
+ }
240
+
241
+ if [ "$abort" -eq 1 ]; then
242
+ if [ "$resume" -eq 1 ] || [ "$onto" -eq 1 ]; then
243
+ echo "error: --abort takes no other options" >&2
244
+ exit 1
245
+ fi
246
+ do_abort
247
+ exit 0
248
+ fi
249
+
250
+ if [ "$force" -eq 1 ]; then
251
+ echo "error: --force only applies to --abort" >&2
252
+ exit 1
253
+ fi
254
+
255
+ case "$cur" in
256
+ review/*) ;;
257
+ *)
258
+ echo "error: not on a review/* branch (HEAD is $cur)" >&2
259
+ exit 1
260
+ ;;
261
+ esac
262
+
263
+ src="$(git config "branch.$cur.reviewsource" || true)"
264
+ tip="$(git config "branch.$cur.reviewtip" || true)"
265
+ if [ -z "$src" ] || [ -z "$tip" ]; then
266
+ echo "error: missing review metadata; was $cur created with git review start?" >&2
267
+ exit 1
268
+ fi
269
+
270
+ # A compare review (git review compare) has no writable source branch — it only
271
+ # stages a diff to read — so there is nothing to extract. Refuse explicitly rather
272
+ # than producing an empty or meaningless review-fixes branch.
273
+ if [ "$(git config "branch.$cur.reviewreadonly" || true)" = "1" ]; then
274
+ echo "error: $cur is a read-only compare review; there is nothing to write back" >&2
275
+ echo "compare just stages a diff to read — run git review clean ${cur#review/} when done." >&2
276
+ exit 1
277
+ fi
278
+
279
+ # Shared helpers, sourced from the libexec directory the dispatcher points us at,
280
+ # so the step block below validates its metadata through load_step_review_meta —
281
+ # the same guard the other step commands use — instead of reading the keys raw.
282
+ # shellcheck source=../git-review-lib.sh
283
+ . "${GIT_REVIEW_LIBEXEC:?}/git-review-lib.sh"
284
+
285
+ # A step review records reviewmode=step alongside reviewstart/reviewcount/
286
+ # reviewstep; a whole review records none of those keys. If reviewmode was lost to
287
+ # a hand-edit but the step keys survive, we would fall through to the whole-mode
288
+ # tail below and diff the current step commit (not the tip) against the tip —
289
+ # silently reversing the author's later commits into the extracted fix. Report the
290
+ # inconsistency rather than producing a corrupt patch.
291
+ mode="$(git config "branch.$cur.reviewmode" || true)"
292
+ if [ "$mode" != "step" ]; then
293
+ for k in reviewstart reviewcount reviewstep; do
294
+ [ -z "$(git config "branch.$cur.$k" || true)" ] || {
295
+ echo "error: corrupt review metadata: $cur has step-review keys but reviewmode is not 'step'" >&2
296
+ exit 1
297
+ }
298
+ done
299
+ fi
300
+
301
+ # record_undo: snapshot everything finish is about to mutate so --abort can put
302
+ # it back verbatim — the review branch's HEAD, its index, and its working tree
303
+ # (captured via a throwaway index so brand-new untracked edits are included).
304
+ # The trees are pinned under refs/review-undo/<src>/ so gc cannot drop them.
305
+ record_undo() {
306
+ wttree="$(worktree_tree)"
307
+ idxtree="$(git write-tree)"
308
+ orig="$(git rev-parse HEAD)"
309
+
310
+ git update-ref "refs/review-undo/$src/head" "$orig"
311
+ git update-ref "refs/review-undo/$src/index" \
312
+ "$(git commit-tree "$idxtree" -m 'git review finish undo (index)')"
313
+ git update-ref "refs/review-undo/$src/worktree" \
314
+ "$(git commit-tree "$wttree" -m 'git review finish undo (worktree)')"
315
+
316
+ git config "branch.$cur.reviewundohead" "$orig"
317
+ if [ "$onto" -eq 1 ]; then
318
+ git config "branch.$cur.reviewundokind" "onto-source"
319
+ # Remember whether the PR branch already existed, so --abort only deletes
320
+ # it when finish was the one that created it.
321
+ if git rev-parse --verify --quiet "refs/heads/$src" >/dev/null; then
322
+ git config "branch.$cur.reviewundosrccreated" 0
323
+ else
324
+ git config "branch.$cur.reviewundosrccreated" 1
325
+ fi
326
+ else
327
+ git config "branch.$cur.reviewundokind" "fixes"
328
+ git config "branch.$cur.reviewundosrccreated" 0
329
+ fi
330
+
331
+ # In step mode finish overwrites the current step's banked edit ref; record
332
+ # its prior value (or "none") so --abort restores exactly that.
333
+ if [ "$mode" = "step" ]; then
334
+ ustep="$(git config "branch.$cur.reviewstep" || echo 1)"
335
+ git config "branch.$cur.reviewundoeditstep" "$ustep"
336
+ uold="$(git rev-parse --verify --quiet "refs/review-edits/$src/$ustep" || true)"
337
+ git config "branch.$cur.reviewundoeditold" "${uold:-none}"
338
+ else
339
+ git config --unset "branch.$cur.reviewundoeditstep" 2>/dev/null || true
340
+ git config --unset "branch.$cur.reviewundoeditold" 2>/dev/null || true
341
+ fi
342
+ }
343
+
344
+ # A finish records an undo point that lives until you --abort it (or tear the
345
+ # review down). If one already exists when starting a fresh finish, a previous
346
+ # finish on this branch has not been resolved — switching back to review/<branch>
347
+ # by hand and finishing again would clobber the undo point with new HEAD/index/
348
+ # worktree snapshots while leaving the old exit state behind, so a later --abort
349
+ # would restore a mix of the two and could delete the earlier finish's branch.
350
+ # Refuse the way git refuses a second merge or rebase while one is in progress,
351
+ # and point at the way out.
352
+ if [ "$resume" -eq 0 ] && [ -n "$(git config "branch.$cur.reviewundohead" || true)" ]; then
353
+ if [ "$(git config "branch.$cur.reviewresume" || true)" = "conflict" ]; then
354
+ echo "error: a finish on $cur stopped at a conflict and is still pending." >&2
355
+ echo "resolve the markers and run git review finish --resume, or git review finish --abort to back out." >&2
356
+ else
357
+ echo "error: a previous finish on $cur has not been resolved." >&2
358
+ echo "run git review finish --abort to undo it (or git review abort to drop the whole review) before finishing again." >&2
359
+ fi
360
+ exit 1
361
+ fi
362
+
363
+ # Only a fresh finish records an undo point; --resume continues the same finish
364
+ # and must keep the point its first invocation took.
365
+ if [ "$resume" -eq 0 ]; then
366
+ record_undo
367
+ fi
368
+
369
+ # In commit-by-commit mode, bank the current commit's edits, move to the tip and
370
+ # replay every banked edit there, so what follows sees the same state as a normal
371
+ # whole-PR review. With --resume we skip the replay: the working tree already
372
+ # holds the resolved edits.
373
+ if [ "$mode" = "step" ] && [ "$resume" -eq 0 ]; then
374
+ # Validate and load the step metadata (count, step, start, commits) through the
375
+ # shared helper, so a hand-deleted or corrupt key is reported the same way the
376
+ # other step commands report it — instead of defaulting count to 0 (which
377
+ # silently discards every banked edit) or letting a missing reviewstart kill us
378
+ # under set -e with no message.
379
+ load_step_review_meta
380
+ cstep="$(printf '%s\n' "$commits" | sed -n "${step}p")"
381
+ git add -A
382
+ tree="$(git write-tree)"
383
+ if [ "$tree" != "$(git rev-parse "$cstep^{tree}")" ]; then
384
+ edit="$(git commit-tree "$tree" -p "$cstep" -m "review edits step $step")"
385
+ git update-ref "refs/review-edits/$src/$step" "$edit"
386
+ else
387
+ # Reverting the step back to a clean tree must clear any edits we banked
388
+ # earlier, or they resurrect in the replay below.
389
+ git update-ref -d "refs/review-edits/$src/$step" 2>/dev/null || true
390
+ fi
391
+ git reset -q --hard "$tip"
392
+ conflict=0
393
+ i=1
394
+ while [ "$i" -le "$count" ]; do
395
+ ref="refs/review-edits/$src/$i"
396
+ if git rev-parse --verify --quiet "$ref" >/dev/null; then
397
+ # Keep going on conflict so every edit lands; conflicts are left as
398
+ # markers for the user to resolve. apply_review_patch routes the diff
399
+ # through a file so a binary banked edit is not corrupted.
400
+ apply_review_patch "${ref}^" "$ref" --3way ||
401
+ conflict=1
402
+ fi
403
+ i=$((i + 1))
404
+ done
405
+ if [ "$conflict" -eq 1 ]; then
406
+ git config "branch.$cur.reviewresume" conflict
407
+ flags=""
408
+ [ "$onto" -eq 1 ] && flags="$flags --onto-source"
409
+ echo "conflict: some banked edits overlap the PR tip." >&2
410
+ echo "the working tree has conflict markers — resolve them, then run:" >&2
411
+ echo " git review finish --resume$flags" >&2
412
+ echo "(git review finish --abort here discards the resolution, like git rebase --abort.)" >&2
413
+ exit 1
414
+ fi
415
+ elif [ "$resume" -eq 1 ]; then
416
+ if [ "$(git config "branch.$cur.reviewresume" || true)" != "conflict" ]; then
417
+ echo "error: nothing to resume" >&2
418
+ exit 1
419
+ fi
420
+ git config --unset "branch.$cur.reviewresume" || true
421
+ fi
422
+
423
+ # Fold any edits into the review branch so we can diff them back out cleanly.
424
+ git add -A
425
+ if ! git diff --cached --quiet; then
426
+ git commit -q -m "review session ($src)"
427
+ fi
428
+
429
+ if git diff --quiet "$tip" "$cur"; then
430
+ # No edits came out of the review. The undo point recorded above guards a
431
+ # finish branch that will never be produced, so roll it back — otherwise it
432
+ # would block a later finish on this branch (and leave nothing meaningful to
433
+ # --abort). Then honour --onto-source by leaving you on the PR branch, the
434
+ # whole point of the flag, instead of stranding you on review/<branch>.
435
+ git config --unset "branch.$cur.reviewresume" 2>/dev/null || true
436
+ for k in reviewundohead reviewundokind reviewundosrccreated \
437
+ reviewundoeditstep reviewundoeditold reviewundoouthead reviewundoouttree; do
438
+ git config --unset "branch.$cur.$k" 2>/dev/null || true
439
+ done
440
+ git for-each-ref --format='%(refname)' "refs/review-undo/$src/" |
441
+ while read -r ref; do
442
+ git update-ref -d "$ref"
443
+ done
444
+
445
+ if [ "$onto" -eq 1 ]; then
446
+ # Same guard as the edited path: never move onto a local $src that sits
447
+ # somewhere other than the reviewed tip.
448
+ if git rev-parse --verify --quiet "refs/heads/$src" >/dev/null; then
449
+ if [ "$(git rev-parse "$src")" != "$tip" ]; then
450
+ echo "error: local $src is not at the reviewed tip; move or delete it first" >&2
451
+ exit 1
452
+ fi
453
+ git switch -q "$src"
454
+ else
455
+ git switch -q -c "$src" "$tip"
456
+ fi
457
+ echo "no review changes to apply; on $src at the reviewed tip" >&2
458
+ exit 0
459
+ fi
460
+
461
+ echo "no review changes to apply" >&2
462
+ exit 0
463
+ fi
464
+
465
+ if [ "$onto" -eq 1 ]; then
466
+ if git rev-parse --verify --quiet "refs/heads/$src" >/dev/null; then
467
+ if [ "$(git rev-parse "$src")" != "$tip" ]; then
468
+ echo "error: local $src is not at the reviewed tip; move or delete it first" >&2
469
+ exit 1
470
+ fi
471
+ git switch -q "$src"
472
+ else
473
+ git switch -q -c "$src" "$tip"
474
+ fi
475
+ apply_review_patch "$tip" "$cur" --index --3way || {
476
+ echo "error: could not apply review changes onto $src" >&2
477
+ exit 1
478
+ }
479
+ record_exit "$src"
480
+ echo "$src ready with your edits staged — review and commit"
481
+ else
482
+ fb="review-fixes/$src"
483
+ git rev-parse --verify --quiet "refs/heads/$fb" >/dev/null && {
484
+ echo "error: $fb already exists; remove it first" >&2
485
+ exit 1
486
+ }
487
+ git switch -q -c "$fb" "$tip"
488
+ apply_review_patch "$tip" "$cur" --index --3way || {
489
+ echo "error: could not apply review changes onto $fb" >&2
490
+ exit 1
491
+ }
492
+ record_exit "$fb"
493
+ echo "$fb ready with your edits staged — review and commit"
494
+ fi