@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.
Files changed (3) hide show
  1. package/README.md +43 -2
  2. package/bin/pullr +181 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # pullr
2
2
 
3
+ [![CI](https://github.com/kova1max/pullr/actions/workflows/ci.yml/badge.svg)](https://github.com/kova1max/pullr/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/%40kova1%2Fpullr?logo=npm&label=npm)](https://www.npmjs.com/package/@kova1/pullr)
5
+ [![Homebrew](https://img.shields.io/github/v/release/kova1max/pullr?logo=homebrew&label=homebrew)](https://github.com/kova1max/homebrew-tap)
6
+ [![License: MIT](https://img.shields.io/github/license/kova1max/pullr)](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 # after that, plain `pullr` works too
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 [--max-depth N] [DIR]
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.0" # set by the release workflow
6
+ VERSION="0.3.1" # set by the release workflow
7
7
 
8
8
  usage() {
9
9
  cat <<'EOF'
10
- Usage: pullr [--max-depth N] [DIR]
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
- --max-depth N How many directory levels below DIR to search (default: 2).
17
- 0 = only DIR itself, 1 = DIR and its direct children, ...
18
- -V, --version Print the version.
19
- -h, --help Show this help.
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
- updated=0 current=0 skipped=0 failed=0
51
- failed_repos=()
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="${1#"$root"}"
55
- rel="${rel#/}"; rel="${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
- skipped=$((skipped + 1)); return
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
- skipped=$((skipped + 1)); return
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 --ff-only 2>&1)"; then
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
- printf ' %s\n' "${out//$'\n'/$'\n' }"
72
- failed=$((failed + 1)); failed_repos+=("$rel"); return
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
- current=$((current + 1))
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
- local n
81
- n="$(git -C "$repo" rev-list --count "$before..$after")"
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
- pull_repo "$dir"
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
- total=$((updated + current + skipped + failed))
104
- if ((total == 0)); then
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
- printf 'Repos: %d %supdated: %d%s up to date: %d %sskipped: %d%s %sfailed: %d%s\n' \
111
- "$total" "$green" "$updated" "$reset" "$current" "$yellow" "$skipped" "$reset" "$red" "$failed" "$reset"
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kova1/pullr",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Fast-forward pull every git repository under a directory",
5
5
  "keywords": [
6
6
  "git",