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,121 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # git-review-lib.sh — helpers shared by the git review step commands.
4
+ #
5
+ # This file is *sourced, never run*. The verbs that need the helpers below
6
+ # (start, next, prev, continue, compare) load it as
7
+ # "${GIT_REVIEW_LIBEXEC:?}/git-review-lib.sh" — GIT_REVIEW_LIBEXEC is exported by
8
+ # the git-review dispatcher before it execs the verb, and points at the real
9
+ # directory where the dispatcher, this lib and the git-review-verbs/ directory
10
+ # live together (installed as libexec, not on PATH). It only defines functions,
11
+ # so sourcing it has no side effects.
12
+
13
+ # show_commit <commit> <n> <total>
14
+ # Print a commit's diffstat first and its identifying header last, so the header
15
+ # stays next to the prompt instead of scrolling off the top when the diffstat is
16
+ # long for a commit that touches many files.
17
+ show_commit() {
18
+ git --no-pager show --stat --format='' "$1"
19
+ printf -- '----\n[%s/%s] %s\n%s\n\n%s\n----\nreview this commit, edit files, then run git review next\n' \
20
+ "$2" "$3" "$(git rev-parse --short "$1")" \
21
+ "$(git show -s --format='%an <%ae>' "$1")" \
22
+ "$(git show -s --format='%s%n%n%b' "$1")"
23
+ }
24
+
25
+ # load_step_review_meta
26
+ # Confirm HEAD is on a review/* branch started with --step and load its metadata
27
+ # into the globals the caller and goto_step rely on: cur, src, tip, start, count,
28
+ # step, commits and total. Exits with a diagnostic on any inconsistency (wrong
29
+ # branch, wrong mode, or missing/corrupt metadata).
30
+ load_step_review_meta() {
31
+ cur="$(git symbolic-ref --quiet --short HEAD || true)"
32
+ [ -n "$cur" ] || {
33
+ echo "error: not on a branch" >&2
34
+ exit 1
35
+ }
36
+ case "$cur" in
37
+ review/*) ;;
38
+ *)
39
+ echo "error: not on a review/* branch (HEAD is $cur)" >&2
40
+ exit 1
41
+ ;;
42
+ esac
43
+
44
+ mode="$(git config "branch.$cur.reviewmode" || true)"
45
+ [ "$mode" = "step" ] || {
46
+ echo "error: $cur was not started with git review start --step" >&2
47
+ exit 1
48
+ }
49
+
50
+ src="$(git config "branch.$cur.reviewsource" || true)"
51
+ tip="$(git config "branch.$cur.reviewtip" || true)"
52
+ start="$(git config "branch.$cur.reviewstart" || true)"
53
+ count="$(git config "branch.$cur.reviewcount" || true)"
54
+ step="$(git config "branch.$cur.reviewstep" || true)"
55
+
56
+ # A key deleted by a hand-edit (while reviewmode stays "step") would otherwise
57
+ # let set -e kill us silently mid-script; read with || true and report it.
58
+ if [ -z "$src" ] || [ -z "$tip" ] || [ -z "$start" ] || [ -z "$count" ]; then
59
+ echo "error: missing review metadata; was $cur created with git review start?" >&2
60
+ exit 1
61
+ fi
62
+
63
+ commits="$(git rev-list --reverse --first-parent --no-merges "$start..$tip")"
64
+
65
+ # Guard against a step that maps to no commit (corrupt config, hand-edited
66
+ # metadata): otherwise goto_step's sed yields an empty commit and git rev-parse
67
+ # '^{tree}' crashes mid-move.
68
+ total="$(printf '%s\n' "$commits" | grep -c .)"
69
+ case "$count" in
70
+ *[!0-9]*)
71
+ echo "error: corrupt review metadata: reviewcount is '$count', not a positive integer" >&2
72
+ exit 1
73
+ ;;
74
+ esac
75
+ [ "$count" -ge 1 ] || {
76
+ echo "error: corrupt review metadata: reviewcount is '$count', not a positive integer" >&2
77
+ exit 1
78
+ }
79
+ case "$step" in
80
+ '' | *[!0-9]*)
81
+ echo "error: corrupt review metadata: reviewstep is '$step', not a positive integer" >&2
82
+ exit 1
83
+ ;;
84
+ esac
85
+ if [ "$step" -lt 1 ] || [ "$step" -gt "$total" ]; then
86
+ echo "error: review step $step out of range (1..$total) — corrupt metadata?" >&2
87
+ exit 1
88
+ fi
89
+ }
90
+
91
+ # goto_step <target>
92
+ # Move a --step review to step <target>: bank the current commit's edits, reset
93
+ # clean to the target commit, restore the target's previously banked edits (if
94
+ # any), then soft-reset so its diff is staged. Relies on the globals set by
95
+ # load_step_review_meta (cur, src, count, step, commits).
96
+ goto_step() {
97
+ target="$1"
98
+ cstep="$(printf '%s\n' "$commits" | sed -n "${step}p")"
99
+ git add -A
100
+ tree="$(git write-tree)"
101
+ if [ "$tree" != "$(git rev-parse "$cstep^{tree}")" ]; then
102
+ edit="$(git commit-tree "$tree" -p "$cstep" -m "review edits step $step")"
103
+ git update-ref "refs/review-edits/$src/$step" "$edit"
104
+ else
105
+ # Reverting the step back to a clean tree must clear any edits we banked
106
+ # earlier, or they resurrect on the next visit / at git review finish.
107
+ git update-ref -d "refs/review-edits/$src/$step" 2>/dev/null || true
108
+ fi
109
+ ctarget="$(printf '%s\n' "$commits" | sed -n "${target}p")"
110
+ git reset -q --hard "$ctarget"
111
+ ref="refs/review-edits/$src/$target"
112
+ if git rev-parse --verify --quiet "$ref" >/dev/null; then
113
+ git diff --binary "${ref}^" "$ref" | git apply || {
114
+ echo "error: could not restore banked edits for step $target" >&2
115
+ exit 1
116
+ }
117
+ fi
118
+ git reset -q --soft "$ctarget^"
119
+ git config "branch.$cur.reviewstep" "$target"
120
+ show_commit "$ctarget" "$target" "$count"
121
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # git review abort — cancel the review on the current branch in one step.
4
+ #
5
+ # Returns you to the branch you started from (or the base), deletes the
6
+ # review/<branch> branch and its banked edits. Because the review was cancelled,
7
+ # it also rolls the --delta marker back to your last actual review.
8
+ #
9
+ set -eu
10
+
11
+ prog="git review abort"
12
+
13
+ usage() {
14
+ cat <<EOF
15
+ usage: $prog
16
+
17
+ Cancel the current review: return to the starting branch, then delete the
18
+ review/<branch> branch and any banked commit-by-commit edits.
19
+ EOF
20
+ }
21
+
22
+ case "${1:-}" in
23
+ -h | --h)
24
+ usage
25
+ exit 0
26
+ ;;
27
+ "") ;;
28
+ *)
29
+ echo "error: unexpected argument $1" >&2
30
+ usage >&2
31
+ exit 1
32
+ ;;
33
+ esac
34
+
35
+ cur="$(git symbolic-ref --quiet --short HEAD || true)"
36
+ case "$cur" in
37
+ review/*) ;;
38
+ *)
39
+ echo "error: not on a review/* branch (HEAD is ${cur:-detached})" >&2
40
+ exit 1
41
+ ;;
42
+ esac
43
+
44
+ src="$(git config "branch.$cur.reviewsource" || true)"
45
+ [ -n "$src" ] || {
46
+ echo "error: missing review metadata; was $cur created with git review start?" >&2
47
+ exit 1
48
+ }
49
+ base="$(git config "branch.$cur.reviewbase" || true)"
50
+ ret="$(git config "branch.$cur.reviewreturn" || true)"
51
+ prevreviewed="$(git config "branch.$cur.reviewprevreviewed" || true)"
52
+
53
+ # A local review tracks its delta progress under a separate config section
54
+ # (reviewworkflowlocal.*); roll back the matching one so a cancelled review never
55
+ # corrupts either side's progress.
56
+ if [ "$(git config "branch.$cur.reviewlocal" || true)" = "1" ]; then
57
+ markerkey="reviewworkflowlocal.$src.reviewed"
58
+ else
59
+ markerkey="reviewworkflow.$src.reviewed"
60
+ fi
61
+
62
+ target=""
63
+ if [ -n "$ret" ] && git rev-parse --verify --quiet "refs/heads/$ret" >/dev/null; then
64
+ target="$ret"
65
+ elif [ -n "$base" ] && git rev-parse --verify --quiet "refs/heads/$base" >/dev/null; then
66
+ target="$base"
67
+ fi
68
+ [ -n "$target" ] || {
69
+ echo "error: could not determine a branch to return to; switch away manually, then run git review clean $src" >&2
70
+ exit 1
71
+ }
72
+
73
+ git switch -q --discard-changes "$target"
74
+ git branch -D "$cur" >/dev/null
75
+
76
+ # This review was cancelled, not completed: undo the reviewed marker it set,
77
+ # restoring the tip of your last actual review (or clearing it if there was none)
78
+ # so a later --delta does not skip commits you never reviewed.
79
+ if [ -n "$prevreviewed" ]; then
80
+ git config "$markerkey" "$prevreviewed"
81
+ else
82
+ git config --unset "$markerkey" 2>/dev/null || true
83
+ fi
84
+
85
+ git for-each-ref --format='%(refname)' "refs/review-edits/$src/" | while read -r ref; do
86
+ git update-ref -d "$ref"
87
+ done
88
+
89
+ # Cancelling the review tears down everything it spawned. If you had already run
90
+ # git review finish, that left a review-fixes/<branch> and a finish undo point
91
+ # (refs/review-undo/<branch>/*) behind; drop them too so an abort leaves nothing
92
+ # dangling. The undo *config* rode on branch.review/<branch> and was already
93
+ # removed when the branch was deleted above.
94
+ if git rev-parse --verify --quiet "refs/heads/review-fixes/$src" >/dev/null; then
95
+ git branch -D "review-fixes/$src" >/dev/null
96
+ fi
97
+ git for-each-ref --format='%(refname)' "refs/review-undo/$src/" | while read -r ref; do
98
+ git update-ref -d "$ref"
99
+ done
100
+
101
+ echo "aborted review of $src; returned to $target"
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # git review clean — delete the review/<branch> and review-fixes/<branch> branches.
4
+ #
5
+ # With no <branch> it deletes all of them. It never deletes the branch you are
6
+ # currently on. The recorded last-reviewed tip (used by --delta) is left alone;
7
+ # discard it with git review forget --delta.
8
+ #
9
+ set -eu
10
+
11
+ prog="git review clean"
12
+
13
+ usage() {
14
+ cat <<EOF
15
+ usage: $prog [branch]
16
+
17
+ Delete the review/<branch> and review-fixes/<branch> branches.
18
+ With no <branch>, delete all of them.
19
+
20
+ The --delta marker is left untouched; use git review forget --delta to discard it.
21
+ EOF
22
+ }
23
+
24
+ arg=""
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ -h | --h)
28
+ usage
29
+ exit 0
30
+ ;;
31
+ -*)
32
+ echo "error: unknown option $1" >&2
33
+ usage >&2
34
+ exit 1
35
+ ;;
36
+ *)
37
+ if [ -z "$arg" ]; then
38
+ arg="$1"
39
+ else
40
+ echo "error: unexpected argument $1" >&2
41
+ exit 1
42
+ fi
43
+ ;;
44
+ esac
45
+ shift
46
+ done
47
+
48
+ cur="$(git symbolic-ref --quiet --short HEAD || true)"
49
+
50
+ if [ -n "$arg" ]; then
51
+ list="review/$arg review-fixes/$arg"
52
+ else
53
+ list="$(git for-each-ref --format='%(refname:short)' refs/heads/review/ refs/heads/review-fixes/)"
54
+ fi
55
+
56
+ # Track whether any cleanup actually happened so the closing message is honest:
57
+ # deleting orphaned refs counts as work even when no review branches exist.
58
+ cleaned=0
59
+
60
+ # No branches is not a stopping point: banked edit refs can still be orphaned
61
+ # and need dropping, so fall through to that cleanup instead of bailing here.
62
+ if [ -n "$list" ]; then
63
+ # Word splitting is intentional: branch names cannot contain whitespace.
64
+ # shellcheck disable=SC2086
65
+ for b in $list; do
66
+ git rev-parse --verify --quiet "refs/heads/$b" >/dev/null || continue
67
+ if [ "$b" = "$cur" ]; then
68
+ echo "skipping $b (currently checked out)" >&2
69
+ continue
70
+ fi
71
+ git branch -D "$b"
72
+ cleaned=1
73
+ done
74
+ fi
75
+
76
+ # Drop any banked commit-by-commit edit refs, plus any git review finish undo points
77
+ # (refs/review-undo/* and their branch.review/<branch>.reviewundo* config) left
78
+ # behind by a finish that was never aborted.
79
+ if [ -n "$arg" ]; then
80
+ editns="refs/review-edits/$arg/"
81
+ undons="refs/review-undo/$arg/"
82
+ undobranches="$arg"
83
+ else
84
+ editns="refs/review-edits/"
85
+ undons="refs/review-undo/"
86
+ undobranches="$(git for-each-ref --format='%(refname)' refs/review-undo/ |
87
+ sed -n 's#^refs/review-undo/\(.*\)/[^/]*$#\1#p' | sort -u)"
88
+ fi
89
+ for ns in "$editns" "$undons"; do
90
+ # Read refs into a variable first: a `... | while read` pipeline runs the
91
+ # loop body in a subshell, so a `cleaned=1` set there would not survive.
92
+ refs="$(git for-each-ref --format='%(refname)' "$ns")"
93
+ [ -n "$refs" ] || continue
94
+ echo "$refs" | while read -r ref; do
95
+ git update-ref -d "$ref"
96
+ done
97
+ cleaned=1
98
+ done
99
+ # Word splitting is intentional: branch names cannot contain whitespace.
100
+ # shellcheck disable=SC2086
101
+ for b in $undobranches; do
102
+ for k in reviewundohead reviewundokind reviewundosrccreated \
103
+ reviewundoeditstep reviewundoeditold reviewundoouthead reviewundoouttree; do
104
+ git config --unset "branch.review/$b.$k" 2>/dev/null || true
105
+ done
106
+ done
107
+
108
+ if [ "$cleaned" -eq 0 ]; then
109
+ # Saved reviews live in review-saved/*, a namespace git review clean deliberately
110
+ # leaves alone (it owns review/* and review-fixes/* only). Point at them when
111
+ # nothing else was found, so "no review branches" does not read as wrong to
112
+ # someone staring at a review-saved/<branch> they expected this to clear.
113
+ if [ -n "$arg" ]; then
114
+ saved="$(git for-each-ref --format='%(refname:short)' "refs/heads/review-saved/$arg")"
115
+ else
116
+ saved="$(git for-each-ref --format='%(refname:short)' refs/heads/review-saved/)"
117
+ fi
118
+ if [ -n "$saved" ]; then
119
+ echo "no review/ or review-fixes/ branches to clean; you have a saved review — resume it with git review continue or discard it with git review forget --saved" >&2
120
+ else
121
+ echo "no review branches found" >&2
122
+ fi
123
+ fi
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # git review compare <a> <b> — stage the diff between two commit-ish, read-only.
4
+ #
5
+ # Unlike git review start, compare has no writable source branch: it materialises
6
+ # the diff <a>..<b> (with <a> the lower bound, <b> the tip) as a staged, read-only
7
+ # review you can annotate inline. There is nothing to extract, so git review finish
8
+ # refuses on it. With --step it walks <a>..<b> one commit at a time, like start
9
+ # --step, advancing with git review next.
10
+ #
11
+ set -eu
12
+
13
+ prog="git review compare"
14
+
15
+ usage() {
16
+ cat <<EOF
17
+ usage: $prog <a> <b> [--step]
18
+
19
+ Stage the diff between two commit-ish on a review/<b> branch for inline reading.
20
+ This is a read-only review: there is no branch to write back to, so git review
21
+ finish refuses on it.
22
+
23
+ <a> the lower bound (a branch, tag or commit) — what to diff from
24
+ <b> the upper bound — what to diff to; names the review branch
25
+ --step read one commit at a time; advance with git review next
26
+ EOF
27
+ }
28
+
29
+ die() {
30
+ echo "error: $1" >&2
31
+ exit 1
32
+ }
33
+
34
+ a=""
35
+ b=""
36
+ have_a=0
37
+ have_b=0
38
+ step=0
39
+ # Collect the two positional commit-ish; mirror start's parser, including -- as the
40
+ # end-of-options separator so a commit-ish that starts with "-" stays positional.
41
+ add_positional() {
42
+ if [ "$have_a" -eq 0 ]; then
43
+ a="$1"
44
+ have_a=1
45
+ elif [ "$have_b" -eq 0 ]; then
46
+ b="$1"
47
+ have_b=1
48
+ else
49
+ die "unexpected argument $1"
50
+ fi
51
+ }
52
+ while [ $# -gt 0 ]; do
53
+ case "$1" in
54
+ -h | --h)
55
+ usage
56
+ exit 0
57
+ ;;
58
+ --step) step=1 ;;
59
+ --)
60
+ shift
61
+ while [ $# -gt 0 ]; do
62
+ add_positional "$1"
63
+ shift
64
+ done
65
+ break
66
+ ;;
67
+ -*)
68
+ echo "error: unknown option $1" >&2
69
+ usage >&2
70
+ exit 1
71
+ ;;
72
+ *)
73
+ add_positional "$1"
74
+ ;;
75
+ esac
76
+ shift
77
+ done
78
+
79
+ if [ "$have_a" -ne 1 ] || [ "$have_b" -ne 1 ]; then
80
+ die "need two commit-ish to compare: $prog <a> <b>"
81
+ fi
82
+
83
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
84
+
85
+ # Shared helpers, sourced from the libexec directory the dispatcher points us at.
86
+ # shellcheck source=../git-review-lib.sh
87
+ . "${GIT_REVIEW_LIBEXEC:?}/git-review-lib.sh"
88
+
89
+ acommit="$(git rev-parse --verify --quiet "$a^{commit}")" || die "unknown commit: $a"
90
+ bcommit="$(git rev-parse --verify --quiet "$b^{commit}")" || die "unknown commit: $b"
91
+ [ "$acommit" != "$bcommit" ] || die "$a and $b are the same commit; nothing to compare"
92
+
93
+ if ! git diff --quiet || ! git diff --cached --quiet; then
94
+ die "you have local changes; commit or stash them first"
95
+ fi
96
+
97
+ # Remember where to return to when the compare is aborted.
98
+ returnbranch="$(git symbolic-ref --quiet --short HEAD || true)"
99
+
100
+ # Pick a refname-safe identity for the review branch from <b> (the tip). A tag or
101
+ # branch name passes through unchanged, for a readable review/<b>; anything git
102
+ # would reject as a ref component (HEAD~3, a^, x:y) falls back to <b>'s short hash,
103
+ # so the branch and the per-step edit refs (refs/review-edits/<name>/*) are always
104
+ # valid. This name is the review's identity for status/list/clean, never a writeback
105
+ # target — compare is read-only.
106
+ if git check-ref-format "review/$b" 2>/dev/null; then
107
+ name="$b"
108
+ else
109
+ name="$(git rev-parse --short "$bcommit")"
110
+ fi
111
+
112
+ rb="review/$name"
113
+ git rev-parse --verify --quiet "refs/heads/$rb" >/dev/null &&
114
+ die "$rb already exists; run git review clean $name first"
115
+ git rev-parse --verify --quiet "refs/heads/review-saved/$name" >/dev/null &&
116
+ die "you have a saved review of $name; resume it with git review continue $name or discard it with git review forget --saved $name"
117
+
118
+ if [ "$step" -eq 1 ]; then
119
+ # --first-parent --no-merges: walk only the range's own commits, skipping merge
120
+ # commits, exactly like start --step so git review next/prev behave identically.
121
+ commits="$(git rev-list --reverse --first-parent --no-merges "$acommit..$bcommit")"
122
+ [ -n "$commits" ] || die "no commits to compare in range $a..$b"
123
+ count="$(printf '%s\n' "$commits" | wc -l | tr -d ' ')"
124
+ first="$(printf '%s\n' "$commits" | sed -n '1p')"
125
+
126
+ git switch -q -c "$rb" "$first" || die "could not create $rb"
127
+ git reset --soft HEAD^
128
+ git config "branch.$rb.reviewsource" "$name"
129
+ git config "branch.$rb.reviewbase" "$a"
130
+ git config "branch.$rb.reviewtip" "$bcommit"
131
+ git config "branch.$rb.reviewstart" "$acommit"
132
+ git config "branch.$rb.reviewmode" "step"
133
+ git config "branch.$rb.reviewcount" "$count"
134
+ git config "branch.$rb.reviewstep" "1"
135
+ git config "branch.$rb.reviewreadonly" "1"
136
+ if [ -n "$returnbranch" ]; then
137
+ git config "branch.$rb.reviewreturn" "$returnbranch"
138
+ fi
139
+
140
+ echo "$rb ready (compare $a..$b, read-only) — walking $count commit(s) one at a time"
141
+ show_commit "$first" 1 "$count"
142
+ exit 0
143
+ fi
144
+
145
+ # Whole range: create review/<name> at <b> and soft-reset to <a> so the staged diff
146
+ # is exactly <a>..<b>, with a clean working tree — the same shape as start, minus the
147
+ # merged-base folding (compare's bounds are explicit) and any writeback metadata.
148
+ git switch -q -c "$rb" "$bcommit" || die "could not create $rb"
149
+ git reset -q --soft "$acommit"
150
+
151
+ git config "branch.$rb.reviewsource" "$name"
152
+ git config "branch.$rb.reviewbase" "$a"
153
+ git config "branch.$rb.reviewtip" "$bcommit"
154
+ git config "branch.$rb.reviewreadonly" "1"
155
+ if [ -n "$returnbranch" ]; then
156
+ git config "branch.$rb.reviewreturn" "$returnbranch"
157
+ fi
158
+
159
+ echo "$rb ready (compare $a..$b, read-only) — the staged diff is for reading; git review finish will refuse"
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # git review continue — resume a review put aside with git review save.
4
+ #
5
+ # It turns review-saved/<branch> back into the active review/<branch>, restoring
6
+ # the exact state you left: in whole mode the staged PR diff with your edits in the
7
+ # working tree; in step mode the commit you were on, its edits, and every other
8
+ # banked edit — so review-next / review-prev keep working as before.
9
+ #
10
+ # With no argument it resumes the only saved review, or lists them if there is more
11
+ # than one. Name a branch to pick a specific one.
12
+ #
13
+ set -eu
14
+
15
+ prog="git review continue"
16
+
17
+ usage() {
18
+ cat <<EOF
19
+ usage: $prog [<branch>]
20
+
21
+ Resume a review saved with git review save. With no argument, resume the only
22
+ saved review (or list them if there is more than one).
23
+ EOF
24
+ }
25
+
26
+ die() {
27
+ echo "error: $1" >&2
28
+ exit 1
29
+ }
30
+
31
+ arg=""
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ -h | --h)
35
+ usage
36
+ exit 0
37
+ ;;
38
+ -*)
39
+ echo "error: unknown option $1" >&2
40
+ usage >&2
41
+ exit 1
42
+ ;;
43
+ *)
44
+ if [ -z "$arg" ]; then
45
+ arg="$1"
46
+ else
47
+ die "unexpected argument $1"
48
+ fi
49
+ ;;
50
+ esac
51
+ shift
52
+ done
53
+
54
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
55
+
56
+ # Shared helpers, sourced from the libexec directory the dispatcher points us at.
57
+ # shellcheck source=../git-review-lib.sh
58
+ . "${GIT_REVIEW_LIBEXEC:?}/git-review-lib.sh"
59
+
60
+ # Pick the saved review to resume.
61
+ if [ -n "$arg" ]; then
62
+ src="$arg"
63
+ saved="review-saved/$src"
64
+ git rev-parse --verify --quiet "refs/heads/$saved" >/dev/null ||
65
+ die "no saved review for $src (looked for $saved)"
66
+ else
67
+ branches="$(git for-each-ref --format='%(refname:short)' refs/heads/review-saved/)"
68
+ [ -n "$branches" ] || die "no saved reviews; save one with git review save"
69
+ n="$(printf '%s\n' "$branches" | wc -l | tr -d ' ')"
70
+ if [ "$n" -gt 1 ]; then
71
+ echo "more than one saved review — name the one to resume:" >&2
72
+ printf '%s\n' "$branches" | sed -n 's#^review-saved/# git review continue #p' >&2
73
+ exit 1
74
+ fi
75
+ saved="$branches"
76
+ src="${saved#review-saved/}"
77
+ fi
78
+
79
+ rb="review/$src"
80
+ git rev-parse --verify --quiet "refs/heads/$rb" >/dev/null &&
81
+ die "$rb is already active; finish or abort it before resuming the saved one"
82
+
83
+ # Resuming materialises a working tree, so refuse to clobber local changes.
84
+ if ! git diff --quiet || ! git diff --cached --quiet; then
85
+ die "you have local changes; commit or stash them first"
86
+ fi
87
+
88
+ mode="$(git config "branch.$saved.reviewmode" || echo whole)"
89
+ tip="$(git config "branch.$saved.reviewtip" || true)"
90
+ # A saved review missing its tip (hand-edited or half-deleted config) would let
91
+ # set -e kill us silently while restoring; report it instead.
92
+ [ -n "$tip" ] || die "missing review metadata for $saved; was it created with git review start?"
93
+
94
+ # restore_meta <key>: copy branch.<saved>.<key> to branch.<rb>.<key> when set.
95
+ # Explicit if so the function returns 0 for absent optional keys (set -e safety).
96
+ restore_meta() {
97
+ v="$(git config "branch.$saved.$1" || true)"
98
+ if [ -n "$v" ]; then
99
+ git config "branch.$rb.$1" "$v"
100
+ fi
101
+ }
102
+
103
+ if [ "$mode" = "step" ]; then
104
+ start="$(git config "branch.$saved.reviewstart" || true)"
105
+ count="$(git config "branch.$saved.reviewcount" || true)"
106
+ step="$(git config "branch.$saved.reviewstep" || true)"
107
+ if [ -z "$start" ] || [ -z "$count" ] || [ -z "$step" ]; then
108
+ die "missing review metadata for $saved; was it created with git review start?"
109
+ fi
110
+
111
+ # Bring the banked edits back to where git review next/prev expect them.
112
+ git for-each-ref --format='%(refname)' "refs/review-saved-edits/$src/" | while read -r ref; do
113
+ n="${ref##*/}"
114
+ git update-ref "refs/review-edits/$src/$n" "$(git rev-parse "$ref")"
115
+ git update-ref -d "$ref"
116
+ done
117
+
118
+ commits="$(git rev-list --reverse --first-parent --no-merges "$start..$tip")"
119
+ cstep="$(printf '%s\n' "$commits" | sed -n "${step}p")"
120
+
121
+ # Land on the saved step exactly like review-next's goto_step does: hard-reset
122
+ # to the commit, re-apply its banked edits, then soft-reset so its diff is staged.
123
+ git switch -q -c "$rb" "$cstep"
124
+ ref="refs/review-edits/$src/$step"
125
+ if git rev-parse --verify --quiet "$ref" >/dev/null; then
126
+ git diff --binary "${ref}^" "$ref" | git apply ||
127
+ die "could not restore your edits for step $step"
128
+ fi
129
+ git reset -q --soft "$cstep^"
130
+
131
+ git config "branch.$rb.reviewmode" step
132
+ restore_meta reviewstart
133
+ restore_meta reviewcount
134
+ git config "branch.$rb.reviewstep" "$step"
135
+ restore_meta reviewsource
136
+ restore_meta reviewbase
137
+ restore_meta reviewtip
138
+ restore_meta reviewprevreviewed
139
+ restore_meta reviewlocal
140
+ restore_meta reviewreturn
141
+
142
+ git branch -D "$saved" >/dev/null
143
+
144
+ echo "resumed review of $src — step $step/$count"
145
+ show_commit "$cstep" "$step" "$count"
146
+ else
147
+ # whole mode: rebuild the pristine review state (PR diff staged at the lower
148
+ # bound) and replay your edits into the working tree, leaving exactly what you
149
+ # had when you saved.
150
+ # Without || true a deleted reviewsavedlower (hand-edited or half-deleted
151
+ # config) would let set -e kill us mid-restore with no message; report it and
152
+ # bail before creating the review branch, the way the step path validates its
153
+ # keys above.
154
+ lower="$(git config "branch.$saved.reviewsavedlower" || true)"
155
+ [ -n "$lower" ] || die "missing review metadata for $saved; was it created with git review start?"
156
+
157
+ git switch -q -c "$rb" "$tip"
158
+ git reset -q --soft "$lower"
159
+ # Pipe the saved edits straight from git diff into git apply. Capturing the
160
+ # patch in a shell variable drops its NUL bytes and trailing newline, which
161
+ # corrupts a binary hunk and silently lost a binary edit on resume. The step
162
+ # path above (refs/review-edits) already pipes the diff directly.
163
+ if ! git diff --quiet "$tip" "$saved"; then
164
+ git diff --binary "$tip" "$saved" | git apply ||
165
+ die "could not restore your edits"
166
+ fi
167
+
168
+ restore_meta reviewsource
169
+ restore_meta reviewbase
170
+ restore_meta reviewtip
171
+ restore_meta reviewprevreviewed
172
+ restore_meta reviewlocal
173
+ restore_meta reviewreturn
174
+
175
+ git branch -D "$saved" >/dev/null
176
+
177
+ echo "resumed review of $src — the staged diff is the PR; edit, then run git review finish"
178
+ fi