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