@kova1/pullr 0.7.0 → 0.9.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 +22 -13
- package/bin/pullr +115 -10
- 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
|
|
@@ -75,6 +78,9 @@ first. It will never:
|
|
|
75
78
|
pullr [options] [DIR]
|
|
76
79
|
```
|
|
77
80
|
|
|
81
|
+
Short options can be combined: `-rs` is `-r -s`, and `-j8` or `-rj 8` set
|
|
82
|
+
the number of jobs.
|
|
83
|
+
|
|
78
84
|
| Option | Default | Meaning |
|
|
79
85
|
| :--- | :--- | :--- |
|
|
80
86
|
| `DIR` | current directory | Where to look for repositories |
|
|
@@ -82,7 +88,8 @@ pullr [options] [DIR]
|
|
|
82
88
|
| `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
|
|
83
89
|
| `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
|
|
84
90
|
| `-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. |
|
|
85
|
-
| `--
|
|
91
|
+
| `--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". |
|
|
92
|
+
| `-s`, `--submodules` | off | Also fast-forward each submodule that is checked out on a branch, see below |
|
|
86
93
|
| `--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
94
|
| `-V`, `--version` | | Print the version |
|
|
88
95
|
| `-h`, `--help` | | Show help |
|
|
@@ -90,7 +97,7 @@ pullr [options] [DIR]
|
|
|
90
97
|
### Output
|
|
91
98
|
|
|
92
99
|
Each repository gets one line: `+` updated, `=` already up to date, `~`
|
|
93
|
-
dirty (local changes
|
|
100
|
+
dirty (local changes conflict with the update, which is held back), `-`
|
|
94
101
|
skipped, `x` failed (with the reason indented below it). Exits `1` if any
|
|
95
102
|
pull failed, `2` on invalid arguments, `0` otherwise; a dirty repository is
|
|
96
103
|
not a failure.
|
|
@@ -113,9 +120,11 @@ Failed: infra
|
|
|
113
120
|
```
|
|
114
121
|
|
|
115
122
|
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
|
-
|
|
123
|
+
remote as it is now, then prints the same lines a real run would. An update
|
|
124
|
+
that would need to stash local changes shows as `+ ... , local changes to
|
|
125
|
+
autostash`, and with `--rebase` a diverged repository shows as
|
|
126
|
+
`+ ... , 2 to rebase` instead of `x`; whether re-applying or rebasing would
|
|
127
|
+
conflict is only known by running it. Fetching
|
|
119
128
|
only updates remote-tracking refs: the working tree and current branch are
|
|
120
129
|
never modified. Exit codes match a real run.
|
|
121
130
|
|
|
@@ -148,7 +157,7 @@ skipped, and uninitialized ones are left out.
|
|
|
148
157
|
| Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
|
|
149
158
|
| Update | fetch + fast‑forward | `git pull` | fetch + fast‑forward | `git pull` | no built-in pull |
|
|
150
159
|
| Merge commits | never | depends on your git config | never | depends on your git config | depends on your command |
|
|
151
|
-
| Local changes in the way |
|
|
160
|
+
| Local changes in the way | stashed and reapplied; rolled back on conflict | git's error | skipped | git's error | - |
|
|
152
161
|
| Dry run | yes | no | no | no | prints commands |
|
|
153
162
|
|
|
154
163
|
**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.9.0" # set by the release workflow
|
|
7
7
|
|
|
8
8
|
usage() {
|
|
9
9
|
cat <<'EOF'
|
|
@@ -24,19 +24,27 @@ 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
|
-
--
|
|
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.
|
|
32
|
+
-s, --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.
|
|
30
35
|
--no-color Disable colored output. Also disabled when NO_COLOR is
|
|
31
36
|
set (https://no-color.org) or output is not a terminal.
|
|
32
37
|
-V, --version Print the version.
|
|
33
38
|
-h, --help Show this help.
|
|
39
|
+
|
|
40
|
+
Short options can be combined: -rs is -r -s, and -j8 or -rj 8 set the jobs.
|
|
34
41
|
EOF
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
mode=pull
|
|
38
45
|
jobs=8
|
|
39
46
|
rebase=0
|
|
47
|
+
autostash=1
|
|
40
48
|
submodules=0
|
|
41
49
|
color=1
|
|
42
50
|
max_depth=2
|
|
@@ -44,13 +52,25 @@ root=""
|
|
|
44
52
|
|
|
45
53
|
while (($#)); do
|
|
46
54
|
case "$1" in
|
|
55
|
+
# bundled short options: split -rs into -r -s, and -j8 into -j 8
|
|
56
|
+
-[!-]?*)
|
|
57
|
+
opt="${1:1:1}" rest="${1:2}"
|
|
58
|
+
shift
|
|
59
|
+
if [[ "$opt" == j ]]; then
|
|
60
|
+
set -- -j "$rest" "$@"
|
|
61
|
+
else
|
|
62
|
+
set -- "-$opt" "-$rest" "$@"
|
|
63
|
+
fi
|
|
64
|
+
;;
|
|
47
65
|
-j|--jobs) jobs="${2:-}"; shift 2 || { usage >&2; exit 2; } ;;
|
|
48
66
|
--jobs=*) jobs="${1#*=}"; shift ;;
|
|
49
67
|
--max-depth) max_depth="${2:-}"; shift 2 || { usage >&2; exit 2; } ;;
|
|
50
68
|
--max-depth=*) max_depth="${1#*=}"; shift ;;
|
|
51
69
|
-n|--dry-run) mode=dryrun; shift ;;
|
|
52
70
|
-r|--rebase) rebase=1; shift ;;
|
|
53
|
-
--
|
|
71
|
+
--autostash) autostash=1; shift ;;
|
|
72
|
+
--no-autostash) autostash=0; shift ;;
|
|
73
|
+
-s|--submodules) submodules=1; shift ;;
|
|
54
74
|
--no-color) color=0; shift ;;
|
|
55
75
|
-h|--help) usage; exit 0 ;;
|
|
56
76
|
-V|--version) echo "pullr $VERSION"; exit 0 ;;
|
|
@@ -125,8 +145,8 @@ report_diverged() {
|
|
|
125
145
|
|
|
126
146
|
report_dirty() {
|
|
127
147
|
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"
|
|
148
|
+
printf '%s~%s %s %s(%s, %s held back %s)%s\n' \
|
|
149
|
+
"$yellow" "$reset" "$1" "$dim" "$2" "$(commits "$3")" "${4:-by local changes}" "$reset"
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
report_current() {
|
|
@@ -138,6 +158,7 @@ report_updated() {
|
|
|
138
158
|
local note
|
|
139
159
|
note="$3 new commit$([[ "$3" == 1 ]] || printf s)"
|
|
140
160
|
[[ "$4" == 0 ]] || note+=", $4 $5"
|
|
161
|
+
[[ -z "${6:-}" ]] || note+=", $6"
|
|
141
162
|
echo updated
|
|
142
163
|
printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$1" "$dim" "$2" "$note" "$reset"
|
|
143
164
|
}
|
|
@@ -178,6 +199,8 @@ pull_repo() {
|
|
|
178
199
|
# git refuses, touching nothing, when local changes are in the way
|
|
179
200
|
if out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"; then
|
|
180
201
|
report_synced "$repo" "$rel" "$branch" "$behind" 0 ""
|
|
202
|
+
elif has_local_changes "$repo" && [[ "$autostash" == 1 ]]; then
|
|
203
|
+
autostash_update "$repo" "$rel" "$branch" "$behind" 0 ff
|
|
181
204
|
elif has_local_changes "$repo"; then
|
|
182
205
|
report_dirty "$rel" "$branch" "$behind"
|
|
183
206
|
else
|
|
@@ -185,6 +208,8 @@ pull_repo() {
|
|
|
185
208
|
fi
|
|
186
209
|
elif [[ "$rebase" == 0 ]]; then
|
|
187
210
|
report_diverged "$rel" "$branch" "$behind" "$ahead"
|
|
211
|
+
elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
|
|
212
|
+
autostash_update "$repo" "$rel" "$branch" "$behind" "$ahead" rebase
|
|
188
213
|
elif has_tracked_changes "$repo"; then
|
|
189
214
|
report_dirty "$rel" "$branch" "$behind"
|
|
190
215
|
elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
|
|
@@ -202,11 +227,87 @@ pull_repo() {
|
|
|
202
227
|
fi
|
|
203
228
|
}
|
|
204
229
|
|
|
230
|
+
# Local changes are in the way of the update ($6: ff or rebase). Stash them,
|
|
231
|
+
# update, and re-apply them. If re-applying is anything but clean, put the
|
|
232
|
+
# repository back exactly as it was and report it as dirty. The stash is only
|
|
233
|
+
# dropped once its changes are back in the working tree.
|
|
234
|
+
autostash_update() {
|
|
235
|
+
local repo="$1" rel="$2" branch="$3" behind="$4" ahead="$5" how="$6"
|
|
236
|
+
local before stash out
|
|
237
|
+
before="$(git -C "$repo" rev-parse HEAD)"
|
|
238
|
+
if ! out="$(git -C "$repo" stash push --include-untracked --quiet -m "pullr autostash" 2>&1)" ||
|
|
239
|
+
! stash="$(git -C "$repo" rev-parse --verify --quiet refs/stash)"; then
|
|
240
|
+
report_failed "$rel" "$branch" "could not stash local changes:"$'\n'"$out"
|
|
241
|
+
return
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
if [[ "$how" == ff ]]; then
|
|
245
|
+
out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"
|
|
246
|
+
else
|
|
247
|
+
out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"
|
|
248
|
+
fi || {
|
|
249
|
+
# Not caused by local changes - they were stashed. Undo, then restore
|
|
250
|
+
# them onto the commit they came from, where they apply cleanly.
|
|
251
|
+
git -C "$repo" rebase --abort >/dev/null 2>&1 || true
|
|
252
|
+
if restore_stash "$repo" "$stash"; then
|
|
253
|
+
report_failed "$rel" "$branch" "$out"
|
|
254
|
+
else
|
|
255
|
+
report_failed "$rel" "$branch" "$out"$'\n'"$(stash_kept "$repo")"
|
|
256
|
+
fi
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if git -C "$repo" stash apply --index --quiet "$stash" >/dev/null 2>&1; then
|
|
261
|
+
drop_stash "$repo" "$stash"
|
|
262
|
+
local verb=""
|
|
263
|
+
[[ "$how" == ff ]] || verb=rebased
|
|
264
|
+
report_synced "$repo" "$rel" "$branch" "$behind" "$([[ "$how" == ff ]] && echo 0 || echo "$ahead")" "$verb" "local changes reapplied"
|
|
265
|
+
return
|
|
266
|
+
fi
|
|
267
|
+
|
|
268
|
+
# Conflict: go back to where we started.
|
|
269
|
+
git -C "$repo" reset --hard --quiet "$before"
|
|
270
|
+
clear_restored_untracked "$repo" "$stash"
|
|
271
|
+
if restore_stash "$repo" "$stash"; then
|
|
272
|
+
report_dirty "$rel" "$branch" "$behind" "because local changes conflict with them"
|
|
273
|
+
else
|
|
274
|
+
report_failed "$rel" "$branch" "could not re-apply local changes after an update conflict."$'\n'"$(stash_kept "$repo")"
|
|
275
|
+
fi
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Re-apply a stash onto the commit it was made from, then drop it.
|
|
279
|
+
restore_stash() {
|
|
280
|
+
git -C "$1" stash apply --index --quiet "$2" >/dev/null 2>&1 || return 1
|
|
281
|
+
drop_stash "$1" "$2"
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# `git stash drop` takes stash@{n}, not a commit: find the entry that is ours.
|
|
285
|
+
drop_stash() {
|
|
286
|
+
local ref
|
|
287
|
+
ref="$(git -C "$1" stash list --format='%gd %H' | awk -v c="$2" '$2 == c { print $1; exit }')"
|
|
288
|
+
[[ -z "$ref" ]] || git -C "$1" stash drop --quiet "$ref" >/dev/null 2>&1 || true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# A failed `stash apply` may already have written the stash's untracked files
|
|
292
|
+
# back. They were moved into the stash, so anything at those paths now came
|
|
293
|
+
# from that apply; remove them so the stash can be applied again.
|
|
294
|
+
clear_restored_untracked() {
|
|
295
|
+
local path
|
|
296
|
+
git -C "$1" rev-parse --verify --quiet "$2^3" >/dev/null || return 0
|
|
297
|
+
while IFS= read -r path; do
|
|
298
|
+
[[ -z "$path" ]] || rm -f -- "$1/$path"
|
|
299
|
+
done < <(git -C "$1" ls-tree -r --name-only "$2^3")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
stash_kept() {
|
|
303
|
+
printf 'Your local changes are safe in the stash (git -C %q stash list).' "$1"
|
|
304
|
+
}
|
|
305
|
+
|
|
205
306
|
# The repository was updated; report it, unless syncing submodules failed.
|
|
206
307
|
report_synced() {
|
|
207
308
|
local out
|
|
208
309
|
if out="$(sync_submodules "$1")"; then
|
|
209
|
-
report_updated "$2" "$3" "$4" "$5" "$6"
|
|
310
|
+
report_updated "$2" "$3" "$4" "$5" "$6" "${7:-}"
|
|
210
311
|
else
|
|
211
312
|
report_failed "$2" "$3" "updated, but updating submodules failed:"$'\n'"$out"
|
|
212
313
|
fi
|
|
@@ -230,13 +331,17 @@ dryrun_repo() {
|
|
|
230
331
|
if [[ "$behind" == 0 ]]; then
|
|
231
332
|
report_current "$rel" "$branch"
|
|
232
333
|
elif [[ "$ahead" == 0 ]]; then
|
|
233
|
-
if changes_block_update "$repo"; then
|
|
334
|
+
if changes_block_update "$repo" && [[ "$autostash" == 1 ]]; then
|
|
335
|
+
report_updated "$rel" "$branch" "$behind" 0 "" "local changes to autostash"
|
|
336
|
+
elif changes_block_update "$repo"; then
|
|
234
337
|
report_dirty "$rel" "$branch" "$behind"
|
|
235
338
|
else
|
|
236
339
|
report_updated "$rel" "$branch" "$behind" 0 ""
|
|
237
340
|
fi
|
|
238
341
|
elif [[ "$rebase" == 0 ]]; then
|
|
239
342
|
report_diverged "$rel" "$branch" "$behind" "$ahead"
|
|
343
|
+
elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
|
|
344
|
+
report_updated "$rel" "$branch" "$behind" "$ahead" "to rebase" "local changes to autostash"
|
|
240
345
|
elif has_tracked_changes "$repo"; then
|
|
241
346
|
report_dirty "$rel" "$branch" "$behind"
|
|
242
347
|
else
|
|
@@ -316,8 +421,8 @@ if ((jobs > 1 && count > 1)); then
|
|
|
316
421
|
scratch="$(mktemp -d)"
|
|
317
422
|
trap 'rm -rf "$scratch"' EXIT
|
|
318
423
|
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
|
|
424
|
+
export mode rebase autostash submodules root green yellow red dim reset
|
|
425
|
+
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
426
|
# shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
|
|
322
427
|
for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
|
|
323
428
|
xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
|