@kova1/pullr 0.1.0 → 0.3.1
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 +43 -2
- package/bin/pullr +181 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# pullr
|
|
2
2
|
|
|
3
|
+
[](https://github.com/kova1max/pullr/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@kova1/pullr)
|
|
5
|
+
[](https://github.com/kova1max/homebrew-tap)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
3
8
|
Fast-forward pull every git repository under a directory.
|
|
4
9
|
|
|
5
10
|
Built for workspaces made of many independent repositories, where keeping
|
|
@@ -21,7 +26,7 @@ Failed: infra
|
|
|
21
26
|
|
|
22
27
|
```sh
|
|
23
28
|
# Homebrew
|
|
24
|
-
brew install kova1max/tap/pullr
|
|
29
|
+
brew install kova1max/tap/pullr
|
|
25
30
|
|
|
26
31
|
# npm
|
|
27
32
|
npm install -g @kova1/pullr
|
|
@@ -30,16 +35,24 @@ npm install -g @kova1/pullr
|
|
|
30
35
|
Or copy [`bin/pullr`](bin/pullr) anywhere on your `PATH` - it is a single bash
|
|
31
36
|
script with no dependencies beyond `git`.
|
|
32
37
|
|
|
38
|
+
Every release is also mirrored to
|
|
39
|
+
[GitHub Packages](https://github.com/kova1max/pullr/pkgs/npm/pullr) as
|
|
40
|
+
`@kova1max/pullr` (GitHub requires the repo owner as scope). Installing from
|
|
41
|
+
there needs a GitHub token, so the npm registry above is the easier choice.
|
|
42
|
+
|
|
33
43
|
## Usage
|
|
34
44
|
|
|
35
45
|
```
|
|
36
|
-
pullr [
|
|
46
|
+
pullr [options] [DIR]
|
|
37
47
|
```
|
|
38
48
|
|
|
39
49
|
| Option | Default | Meaning |
|
|
40
50
|
| --- | --- | --- |
|
|
41
51
|
| `DIR` | current directory | Where to look for repositories |
|
|
52
|
+
| `-j`, `--jobs N` | `4` | 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. |
|
|
42
53
|
| `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
|
|
54
|
+
| `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
|
|
55
|
+
| `-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. |
|
|
43
56
|
| `-V`, `--version` | | Print the version |
|
|
44
57
|
| `-h`, `--help` | | Show help |
|
|
45
58
|
|
|
@@ -47,6 +60,10 @@ Behaviour:
|
|
|
47
60
|
|
|
48
61
|
- Runs `git pull --ff-only`, so it never creates merge commits. A branch that
|
|
49
62
|
has diverged from its upstream is reported as failed and left untouched.
|
|
63
|
+
With `--rebase`, that branch's local commits are rebased onto the upstream
|
|
64
|
+
instead, and the line reads `+ api (main, 3 new commits, 2 rebased)`. There
|
|
65
|
+
is no auto-stash: a dirty tree makes git refuse the rebase, which is
|
|
66
|
+
reported as failed.
|
|
50
67
|
- Does not descend into a repository once found - nested repositories and
|
|
51
68
|
submodules are left to their parent.
|
|
52
69
|
- Skips repositories on a detached `HEAD` and branches with no upstream.
|
|
@@ -56,6 +73,30 @@ Behaviour:
|
|
|
56
73
|
Each repository gets one line: `+` updated, `=` already up to date, `-`
|
|
57
74
|
skipped, `x` failed (with git's output indented below it).
|
|
58
75
|
|
|
76
|
+
### Dry run
|
|
77
|
+
|
|
78
|
+
`pullr --dry-run` shows what a run would do, without pulling:
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
$ pullr --dry-run ~/work
|
|
82
|
+
Dry run: nothing will be pulled.
|
|
83
|
+
+ api (main, 3 new commits)
|
|
84
|
+
= web (main)
|
|
85
|
+
x infra (main)
|
|
86
|
+
diverged from upstream (behind 2, ahead 1): cannot fast-forward
|
|
87
|
+
- scratch (main has no upstream)
|
|
88
|
+
|
|
89
|
+
Repos: 4 would update: 1 up to date: 1 skipped: 1 would fail: 1
|
|
90
|
+
Failed: infra
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It runs `git fetch` in each repository first, so the answer reflects the
|
|
94
|
+
remote as it is now, then prints the same lines a real run would (with
|
|
95
|
+
`--rebase`, a diverged repository shows as `+ ... , 2 to rebase` instead of
|
|
96
|
+
`x`; whether the rebase would conflict is only known by running it). Fetching
|
|
97
|
+
only updates remote-tracking refs: the working tree and current branch are
|
|
98
|
+
never modified. Exit codes match a real run.
|
|
99
|
+
|
|
59
100
|
## Development
|
|
60
101
|
|
|
61
102
|
```sh
|
package/bin/pullr
CHANGED
|
@@ -1,32 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Recursively find git repos under a directory and fast-forward pull each one.
|
|
3
|
-
# Usage: pullr [--max-depth N] [DIR]
|
|
3
|
+
# Usage: pullr [--jobs N] [--max-depth N] [--dry-run] [--rebase] [DIR]
|
|
4
4
|
set -euo pipefail
|
|
5
5
|
|
|
6
|
-
VERSION="0.1
|
|
6
|
+
VERSION="0.3.1" # set by the release workflow
|
|
7
7
|
|
|
8
8
|
usage() {
|
|
9
9
|
cat <<'EOF'
|
|
10
|
-
Usage: pullr [
|
|
10
|
+
Usage: pullr [options] [DIR]
|
|
11
11
|
|
|
12
12
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
-j, --jobs N Work on up to N repositories in parallel (default: 4).
|
|
17
|
+
Output is buffered per repository and printed in order.
|
|
18
|
+
With N > 1 git never prompts for credentials.
|
|
19
|
+
--max-depth N How many directory levels below DIR to search (default: 2).
|
|
20
|
+
0 = only DIR itself, 1 = DIR and its direct children, ...
|
|
21
|
+
-n, --dry-run Fetch, then show what a run would do to each repository
|
|
22
|
+
without pulling anything.
|
|
23
|
+
-r, --rebase Rebase local commits onto the upstream instead of
|
|
24
|
+
refusing a branch that has diverged (git pull --rebase).
|
|
25
|
+
A rebase that hits a conflict is aborted, leaving the
|
|
26
|
+
repository as it was.
|
|
27
|
+
-V, --version Print the version.
|
|
28
|
+
-h, --help Show this help.
|
|
20
29
|
EOF
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
mode=pull
|
|
33
|
+
jobs=4
|
|
34
|
+
rebase=0
|
|
23
35
|
max_depth=2
|
|
24
36
|
root=""
|
|
25
37
|
|
|
26
38
|
while (($#)); do
|
|
27
39
|
case "$1" in
|
|
40
|
+
-j|--jobs) jobs="${2:-}"; shift 2 || { usage >&2; exit 2; } ;;
|
|
41
|
+
--jobs=*) jobs="${1#*=}"; shift ;;
|
|
28
42
|
--max-depth) max_depth="${2:-}"; shift 2 || { usage >&2; exit 2; } ;;
|
|
29
43
|
--max-depth=*) max_depth="${1#*=}"; shift ;;
|
|
44
|
+
-n|--dry-run) mode=dryrun; shift ;;
|
|
45
|
+
-r|--rebase) rebase=1; shift ;;
|
|
30
46
|
-h|--help) usage; exit 0 ;;
|
|
31
47
|
-V|--version) echo "pullr $VERSION"; exit 0 ;;
|
|
32
48
|
-*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
|
@@ -36,6 +52,7 @@ while (($#)); do
|
|
|
36
52
|
esac
|
|
37
53
|
done
|
|
38
54
|
|
|
55
|
+
[[ "$jobs" =~ ^[1-9][0-9]*$ ]] || { echo "--jobs must be a positive integer" >&2; exit 2; }
|
|
39
56
|
[[ "$max_depth" =~ ^[0-9]+$ ]] || { echo "--max-depth must be a non-negative integer" >&2; exit 2; }
|
|
40
57
|
root="${root:-.}"
|
|
41
58
|
[[ -d "$root" ]] || { echo "Not a directory: $root" >&2; exit 2; }
|
|
@@ -47,47 +64,130 @@ else
|
|
|
47
64
|
green="" yellow="" red="" dim="" reset=""
|
|
48
65
|
fi
|
|
49
66
|
|
|
50
|
-
|
|
51
|
-
|
|
67
|
+
# --- per-repository work -----------------------------------------------------
|
|
68
|
+
# Each *_repo function prints a result: the first line is the category used
|
|
69
|
+
# for the summary, the rest is what the user sees. That shape lets the same
|
|
70
|
+
# functions run inline or inside a worker whose output is buffered to a file.
|
|
71
|
+
|
|
72
|
+
repo_label() {
|
|
73
|
+
local rel="${1#"$root"}"
|
|
74
|
+
rel="${rel#/}"
|
|
75
|
+
printf '%s' "${rel:-.}"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
indented() {
|
|
79
|
+
printf ' %s\n' "${1//$'\n'/$'\n' }"
|
|
80
|
+
}
|
|
52
81
|
|
|
53
82
|
pull_repo() {
|
|
54
|
-
local repo="$1" rel
|
|
55
|
-
rel="$
|
|
83
|
+
local repo="$1" rel branch
|
|
84
|
+
rel="$(repo_label "$repo")"
|
|
56
85
|
|
|
57
|
-
local branch
|
|
58
86
|
if ! branch="$(git -C "$repo" symbolic-ref --quiet --short HEAD)"; then
|
|
87
|
+
echo skipped
|
|
59
88
|
printf '%s-%s %s %s(detached HEAD)%s\n' "$yellow" "$reset" "$rel" "$dim" "$reset"
|
|
60
|
-
|
|
89
|
+
return
|
|
61
90
|
fi
|
|
62
91
|
if ! git -C "$repo" rev-parse --quiet --verify '@{upstream}' >/dev/null 2>&1; then
|
|
92
|
+
echo skipped
|
|
63
93
|
printf '%s-%s %s %s(%s has no upstream)%s\n' "$yellow" "$reset" "$rel" "$dim" "$branch" "$reset"
|
|
64
|
-
|
|
94
|
+
return
|
|
65
95
|
fi
|
|
66
96
|
|
|
67
|
-
local before after out
|
|
97
|
+
local before after out how=--ff-only
|
|
98
|
+
[[ "$rebase" == 0 ]] || how=--rebase
|
|
68
99
|
before="$(git -C "$repo" rev-parse HEAD)"
|
|
69
|
-
if ! out="$(git -C "$repo" pull
|
|
100
|
+
if ! out="$(git -C "$repo" pull "$how" 2>&1)"; then
|
|
101
|
+
# a conflicting rebase must not leave the repository mid-rebase
|
|
102
|
+
[[ "$rebase" == 0 ]] || git -C "$repo" rebase --abort >/dev/null 2>&1 || true
|
|
103
|
+
printf 'failed\t%s\n' "$rel"
|
|
70
104
|
printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$rel" "$dim" "$branch" "$reset"
|
|
71
|
-
|
|
72
|
-
|
|
105
|
+
indented "$out"
|
|
106
|
+
return
|
|
73
107
|
fi
|
|
74
108
|
after="$(git -C "$repo" rev-parse HEAD)"
|
|
75
109
|
|
|
76
110
|
if [[ "$before" == "$after" ]]; then
|
|
111
|
+
echo current
|
|
77
112
|
printf '%s=%s %s %s(%s)%s\n' "$dim" "$reset" "$rel" "$dim" "$branch" "$reset"
|
|
78
|
-
|
|
113
|
+
return
|
|
114
|
+
fi
|
|
115
|
+
# New upstream commits, and (after a rebase) local commits now on top of them.
|
|
116
|
+
local n local_n=0
|
|
117
|
+
n="$(git -C "$repo" rev-list --count "$before..@{upstream}")"
|
|
118
|
+
[[ "$rebase" == 0 ]] || local_n="$(git -C "$repo" rev-list --count '@{upstream}..HEAD')"
|
|
119
|
+
echo updated
|
|
120
|
+
printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$rel" "$dim" "$branch" \
|
|
121
|
+
"$(update_note "$n" "$local_n" rebased)" "$reset"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# "N new commit(s)[, M rebased|to rebase]"
|
|
125
|
+
update_note() {
|
|
126
|
+
local n="$1" local_n="$2" verb="$3" note
|
|
127
|
+
note="$n new commit$([[ "$n" == 1 ]] || printf s)"
|
|
128
|
+
[[ "$local_n" == 0 ]] || note+=", $local_n $verb"
|
|
129
|
+
printf '%s' "$note"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
dryrun_repo() {
|
|
133
|
+
local repo="$1" rel branch
|
|
134
|
+
rel="$(repo_label "$repo")"
|
|
135
|
+
|
|
136
|
+
if ! branch="$(git -C "$repo" symbolic-ref --quiet --short HEAD)"; then
|
|
137
|
+
echo skipped
|
|
138
|
+
printf '%s-%s %s %s(detached HEAD)%s\n' "$yellow" "$reset" "$rel" "$dim" "$reset"
|
|
139
|
+
return
|
|
140
|
+
fi
|
|
141
|
+
if ! git -C "$repo" rev-parse --quiet --verify '@{upstream}' >/dev/null 2>&1; then
|
|
142
|
+
echo skipped
|
|
143
|
+
printf '%s-%s %s %s(%s has no upstream)%s\n' "$yellow" "$reset" "$rel" "$dim" "$branch" "$reset"
|
|
144
|
+
return
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
local out
|
|
148
|
+
if ! out="$(git -C "$repo" fetch --quiet 2>&1)"; then
|
|
149
|
+
printf 'failed\t%s\n' "$rel"
|
|
150
|
+
printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$rel" "$dim" "$branch" "$reset"
|
|
151
|
+
indented "$out"
|
|
152
|
+
return
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
local counts behind ahead
|
|
156
|
+
counts="$(git -C "$repo" rev-list --left-right --count '@{upstream}...HEAD')"
|
|
157
|
+
behind="${counts%%[[:space:]]*}"
|
|
158
|
+
ahead="${counts##*[[:space:]]}"
|
|
159
|
+
|
|
160
|
+
if [[ "$behind" != 0 && "$ahead" != 0 && "$rebase" == 0 ]]; then
|
|
161
|
+
printf 'failed\t%s\n' "$rel"
|
|
162
|
+
printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$rel" "$dim" "$branch" "$reset"
|
|
163
|
+
indented "diverged from upstream (behind $behind, ahead $ahead): cannot fast-forward"
|
|
164
|
+
elif [[ "$behind" != 0 ]]; then
|
|
165
|
+
# without --rebase this branch is only reached when ahead is 0
|
|
166
|
+
echo updated
|
|
167
|
+
printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$rel" "$dim" "$branch" \
|
|
168
|
+
"$(update_note "$behind" "$ahead" "to rebase")" "$reset"
|
|
79
169
|
else
|
|
80
|
-
|
|
81
|
-
n
|
|
82
|
-
printf '%s+%s %s %s(%s, %s new commit(s))%s\n' "$green" "$reset" "$rel" "$dim" "$branch" "$n" "$reset"
|
|
83
|
-
updated=$((updated + 1))
|
|
170
|
+
echo current
|
|
171
|
+
printf '%s=%s %s %s(%s)%s\n' "$dim" "$reset" "$rel" "$dim" "$branch" "$reset"
|
|
84
172
|
fi
|
|
85
173
|
}
|
|
86
174
|
|
|
175
|
+
# Runs inside the worker pool: $1 = scratch dir, $2 = index, $3 = repo.
|
|
176
|
+
# The result file appears atomically (write, then rename) so the collector
|
|
177
|
+
# can print results in order as they land.
|
|
178
|
+
worker() {
|
|
179
|
+
exec </dev/null
|
|
180
|
+
"${mode}_repo" "$3" >"$1/$2.tmp" 2>&1 || true
|
|
181
|
+
mv "$1/$2.tmp" "$1/$2.done"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# --- discovery ---------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
repos=()
|
|
87
187
|
walk() {
|
|
88
188
|
local dir="$1" depth="$2"
|
|
89
189
|
if [[ -e "$dir/.git" ]]; then
|
|
90
|
-
|
|
190
|
+
repos+=("$dir")
|
|
91
191
|
return
|
|
92
192
|
fi
|
|
93
193
|
((depth < max_depth)) || return 0
|
|
@@ -97,18 +197,70 @@ walk() {
|
|
|
97
197
|
walk "${child%/}" $((depth + 1))
|
|
98
198
|
done
|
|
99
199
|
}
|
|
100
|
-
|
|
101
200
|
walk "$root" 0
|
|
102
201
|
|
|
103
|
-
|
|
104
|
-
if ((
|
|
202
|
+
count=${#repos[@]}
|
|
203
|
+
if ((count == 0)); then
|
|
105
204
|
echo "No git repositories found under $root (max depth $max_depth)"
|
|
106
205
|
exit 0
|
|
107
206
|
fi
|
|
108
207
|
|
|
208
|
+
# --- collection --------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
[[ "$mode" != dryrun ]] || printf '%sDry run: nothing will be pulled.%s\n' "$dim" "$reset"
|
|
211
|
+
|
|
212
|
+
updated=0 current=0 skipped=0 failed=0
|
|
213
|
+
failed_repos=()
|
|
214
|
+
|
|
215
|
+
consume() {
|
|
216
|
+
local text="$1" head="${1%%$'\n'*}" body="${1#*$'\n'}"
|
|
217
|
+
local category="${head%%$'\t'*}" label="${head#*$'\t'}"
|
|
218
|
+
case "$category" in
|
|
219
|
+
updated) updated=$((updated + 1)) ;;
|
|
220
|
+
current) current=$((current + 1)) ;;
|
|
221
|
+
skipped) skipped=$((skipped + 1)) ;;
|
|
222
|
+
failed) failed=$((failed + 1)); failed_repos+=("$label") ;;
|
|
223
|
+
*) echo "pullr: unexpected worker result: $text" >&2; exit 1 ;;
|
|
224
|
+
esac
|
|
225
|
+
printf '%s\n' "$body"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if ((jobs > 1 && count > 1)); then
|
|
229
|
+
scratch="$(mktemp -d)"
|
|
230
|
+
trap 'rm -rf "$scratch"' EXIT
|
|
231
|
+
export GIT_TERMINAL_PROMPT=0
|
|
232
|
+
export mode rebase root green yellow red dim reset
|
|
233
|
+
export -f worker pull_repo dryrun_repo repo_label indented update_note
|
|
234
|
+
# shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
|
|
235
|
+
for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
|
|
236
|
+
xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
|
|
237
|
+
pool=$!
|
|
238
|
+
trap 'kill "$pool" 2>/dev/null; rm -rf "$scratch"; exit 130' INT TERM
|
|
239
|
+
for ((i = 0; i < count; i++)); do
|
|
240
|
+
until [[ -e "$scratch/$i.done" ]]; do
|
|
241
|
+
if ! kill -0 "$pool" 2>/dev/null && [[ ! -e "$scratch/$i.done" ]]; then
|
|
242
|
+
echo "pullr: worker pool exited before finishing $(repo_label "${repos[i]}")" >&2
|
|
243
|
+
exit 1
|
|
244
|
+
fi
|
|
245
|
+
sleep 0.1
|
|
246
|
+
done
|
|
247
|
+
consume "$(cat "$scratch/$i.done")"
|
|
248
|
+
done
|
|
249
|
+
wait "$pool" || true
|
|
250
|
+
else
|
|
251
|
+
for repo in "${repos[@]}"; do
|
|
252
|
+
consume "$("${mode}_repo" "$repo")"
|
|
253
|
+
done
|
|
254
|
+
fi
|
|
255
|
+
|
|
109
256
|
echo
|
|
110
|
-
|
|
111
|
-
|
|
257
|
+
if [[ "$mode" == dryrun ]]; then
|
|
258
|
+
printf 'Repos: %d %swould update: %d%s up to date: %d %sskipped: %d%s %swould fail: %d%s\n' \
|
|
259
|
+
"$count" "$green" "$updated" "$reset" "$current" "$yellow" "$skipped" "$reset" "$red" "$failed" "$reset"
|
|
260
|
+
else
|
|
261
|
+
printf 'Repos: %d %supdated: %d%s up to date: %d %sskipped: %d%s %sfailed: %d%s\n' \
|
|
262
|
+
"$count" "$green" "$updated" "$reset" "$current" "$yellow" "$skipped" "$reset" "$red" "$failed" "$reset"
|
|
263
|
+
fi
|
|
112
264
|
if ((failed)); then
|
|
113
265
|
printf 'Failed: %s\n' "${failed_repos[*]}"
|
|
114
266
|
exit 1
|