@kova1/pullr 0.5.0 → 0.7.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 +53 -10
- package/bin/pullr +62 -18
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/kova1max/homebrew-tap)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Safe, fast-forward pull every git repository under a directory.
|
|
9
9
|
|
|
10
10
|
Built for workspaces made of many independent repositories, where keeping
|
|
11
11
|
everything current means `cd`-ing into each one.
|
|
@@ -40,9 +40,6 @@ To try it without installing anything:
|
|
|
40
40
|
npx @kova1/pullr --dry-run ~/work
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Or copy [`bin/pullr`](bin/pullr) anywhere on your `PATH` - it is a single bash
|
|
44
|
-
script with no dependencies beyond `git`.
|
|
45
|
-
|
|
46
43
|
Every release is also mirrored to
|
|
47
44
|
[GitHub Packages](https://github.com/kova1max/pullr/pkgs/npm/pullr) as
|
|
48
45
|
`@kova1max/pullr` (GitHub requires the repo owner as scope). Installing from
|
|
@@ -64,8 +61,12 @@ first. It will never:
|
|
|
64
61
|
incoming files don't stop the update.
|
|
65
62
|
- **Touch a repository on a detached `HEAD`**, or a branch with no upstream.
|
|
66
63
|
Both are skipped.
|
|
67
|
-
- **Enter a nested repository.** Repositories inside another repository
|
|
68
|
-
|
|
64
|
+
- **Enter a nested repository.** Repositories inside another repository are
|
|
65
|
+
left to their parent.
|
|
66
|
+
- **Move a submodule off its branch.** With `--submodules`, a submodule is
|
|
67
|
+
only ever fast-forwarded on the branch it is on. (If you set git's
|
|
68
|
+
`submodule.recurse`, pullr does what `git pull` does with it: checks
|
|
69
|
+
submodules out at the recorded commit.)
|
|
69
70
|
- **Follow symlinked directories.**
|
|
70
71
|
|
|
71
72
|
## Usage
|
|
@@ -74,13 +75,14 @@ first. It will never:
|
|
|
74
75
|
pullr [options] [DIR]
|
|
75
76
|
```
|
|
76
77
|
|
|
77
|
-
| Option | Default | Meaning |
|
|
78
|
-
|
|
|
79
|
-
| `DIR` | current
|
|
80
|
-
| `-j`, `--jobs N` | `
|
|
78
|
+
| Option | Default | Meaning |
|
|
79
|
+
| :--- | :--- | :--- |
|
|
80
|
+
| `DIR` | current directory | Where to look for repositories |
|
|
81
|
+
| `-j`, `--jobs N` | `8` | Work on up to `N` repositories in parallel. Output is buffered per repository and printed in discovery order, so it reads the same as a sequential run. With `N > 1` git never prompts for credentials; use `--jobs 1` for repositories that need an interactive login. |
|
|
81
82
|
| `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
|
|
82
83
|
| `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
|
|
83
84
|
| `-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
|
+
| `--submodules` | off | Also fast-forward each submodule that is checked out on a branch, see below |
|
|
84
86
|
| `--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. |
|
|
85
87
|
| `-V`, `--version` | | Print the version |
|
|
86
88
|
| `-h`, `--help` | | Show help |
|
|
@@ -117,6 +119,47 @@ remote as it is now, then prints the same lines a real run would (with
|
|
|
117
119
|
only updates remote-tracking refs: the working tree and current branch are
|
|
118
120
|
never modified. Exit codes match a real run.
|
|
119
121
|
|
|
122
|
+
### Submodules
|
|
123
|
+
|
|
124
|
+
By default pullr updates a repository the way `git pull` does: submodules stay
|
|
125
|
+
where they are, unless you set git's `submodule.recurse`, in which case they
|
|
126
|
+
are checked out at the commits the parent now records, as `git pull` would.
|
|
127
|
+
|
|
128
|
+
`--submodules` is for working inside submodules. Each submodule that is
|
|
129
|
+
checked out on a branch is treated as a repository of its own - fetched,
|
|
130
|
+
fast-forwarded with the same rules, and listed under its parent:
|
|
131
|
+
|
|
132
|
+
```console
|
|
133
|
+
$ pullr --submodules ~/work
|
|
134
|
+
+ app (main, 2 new commits)
|
|
135
|
+
+ app/lib/core (main, 5 new commits)
|
|
136
|
+
- app/vendor/sdk (detached HEAD)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Submodules on a detached HEAD (git's default after `git submodule update`) are
|
|
140
|
+
skipped, and uninitialized ones are left out.
|
|
141
|
+
|
|
142
|
+
## How it compares
|
|
143
|
+
|
|
144
|
+
| | pullr | [gita](https://github.com/nosarthur/gita) | [gitup](https://github.com/earwig/git-repo-updater) | [myrepos](https://myrepos.branchable.com) (`mr`) | [mani](https://github.com/alajmo/mani) |
|
|
145
|
+
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
146
|
+
| Needs | bash and git | Python 3.8+ | Python 3.10+ | Perl | Go binary |
|
|
147
|
+
| Finds repos | scans a directory | register first (`gita add`) | scans a directory | register first (`~/.mrconfig`) | register first (`mani.yaml`) |
|
|
148
|
+
| Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
|
|
149
|
+
| Update | fetch + fast‑forward | `git pull` | fetch + fast‑forward | `git pull` | no built-in pull |
|
|
150
|
+
| Merge commits | never | depends on your git config | never | depends on your git config | depends on your command |
|
|
151
|
+
| Local changes in the way | reported as dirty | git's error | skipped | git's error | - |
|
|
152
|
+
| Dry run | yes | no | no | no | prints commands |
|
|
153
|
+
|
|
154
|
+
**Why "8 at a time" and not "all at once".** Many self-hosted git servers
|
|
155
|
+
limit how many SSH connections can be opening at the same time; OpenSSH's
|
|
156
|
+
default (`MaxStartups 10:30:100`) starts refusing them past 10. Fetching
|
|
157
|
+
every repository at once then fails for reasons that have nothing to do with
|
|
158
|
+
your repositories. On a self-hosted GitLab with 61 repositories, 16 parallel
|
|
159
|
+
fetches produced 6-9 of those spurious failures and 32 produced 10-14, while
|
|
160
|
+
8 produced none. GitHub itself handled 64 at once without errors, so raise
|
|
161
|
+
`-j` freely there.
|
|
162
|
+
|
|
120
163
|
## License
|
|
121
164
|
|
|
122
165
|
[MIT](LICENSE)
|
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] [--no-color] [DIR]
|
|
3
|
+
# Usage: pullr [--jobs N] [--max-depth N] [--dry-run] [--rebase] [--submodules] [--no-color] [DIR]
|
|
4
4
|
set -euo pipefail
|
|
5
5
|
|
|
6
|
-
VERSION="0.
|
|
6
|
+
VERSION="0.7.0" # set by the release workflow
|
|
7
7
|
|
|
8
8
|
usage() {
|
|
9
9
|
cat <<'EOF'
|
|
@@ -13,7 +13,7 @@ Finds git repositories under DIR (default: current directory) and runs
|
|
|
13
13
|
`git pull --ff-only` in each. Does not descend into a repository once found.
|
|
14
14
|
|
|
15
15
|
Options:
|
|
16
|
-
-j, --jobs N Work on up to N repositories in parallel (default:
|
|
16
|
+
-j, --jobs N Work on up to N repositories in parallel (default: 8).
|
|
17
17
|
Output is buffered per repository and printed in order.
|
|
18
18
|
With N > 1 git never prompts for credentials.
|
|
19
19
|
--max-depth N How many directory levels below DIR to search (default: 2).
|
|
@@ -24,6 +24,9 @@ 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
|
+
--submodules Also fast-forward each submodule that is checked out on a
|
|
28
|
+
branch, with the same rules as any other repository.
|
|
29
|
+
Submodules on a detached HEAD are skipped.
|
|
27
30
|
--no-color Disable colored output. Also disabled when NO_COLOR is
|
|
28
31
|
set (https://no-color.org) or output is not a terminal.
|
|
29
32
|
-V, --version Print the version.
|
|
@@ -32,8 +35,9 @@ EOF
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
mode=pull
|
|
35
|
-
jobs=
|
|
38
|
+
jobs=8
|
|
36
39
|
rebase=0
|
|
40
|
+
submodules=0
|
|
37
41
|
color=1
|
|
38
42
|
max_depth=2
|
|
39
43
|
root=""
|
|
@@ -46,6 +50,7 @@ while (($#)); do
|
|
|
46
50
|
--max-depth=*) max_depth="${1#*=}"; shift ;;
|
|
47
51
|
-n|--dry-run) mode=dryrun; shift ;;
|
|
48
52
|
-r|--rebase) rebase=1; shift ;;
|
|
53
|
+
--submodules) submodules=1; shift ;;
|
|
49
54
|
--no-color) color=0; shift ;;
|
|
50
55
|
-h|--help) usage; exit 0 ;;
|
|
51
56
|
-V|--version) echo "pullr $VERSION"; exit 0 ;;
|
|
@@ -142,9 +147,24 @@ commits() {
|
|
|
142
147
|
printf '%s commit%s' "$1" "$([[ "$1" == 1 ]] || printf s)"
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
# Uncommitted changes to tracked files, staged or not.
|
|
150
|
+
# Uncommitted changes to tracked files, staged or not. A submodule checked out
|
|
151
|
+
# at a different commit than the one recorded is not one: git neither refuses
|
|
152
|
+
# to update over it nor touches it.
|
|
146
153
|
has_tracked_changes() {
|
|
147
|
-
! git -C "$1" diff --quiet HEAD --
|
|
154
|
+
! git -C "$1" diff --quiet --ignore-submodules HEAD --
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
has_local_changes() {
|
|
158
|
+
[[ -n "$(git -C "$1" status --porcelain --ignore-submodules=all)" ]]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Mirror `git pull` for people who set submodule.recurse: after the update,
|
|
162
|
+
# check submodules out at the commits the parent now records. With
|
|
163
|
+
# --submodules each one is fast-forwarded on its own branch instead.
|
|
164
|
+
sync_submodules() {
|
|
165
|
+
[[ "$submodules" == 0 ]] || return 0
|
|
166
|
+
[[ "$(git -C "$1" config --type=bool --get submodule.recurse 2>/dev/null)" == true ]] || return 0
|
|
167
|
+
git -C "$1" submodule update --recursive 2>&1
|
|
148
168
|
}
|
|
149
169
|
|
|
150
170
|
pull_repo() {
|
|
@@ -157,8 +177,8 @@ pull_repo() {
|
|
|
157
177
|
elif [[ "$ahead" == 0 ]]; then
|
|
158
178
|
# git refuses, touching nothing, when local changes are in the way
|
|
159
179
|
if out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"; then
|
|
160
|
-
|
|
161
|
-
elif
|
|
180
|
+
report_synced "$repo" "$rel" "$branch" "$behind" 0 ""
|
|
181
|
+
elif has_local_changes "$repo"; then
|
|
162
182
|
report_dirty "$rel" "$branch" "$behind"
|
|
163
183
|
else
|
|
164
184
|
report_failed "$rel" "$branch" "$out"
|
|
@@ -168,13 +188,13 @@ pull_repo() {
|
|
|
168
188
|
elif has_tracked_changes "$repo"; then
|
|
169
189
|
report_dirty "$rel" "$branch" "$behind"
|
|
170
190
|
elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
|
|
171
|
-
|
|
191
|
+
report_synced "$repo" "$rel" "$branch" "$behind" "$ahead" rebased
|
|
172
192
|
else
|
|
173
193
|
# A rebase that stopped on a conflict must not leave the repository
|
|
174
194
|
# mid-rebase; --abort succeeds only when one is in progress.
|
|
175
195
|
if git -C "$repo" rebase --abort >/dev/null 2>&1; then
|
|
176
196
|
report_failed "$rel" "$branch" "$out"
|
|
177
|
-
elif
|
|
197
|
+
elif has_local_changes "$repo"; then
|
|
178
198
|
report_dirty "$rel" "$branch" "$behind"
|
|
179
199
|
else
|
|
180
200
|
report_failed "$rel" "$branch" "$out"
|
|
@@ -182,12 +202,22 @@ pull_repo() {
|
|
|
182
202
|
fi
|
|
183
203
|
}
|
|
184
204
|
|
|
205
|
+
# The repository was updated; report it, unless syncing submodules failed.
|
|
206
|
+
report_synced() {
|
|
207
|
+
local out
|
|
208
|
+
if out="$(sync_submodules "$1")"; then
|
|
209
|
+
report_updated "$2" "$3" "$4" "$5" "$6"
|
|
210
|
+
else
|
|
211
|
+
report_failed "$2" "$3" "updated, but updating submodules failed:"$'\n'"$out"
|
|
212
|
+
fi
|
|
213
|
+
}
|
|
214
|
+
|
|
185
215
|
# Would local changes stop a fast-forward? Git refuses when a file it has to
|
|
186
216
|
# update is modified locally or exists untracked, so compare the two lists.
|
|
187
217
|
changes_block_update() {
|
|
188
218
|
local repo="$1" incoming local_paths
|
|
189
219
|
incoming="$(git -C "$repo" diff --name-only HEAD '@{upstream}')"
|
|
190
|
-
local_paths="$(git -C "$repo" status --porcelain --untracked-files=all | cut -c4-)"
|
|
220
|
+
local_paths="$(git -C "$repo" status --porcelain --untracked-files=all --ignore-submodules=all | cut -c4-)"
|
|
191
221
|
[[ -n "$incoming" && -n "$local_paths" ]] || return 1
|
|
192
222
|
printf '%s\n' "$local_paths" | grep -qFxf <(printf '%s\n' "$incoming")
|
|
193
223
|
}
|
|
@@ -225,11 +255,12 @@ worker() {
|
|
|
225
255
|
|
|
226
256
|
# --- discovery ---------------------------------------------------------------
|
|
227
257
|
|
|
228
|
-
repos=()
|
|
258
|
+
repos=() nested=()
|
|
229
259
|
walk() {
|
|
230
260
|
local dir="$1" depth="$2"
|
|
231
261
|
if [[ -e "$dir/.git" ]]; then
|
|
232
|
-
repos+=("$dir")
|
|
262
|
+
repos+=("$dir") nested+=(0)
|
|
263
|
+
[[ "$submodules" == 0 ]] || add_submodules "$dir"
|
|
233
264
|
return
|
|
234
265
|
fi
|
|
235
266
|
((depth < max_depth)) || return 0
|
|
@@ -239,6 +270,17 @@ walk() {
|
|
|
239
270
|
walk "${child%/}" $((depth + 1))
|
|
240
271
|
done
|
|
241
272
|
}
|
|
273
|
+
# Checked-out submodules, recursively, in git's order; uninitialized ones
|
|
274
|
+
# have no checkout to update and are left out.
|
|
275
|
+
add_submodules() {
|
|
276
|
+
local sub
|
|
277
|
+
# shellcheck disable=SC2016 # $displaypath is expanded by git submodule foreach
|
|
278
|
+
while IFS= read -r sub; do
|
|
279
|
+
[[ -n "$sub" ]] || continue
|
|
280
|
+
repos+=("$1/$sub") nested+=(1)
|
|
281
|
+
done < <(git -C "$1" submodule foreach --quiet --recursive 'printf "%s\n" "$displaypath"' 2>/dev/null)
|
|
282
|
+
}
|
|
283
|
+
|
|
242
284
|
walk "$root" 0
|
|
243
285
|
|
|
244
286
|
count=${#repos[@]}
|
|
@@ -256,6 +298,8 @@ failed_repos=() dirty_repos=()
|
|
|
256
298
|
|
|
257
299
|
consume() {
|
|
258
300
|
local text="$1" head="${1%%$'\n'*}" body="${1#*$'\n'}"
|
|
301
|
+
# a submodule's lines sit under its parent's
|
|
302
|
+
[[ "${2:-0}" == 0 ]] || body=" ${body//$'\n'/$'\n' }"
|
|
259
303
|
local category="${head%%$'\t'*}" label="${head#*$'\t'}"
|
|
260
304
|
case "$category" in
|
|
261
305
|
updated) updated=$((updated + 1)) ;;
|
|
@@ -272,8 +316,8 @@ if ((jobs > 1 && count > 1)); then
|
|
|
272
316
|
scratch="$(mktemp -d)"
|
|
273
317
|
trap 'rm -rf "$scratch"' EXIT
|
|
274
318
|
export GIT_TERMINAL_PROMPT=0
|
|
275
|
-
export mode rebase root green yellow red dim reset
|
|
276
|
-
export -f worker pull_repo dryrun_repo repo_label indented prepare report_failed report_diverged report_dirty report_current report_updated commits has_tracked_changes changes_block_update
|
|
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
|
|
277
321
|
# shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
|
|
278
322
|
for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
|
|
279
323
|
xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
|
|
@@ -287,12 +331,12 @@ if ((jobs > 1 && count > 1)); then
|
|
|
287
331
|
fi
|
|
288
332
|
sleep 0.1
|
|
289
333
|
done
|
|
290
|
-
consume "$(cat "$scratch/$i.done")"
|
|
334
|
+
consume "$(cat "$scratch/$i.done")" "${nested[i]}"
|
|
291
335
|
done
|
|
292
336
|
wait "$pool" || true
|
|
293
337
|
else
|
|
294
|
-
for
|
|
295
|
-
consume "$("${mode}_repo" "$
|
|
338
|
+
for ((i = 0; i < count; i++)); do
|
|
339
|
+
consume "$("${mode}_repo" "${repos[i]}")" "${nested[i]}"
|
|
296
340
|
done
|
|
297
341
|
fi
|
|
298
342
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kova1/pullr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Safe, fast-forward pull every git repository under a directory",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git",
|
|
7
7
|
"pull",
|