@kova1/pullr 0.7.0 → 0.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/README.md +18 -12
- package/bin/pullr +101 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,13 +13,13 @@ everything current means `cd`-ing into each one.
|
|
|
13
13
|
```console
|
|
14
14
|
$ pullr ~/work
|
|
15
15
|
+ api (main, 3 new commits)
|
|
16
|
-
|
|
17
|
-
~ docs (main, 2 commits held back
|
|
16
|
+
+ web (main, 1 new commit, local changes reapplied)
|
|
17
|
+
~ docs (main, 2 commits held back because local changes conflict with them)
|
|
18
18
|
- scratch (main has no upstream)
|
|
19
19
|
x infra (main)
|
|
20
20
|
diverged from upstream (behind 2, ahead 1): cannot fast-forward
|
|
21
21
|
|
|
22
|
-
Repos: 5 updated:
|
|
22
|
+
Repos: 5 updated: 2 up to date: 0 dirty: 1 skipped: 1 failed: 1
|
|
23
23
|
Dirty: docs
|
|
24
24
|
Failed: infra
|
|
25
25
|
```
|
|
@@ -55,10 +55,13 @@ first. It will never:
|
|
|
55
55
|
failed and left exactly as it was. Rebasing it is opt-in with `--rebase`,
|
|
56
56
|
and a rebase that hits a conflict is aborted, leaving the repository as it
|
|
57
57
|
was.
|
|
58
|
-
- **
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
- **Leave your changes half-merged, or lose them.** When local edits or
|
|
59
|
+
untracked files are in the way of an update, pullr stashes them, updates,
|
|
60
|
+
and re-applies them on top. If they conflict with the incoming commits, it
|
|
61
|
+
puts the repository back exactly as it was - same commit, same files, same
|
|
62
|
+
staged and unstaged changes, no stash left behind - and reports it as
|
|
63
|
+
dirty. Edits that don't touch incoming files are never stashed at all.
|
|
64
|
+
`--no-autostash` skips the stash and just reports the repository as dirty.
|
|
62
65
|
- **Touch a repository on a detached `HEAD`**, or a branch with no upstream.
|
|
63
66
|
Both are skipped.
|
|
64
67
|
- **Enter a nested repository.** Repositories inside another repository are
|
|
@@ -82,6 +85,7 @@ pullr [options] [DIR]
|
|
|
82
85
|
| `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
|
|
83
86
|
| `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
|
|
84
87
|
| `-r`, `--rebase` | off | Rebase local commits onto the upstream instead of refusing a diverged branch (`git pull --rebase`). A rebase that hits a conflict is aborted and the repository is left as it was. |
|
|
88
|
+
| `--no-autostash` | | Don't stash local changes that are in the way of an update; report the repository as dirty instead. Autostash is on by default, see "What it will never do". |
|
|
85
89
|
| `--submodules` | off | Also fast-forward each submodule that is checked out on a branch, see below |
|
|
86
90
|
| `--no-color` | | Disable colored output. Also disabled when the [`NO_COLOR`](https://no-color.org) environment variable is set, or when output is not a terminal. |
|
|
87
91
|
| `-V`, `--version` | | Print the version |
|
|
@@ -90,7 +94,7 @@ pullr [options] [DIR]
|
|
|
90
94
|
### Output
|
|
91
95
|
|
|
92
96
|
Each repository gets one line: `+` updated, `=` already up to date, `~`
|
|
93
|
-
dirty (local changes
|
|
97
|
+
dirty (local changes conflict with the update, which is held back), `-`
|
|
94
98
|
skipped, `x` failed (with the reason indented below it). Exits `1` if any
|
|
95
99
|
pull failed, `2` on invalid arguments, `0` otherwise; a dirty repository is
|
|
96
100
|
not a failure.
|
|
@@ -113,9 +117,11 @@ Failed: infra
|
|
|
113
117
|
```
|
|
114
118
|
|
|
115
119
|
It runs `git fetch` in each repository first, so the answer reflects the
|
|
116
|
-
remote as it is now, then prints the same lines a real run would
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
remote as it is now, then prints the same lines a real run would. An update
|
|
121
|
+
that would need to stash local changes shows as `+ ... , local changes to
|
|
122
|
+
autostash`, and with `--rebase` a diverged repository shows as
|
|
123
|
+
`+ ... , 2 to rebase` instead of `x`; whether re-applying or rebasing would
|
|
124
|
+
conflict is only known by running it. Fetching
|
|
119
125
|
only updates remote-tracking refs: the working tree and current branch are
|
|
120
126
|
never modified. Exit codes match a real run.
|
|
121
127
|
|
|
@@ -148,7 +154,7 @@ skipped, and uninitialized ones are left out.
|
|
|
148
154
|
| Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
|
|
149
155
|
| Update | fetch + fast‑forward | `git pull` | fetch + fast‑forward | `git pull` | no built-in pull |
|
|
150
156
|
| Merge commits | never | depends on your git config | never | depends on your git config | depends on your command |
|
|
151
|
-
| Local changes in the way |
|
|
157
|
+
| Local changes in the way | stashed and reapplied; rolled back on conflict | git's error | skipped | git's error | - |
|
|
152
158
|
| Dry run | yes | no | no | no | prints commands |
|
|
153
159
|
|
|
154
160
|
**Why "8 at a time" and not "all at once".** Many self-hosted git servers
|
package/bin/pullr
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Recursively find git repos under a directory and fast-forward pull each one.
|
|
3
|
-
# Usage: pullr [--jobs N] [--max-depth N] [--dry-run] [--rebase] [--submodules] [--no-color] [DIR]
|
|
3
|
+
# Usage: pullr [--jobs N] [--max-depth N] [--dry-run] [--rebase] [--no-autostash] [--submodules] [--no-color] [DIR]
|
|
4
4
|
set -euo pipefail
|
|
5
5
|
|
|
6
|
-
VERSION="0.
|
|
6
|
+
VERSION="0.8.0" # set by the release workflow
|
|
7
7
|
|
|
8
8
|
usage() {
|
|
9
9
|
cat <<'EOF'
|
|
@@ -24,6 +24,11 @@ Options:
|
|
|
24
24
|
refusing a branch that has diverged (git pull --rebase).
|
|
25
25
|
A rebase that hits a conflict is aborted, leaving the
|
|
26
26
|
repository as it was.
|
|
27
|
+
--no-autostash Don't stash local changes that are in the way of an
|
|
28
|
+
update. By default they are stashed, the update is
|
|
29
|
+
made and they are re-applied; if re-applying conflicts,
|
|
30
|
+
the repository is put back exactly as it was.
|
|
31
|
+
--autostash The default; accepted for scripts that want to be explicit.
|
|
27
32
|
--submodules Also fast-forward each submodule that is checked out on a
|
|
28
33
|
branch, with the same rules as any other repository.
|
|
29
34
|
Submodules on a detached HEAD are skipped.
|
|
@@ -37,6 +42,7 @@ EOF
|
|
|
37
42
|
mode=pull
|
|
38
43
|
jobs=8
|
|
39
44
|
rebase=0
|
|
45
|
+
autostash=1
|
|
40
46
|
submodules=0
|
|
41
47
|
color=1
|
|
42
48
|
max_depth=2
|
|
@@ -50,6 +56,8 @@ while (($#)); do
|
|
|
50
56
|
--max-depth=*) max_depth="${1#*=}"; shift ;;
|
|
51
57
|
-n|--dry-run) mode=dryrun; shift ;;
|
|
52
58
|
-r|--rebase) rebase=1; shift ;;
|
|
59
|
+
--autostash) autostash=1; shift ;;
|
|
60
|
+
--no-autostash) autostash=0; shift ;;
|
|
53
61
|
--submodules) submodules=1; shift ;;
|
|
54
62
|
--no-color) color=0; shift ;;
|
|
55
63
|
-h|--help) usage; exit 0 ;;
|
|
@@ -125,8 +133,8 @@ report_diverged() {
|
|
|
125
133
|
|
|
126
134
|
report_dirty() {
|
|
127
135
|
printf 'dirty\t%s\n' "$1"
|
|
128
|
-
printf '%s~%s %s %s(%s, %s held back
|
|
129
|
-
"$yellow" "$reset" "$1" "$dim" "$2" "$(commits "$3")" "$reset"
|
|
136
|
+
printf '%s~%s %s %s(%s, %s held back %s)%s\n' \
|
|
137
|
+
"$yellow" "$reset" "$1" "$dim" "$2" "$(commits "$3")" "${4:-by local changes}" "$reset"
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
report_current() {
|
|
@@ -138,6 +146,7 @@ report_updated() {
|
|
|
138
146
|
local note
|
|
139
147
|
note="$3 new commit$([[ "$3" == 1 ]] || printf s)"
|
|
140
148
|
[[ "$4" == 0 ]] || note+=", $4 $5"
|
|
149
|
+
[[ -z "${6:-}" ]] || note+=", $6"
|
|
141
150
|
echo updated
|
|
142
151
|
printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$1" "$dim" "$2" "$note" "$reset"
|
|
143
152
|
}
|
|
@@ -178,6 +187,8 @@ pull_repo() {
|
|
|
178
187
|
# git refuses, touching nothing, when local changes are in the way
|
|
179
188
|
if out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"; then
|
|
180
189
|
report_synced "$repo" "$rel" "$branch" "$behind" 0 ""
|
|
190
|
+
elif has_local_changes "$repo" && [[ "$autostash" == 1 ]]; then
|
|
191
|
+
autostash_update "$repo" "$rel" "$branch" "$behind" 0 ff
|
|
181
192
|
elif has_local_changes "$repo"; then
|
|
182
193
|
report_dirty "$rel" "$branch" "$behind"
|
|
183
194
|
else
|
|
@@ -185,6 +196,8 @@ pull_repo() {
|
|
|
185
196
|
fi
|
|
186
197
|
elif [[ "$rebase" == 0 ]]; then
|
|
187
198
|
report_diverged "$rel" "$branch" "$behind" "$ahead"
|
|
199
|
+
elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
|
|
200
|
+
autostash_update "$repo" "$rel" "$branch" "$behind" "$ahead" rebase
|
|
188
201
|
elif has_tracked_changes "$repo"; then
|
|
189
202
|
report_dirty "$rel" "$branch" "$behind"
|
|
190
203
|
elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
|
|
@@ -202,11 +215,87 @@ pull_repo() {
|
|
|
202
215
|
fi
|
|
203
216
|
}
|
|
204
217
|
|
|
218
|
+
# Local changes are in the way of the update ($6: ff or rebase). Stash them,
|
|
219
|
+
# update, and re-apply them. If re-applying is anything but clean, put the
|
|
220
|
+
# repository back exactly as it was and report it as dirty. The stash is only
|
|
221
|
+
# dropped once its changes are back in the working tree.
|
|
222
|
+
autostash_update() {
|
|
223
|
+
local repo="$1" rel="$2" branch="$3" behind="$4" ahead="$5" how="$6"
|
|
224
|
+
local before stash out
|
|
225
|
+
before="$(git -C "$repo" rev-parse HEAD)"
|
|
226
|
+
if ! out="$(git -C "$repo" stash push --include-untracked --quiet -m "pullr autostash" 2>&1)" ||
|
|
227
|
+
! stash="$(git -C "$repo" rev-parse --verify --quiet refs/stash)"; then
|
|
228
|
+
report_failed "$rel" "$branch" "could not stash local changes:"$'\n'"$out"
|
|
229
|
+
return
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
if [[ "$how" == ff ]]; then
|
|
233
|
+
out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"
|
|
234
|
+
else
|
|
235
|
+
out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"
|
|
236
|
+
fi || {
|
|
237
|
+
# Not caused by local changes - they were stashed. Undo, then restore
|
|
238
|
+
# them onto the commit they came from, where they apply cleanly.
|
|
239
|
+
git -C "$repo" rebase --abort >/dev/null 2>&1 || true
|
|
240
|
+
if restore_stash "$repo" "$stash"; then
|
|
241
|
+
report_failed "$rel" "$branch" "$out"
|
|
242
|
+
else
|
|
243
|
+
report_failed "$rel" "$branch" "$out"$'\n'"$(stash_kept "$repo")"
|
|
244
|
+
fi
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if git -C "$repo" stash apply --index --quiet "$stash" >/dev/null 2>&1; then
|
|
249
|
+
drop_stash "$repo" "$stash"
|
|
250
|
+
local verb=""
|
|
251
|
+
[[ "$how" == ff ]] || verb=rebased
|
|
252
|
+
report_synced "$repo" "$rel" "$branch" "$behind" "$([[ "$how" == ff ]] && echo 0 || echo "$ahead")" "$verb" "local changes reapplied"
|
|
253
|
+
return
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# Conflict: go back to where we started.
|
|
257
|
+
git -C "$repo" reset --hard --quiet "$before"
|
|
258
|
+
clear_restored_untracked "$repo" "$stash"
|
|
259
|
+
if restore_stash "$repo" "$stash"; then
|
|
260
|
+
report_dirty "$rel" "$branch" "$behind" "because local changes conflict with them"
|
|
261
|
+
else
|
|
262
|
+
report_failed "$rel" "$branch" "could not re-apply local changes after an update conflict."$'\n'"$(stash_kept "$repo")"
|
|
263
|
+
fi
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# Re-apply a stash onto the commit it was made from, then drop it.
|
|
267
|
+
restore_stash() {
|
|
268
|
+
git -C "$1" stash apply --index --quiet "$2" >/dev/null 2>&1 || return 1
|
|
269
|
+
drop_stash "$1" "$2"
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# `git stash drop` takes stash@{n}, not a commit: find the entry that is ours.
|
|
273
|
+
drop_stash() {
|
|
274
|
+
local ref
|
|
275
|
+
ref="$(git -C "$1" stash list --format='%gd %H' | awk -v c="$2" '$2 == c { print $1; exit }')"
|
|
276
|
+
[[ -z "$ref" ]] || git -C "$1" stash drop --quiet "$ref" >/dev/null 2>&1 || true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# A failed `stash apply` may already have written the stash's untracked files
|
|
280
|
+
# back. They were moved into the stash, so anything at those paths now came
|
|
281
|
+
# from that apply; remove them so the stash can be applied again.
|
|
282
|
+
clear_restored_untracked() {
|
|
283
|
+
local path
|
|
284
|
+
git -C "$1" rev-parse --verify --quiet "$2^3" >/dev/null || return 0
|
|
285
|
+
while IFS= read -r path; do
|
|
286
|
+
[[ -z "$path" ]] || rm -f -- "$1/$path"
|
|
287
|
+
done < <(git -C "$1" ls-tree -r --name-only "$2^3")
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
stash_kept() {
|
|
291
|
+
printf 'Your local changes are safe in the stash (git -C %q stash list).' "$1"
|
|
292
|
+
}
|
|
293
|
+
|
|
205
294
|
# The repository was updated; report it, unless syncing submodules failed.
|
|
206
295
|
report_synced() {
|
|
207
296
|
local out
|
|
208
297
|
if out="$(sync_submodules "$1")"; then
|
|
209
|
-
report_updated "$2" "$3" "$4" "$5" "$6"
|
|
298
|
+
report_updated "$2" "$3" "$4" "$5" "$6" "${7:-}"
|
|
210
299
|
else
|
|
211
300
|
report_failed "$2" "$3" "updated, but updating submodules failed:"$'\n'"$out"
|
|
212
301
|
fi
|
|
@@ -230,13 +319,17 @@ dryrun_repo() {
|
|
|
230
319
|
if [[ "$behind" == 0 ]]; then
|
|
231
320
|
report_current "$rel" "$branch"
|
|
232
321
|
elif [[ "$ahead" == 0 ]]; then
|
|
233
|
-
if changes_block_update "$repo"; then
|
|
322
|
+
if changes_block_update "$repo" && [[ "$autostash" == 1 ]]; then
|
|
323
|
+
report_updated "$rel" "$branch" "$behind" 0 "" "local changes to autostash"
|
|
324
|
+
elif changes_block_update "$repo"; then
|
|
234
325
|
report_dirty "$rel" "$branch" "$behind"
|
|
235
326
|
else
|
|
236
327
|
report_updated "$rel" "$branch" "$behind" 0 ""
|
|
237
328
|
fi
|
|
238
329
|
elif [[ "$rebase" == 0 ]]; then
|
|
239
330
|
report_diverged "$rel" "$branch" "$behind" "$ahead"
|
|
331
|
+
elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
|
|
332
|
+
report_updated "$rel" "$branch" "$behind" "$ahead" "to rebase" "local changes to autostash"
|
|
240
333
|
elif has_tracked_changes "$repo"; then
|
|
241
334
|
report_dirty "$rel" "$branch" "$behind"
|
|
242
335
|
else
|
|
@@ -316,8 +409,8 @@ if ((jobs > 1 && count > 1)); then
|
|
|
316
409
|
scratch="$(mktemp -d)"
|
|
317
410
|
trap 'rm -rf "$scratch"' EXIT
|
|
318
411
|
export GIT_TERMINAL_PROMPT=0
|
|
319
|
-
export mode rebase submodules root green yellow red dim reset
|
|
320
|
-
export -f worker pull_repo dryrun_repo repo_label indented prepare report_failed report_diverged report_dirty report_current report_updated report_synced commits has_tracked_changes has_local_changes sync_submodules changes_block_update
|
|
412
|
+
export mode rebase autostash submodules root green yellow red dim reset
|
|
413
|
+
export -f worker pull_repo dryrun_repo repo_label indented prepare report_failed report_diverged report_dirty report_current report_updated report_synced autostash_update restore_stash drop_stash clear_restored_untracked stash_kept commits has_tracked_changes has_local_changes sync_submodules changes_block_update
|
|
321
414
|
# shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
|
|
322
415
|
for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
|
|
323
416
|
xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
|