@kova1/pullr 0.4.0 → 0.6.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 +40 -16
  2. package/bin/pullr +117 -78
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Homebrew](https://img.shields.io/github/v/release/kova1max/pullr?logo=homebrew&label=homebrew)](https://github.com/kova1max/homebrew-tap)
6
6
  [![License: MIT](https://img.shields.io/github/license/kova1max/pullr)](LICENSE)
7
7
 
8
- Fast-forward pull every git repository under a directory.
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.
@@ -14,11 +14,13 @@ everything current means `cd`-ing into each one.
14
14
  $ pullr ~/work
15
15
  + api (main, 3 new commits)
16
16
  = web (main)
17
+ ~ docs (main, 2 commits held back by local changes)
17
18
  - scratch (main has no upstream)
18
19
  x infra (main)
19
- fatal: Not possible to fast-forward, aborting.
20
+ diverged from upstream (behind 2, ahead 1): cannot fast-forward
20
21
 
21
- Repos: 4 updated: 1 up to date: 1 skipped: 1 failed: 1
22
+ Repos: 5 updated: 1 up to date: 1 dirty: 1 skipped: 1 failed: 1
23
+ Dirty: docs
22
24
  Failed: infra
23
25
  ```
24
26
 
@@ -38,9 +40,6 @@ To try it without installing anything:
38
40
  npx @kova1/pullr --dry-run ~/work
39
41
  ```
40
42
 
41
- Or copy [`bin/pullr`](bin/pullr) anywhere on your `PATH` - it is a single bash
42
- script with no dependencies beyond `git`.
43
-
44
43
  Every release is also mirrored to
45
44
  [GitHub Packages](https://github.com/kova1max/pullr/pkgs/npm/pullr) as
46
45
  `@kova1max/pullr` (GitHub requires the repo owner as scope). Installing from
@@ -56,8 +55,10 @@ first. It will never:
56
55
  failed and left exactly as it was. Rebasing it is opt-in with `--rebase`,
57
56
  and a rebase that hits a conflict is aborted, leaving the repository as it
58
57
  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.
58
+ - **Stash, overwrite or discard your changes.** There is no auto-stash. If
59
+ local edits or untracked files are in the way of incoming changes, the
60
+ repository is reported as dirty and left as it was; edits that don't touch
61
+ incoming files don't stop the update.
61
62
  - **Touch a repository on a detached `HEAD`**, or a branch with no upstream.
62
63
  Both are skipped.
63
64
  - **Enter a nested repository.** Repositories inside another repository,
@@ -70,10 +71,10 @@ first. It will never:
70
71
  pullr [options] [DIR]
71
72
  ```
72
73
 
73
- | Option | Default | Meaning |
74
- | --- | --- | --- |
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. |
74
+ | Option                       | Default | Meaning |
75
+ | :--- | :--- | :--- |
76
+ | `DIR` | current directory | Where to look for repositories |
77
+ | `-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. |
77
78
  | `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
78
79
  | `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
79
80
  | `-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. |
@@ -83,9 +84,11 @@ pullr [options] [DIR]
83
84
 
84
85
  ### Output
85
86
 
86
- Each repository gets one line: `+` updated, `=` already up to date, `-`
87
- skipped, `x` failed (with git's output indented below it). Exits `1` if any
88
- pull failed, `2` on invalid arguments, `0` otherwise.
87
+ Each repository gets one line: `+` updated, `=` already up to date, `~`
88
+ dirty (local changes are in the way of the update, which is held back), `-`
89
+ skipped, `x` failed (with the reason indented below it). Exits `1` if any
90
+ pull failed, `2` on invalid arguments, `0` otherwise; a dirty repository is
91
+ not a failure.
89
92
 
90
93
  ### Dry run
91
94
 
@@ -100,7 +103,7 @@ x infra (main)
100
103
  diverged from upstream (behind 2, ahead 1): cannot fast-forward
101
104
  - scratch (main has no upstream)
102
105
 
103
- Repos: 4 would update: 1 up to date: 1 skipped: 1 would fail: 1
106
+ Repos: 4 would update: 1 up to date: 1 dirty: 0 skipped: 1 would fail: 1
104
107
  Failed: infra
105
108
  ```
106
109
 
@@ -111,6 +114,27 @@ remote as it is now, then prints the same lines a real run would (with
111
114
  only updates remote-tracking refs: the working tree and current branch are
112
115
  never modified. Exit codes match a real run.
113
116
 
117
+ ## How it compares
118
+
119
+ | | 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) |
120
+ | :--- | :--- | :--- | :--- | :--- | :--- |
121
+ | Needs | bash and git | Python 3.8+ | Python 3.10+ | Perl | Go binary |
122
+ | Finds repos | scans a directory | register first (`gita add`) | scans a directory | register first (`~/.mrconfig`) | register first (`mani.yaml`) |
123
+ | Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
124
+ | Update | fetch + fast‑forward | `git pull` | fetch + fast‑forward | `git pull` | no built-in pull |
125
+ | Merge commits | never | depends on your git config | never | depends on your git config | depends on your command |
126
+ | Local changes in the way | reported as dirty | git's error | skipped | git's error | - |
127
+ | Dry run | yes | no | no | no | prints commands |
128
+
129
+ **Why "8 at a time" and not "all at once".** Many self-hosted git servers
130
+ limit how many SSH connections can be opening at the same time; OpenSSH's
131
+ default (`MaxStartups 10:30:100`) starts refusing them past 10. Fetching
132
+ every repository at once then fails for reasons that have nothing to do with
133
+ your repositories. On a self-hosted GitLab with 61 repositories, 16 parallel
134
+ fetches produced 6-9 of those spurious failures and 32 produced 10-14, while
135
+ 8 produced none. GitHub itself handled 64 at once without errors, so raise
136
+ `-j` freely there.
137
+
114
138
  ## License
115
139
 
116
140
  [MIT](LICENSE)
package/bin/pullr CHANGED
@@ -3,7 +3,7 @@
3
3
  # Usage: pullr [--jobs N] [--max-depth N] [--dry-run] [--rebase] [--no-color] [DIR]
4
4
  set -euo pipefail
5
5
 
6
- VERSION="0.4.0" # set by the release workflow
6
+ VERSION="0.6.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: 4).
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).
@@ -32,7 +32,7 @@ EOF
32
32
  }
33
33
 
34
34
  mode=pull
35
- jobs=4
35
+ jobs=8
36
36
  rebase=0
37
37
  color=1
38
38
  max_depth=2
@@ -84,96 +84,133 @@ indented() {
84
84
  printf ' %s\n' "${1//$'\n'/$'\n' }"
85
85
  }
86
86
 
87
- pull_repo() {
88
- local repo="$1" rel branch
89
- rel="$(repo_label "$repo")"
90
-
87
+ # Shared first half of a pull and a dry run: check the branch, fetch, and
88
+ # count commits. On success it sets the caller's branch, behind and ahead;
89
+ # otherwise it prints the result line and returns 1.
90
+ prepare() {
91
+ local repo="$1" rel="$2" out counts
91
92
  if ! branch="$(git -C "$repo" symbolic-ref --quiet --short HEAD)"; then
92
93
  echo skipped
93
94
  printf '%s-%s %s %s(detached HEAD)%s\n' "$yellow" "$reset" "$rel" "$dim" "$reset"
94
- return
95
+ return 1
95
96
  fi
96
97
  if ! git -C "$repo" rev-parse --quiet --verify '@{upstream}' >/dev/null 2>&1; then
97
98
  echo skipped
98
99
  printf '%s-%s %s %s(%s has no upstream)%s\n' "$yellow" "$reset" "$rel" "$dim" "$branch" "$reset"
99
- return
100
+ return 1
100
101
  fi
101
-
102
- local before after out how=--ff-only
103
- [[ "$rebase" == 0 ]] || how=--rebase
104
- before="$(git -C "$repo" rev-parse HEAD)"
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"
109
- printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$rel" "$dim" "$branch" "$reset"
110
- indented "$out"
111
- return
102
+ if ! out="$(git -C "$repo" fetch --quiet 2>&1)"; then
103
+ report_failed "$rel" "$branch" "$out"
104
+ return 1
112
105
  fi
113
- after="$(git -C "$repo" rev-parse HEAD)"
106
+ counts="$(git -C "$repo" rev-list --left-right --count '@{upstream}...HEAD')"
107
+ behind="${counts%%[[:space:]]*}"
108
+ ahead="${counts##*[[:space:]]}"
109
+ }
114
110
 
115
- if [[ "$before" == "$after" ]]; then
116
- echo current
117
- printf '%s=%s %s %s(%s)%s\n' "$dim" "$reset" "$rel" "$dim" "$branch" "$reset"
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')"
111
+ report_failed() {
112
+ printf 'failed\t%s\n' "$1"
113
+ printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$1" "$dim" "$2" "$reset"
114
+ indented "$3"
115
+ }
116
+
117
+ report_diverged() {
118
+ report_failed "$1" "$2" "diverged from upstream (behind $3, ahead $4): cannot fast-forward"
119
+ }
120
+
121
+ report_dirty() {
122
+ printf 'dirty\t%s\n' "$1"
123
+ printf '%s~%s %s %s(%s, %s held back by local changes)%s\n' \
124
+ "$yellow" "$reset" "$1" "$dim" "$2" "$(commits "$3")" "$reset"
125
+ }
126
+
127
+ report_current() {
128
+ echo current
129
+ printf '%s=%s %s %s(%s)%s\n' "$dim" "$reset" "$1" "$dim" "$2" "$reset"
130
+ }
131
+
132
+ report_updated() {
133
+ local note
134
+ note="$3 new commit$([[ "$3" == 1 ]] || printf s)"
135
+ [[ "$4" == 0 ]] || note+=", $4 $5"
124
136
  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"
137
+ printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$1" "$dim" "$2" "$note" "$reset"
127
138
  }
128
139
 
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"
140
+ # "1 commit", "3 commits"
141
+ commits() {
142
+ printf '%s commit%s' "$1" "$([[ "$1" == 1 ]] || printf s)"
135
143
  }
136
144
 
137
- dryrun_repo() {
138
- local repo="$1" rel branch
145
+ # Uncommitted changes to tracked files, staged or not.
146
+ has_tracked_changes() {
147
+ ! git -C "$1" diff --quiet HEAD --
148
+ }
149
+
150
+ pull_repo() {
151
+ local repo="$1" rel branch behind ahead out
139
152
  rel="$(repo_label "$repo")"
153
+ prepare "$repo" "$rel" || return 0
140
154
 
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
155
+ if [[ "$behind" == 0 ]]; then
156
+ report_current "$rel" "$branch"
157
+ elif [[ "$ahead" == 0 ]]; then
158
+ # git refuses, touching nothing, when local changes are in the way
159
+ if out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"; then
160
+ report_updated "$rel" "$branch" "$behind" 0 ""
161
+ elif [[ -n "$(git -C "$repo" status --porcelain)" ]]; then
162
+ report_dirty "$rel" "$branch" "$behind"
163
+ else
164
+ report_failed "$rel" "$branch" "$out"
165
+ fi
166
+ elif [[ "$rebase" == 0 ]]; then
167
+ report_diverged "$rel" "$branch" "$behind" "$ahead"
168
+ elif has_tracked_changes "$repo"; then
169
+ report_dirty "$rel" "$branch" "$behind"
170
+ elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
171
+ report_updated "$rel" "$branch" "$behind" "$ahead" rebased
172
+ else
173
+ # A rebase that stopped on a conflict must not leave the repository
174
+ # mid-rebase; --abort succeeds only when one is in progress.
175
+ if git -C "$repo" rebase --abort >/dev/null 2>&1; then
176
+ report_failed "$rel" "$branch" "$out"
177
+ elif [[ -n "$(git -C "$repo" status --porcelain)" ]]; then
178
+ report_dirty "$rel" "$branch" "$behind"
179
+ else
180
+ report_failed "$rel" "$branch" "$out"
181
+ fi
150
182
  fi
183
+ }
151
184
 
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
185
+ # Would local changes stop a fast-forward? Git refuses when a file it has to
186
+ # update is modified locally or exists untracked, so compare the two lists.
187
+ changes_block_update() {
188
+ local repo="$1" incoming local_paths
189
+ incoming="$(git -C "$repo" diff --name-only HEAD '@{upstream}')"
190
+ local_paths="$(git -C "$repo" status --porcelain --untracked-files=all | cut -c4-)"
191
+ [[ -n "$incoming" && -n "$local_paths" ]] || return 1
192
+ printf '%s\n' "$local_paths" | grep -qFxf <(printf '%s\n' "$incoming")
193
+ }
159
194
 
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:]]}"
195
+ dryrun_repo() {
196
+ local repo="$1" rel branch behind ahead
197
+ rel="$(repo_label "$repo")"
198
+ prepare "$repo" "$rel" || return 0
164
199
 
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"
200
+ if [[ "$behind" == 0 ]]; then
201
+ report_current "$rel" "$branch"
202
+ elif [[ "$ahead" == 0 ]]; then
203
+ if changes_block_update "$repo"; then
204
+ report_dirty "$rel" "$branch" "$behind"
205
+ else
206
+ report_updated "$rel" "$branch" "$behind" 0 ""
207
+ fi
208
+ elif [[ "$rebase" == 0 ]]; then
209
+ report_diverged "$rel" "$branch" "$behind" "$ahead"
210
+ elif has_tracked_changes "$repo"; then
211
+ report_dirty "$rel" "$branch" "$behind"
174
212
  else
175
- echo current
176
- printf '%s=%s %s %s(%s)%s\n' "$dim" "$reset" "$rel" "$dim" "$branch" "$reset"
213
+ report_updated "$rel" "$branch" "$behind" "$ahead" "to rebase"
177
214
  fi
178
215
  }
179
216
 
@@ -214,8 +251,8 @@ fi
214
251
 
215
252
  [[ "$mode" != dryrun ]] || printf '%sDry run: nothing will be pulled.%s\n' "$dim" "$reset"
216
253
 
217
- updated=0 current=0 skipped=0 failed=0
218
- failed_repos=()
254
+ updated=0 current=0 skipped=0 failed=0 dirty=0
255
+ failed_repos=() dirty_repos=()
219
256
 
220
257
  consume() {
221
258
  local text="$1" head="${1%%$'\n'*}" body="${1#*$'\n'}"
@@ -224,6 +261,7 @@ consume() {
224
261
  updated) updated=$((updated + 1)) ;;
225
262
  current) current=$((current + 1)) ;;
226
263
  skipped) skipped=$((skipped + 1)) ;;
264
+ dirty) dirty=$((dirty + 1)); dirty_repos+=("$label") ;;
227
265
  failed) failed=$((failed + 1)); failed_repos+=("$label") ;;
228
266
  *) echo "pullr: unexpected worker result: $text" >&2; exit 1 ;;
229
267
  esac
@@ -235,7 +273,7 @@ if ((jobs > 1 && count > 1)); then
235
273
  trap 'rm -rf "$scratch"' EXIT
236
274
  export GIT_TERMINAL_PROMPT=0
237
275
  export mode rebase root green yellow red dim reset
238
- export -f worker pull_repo dryrun_repo repo_label indented update_note
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
239
277
  # shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
240
278
  for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
241
279
  xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
@@ -260,12 +298,13 @@ fi
260
298
 
261
299
  echo
262
300
  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"
301
+ printf 'Repos: %d %swould update: %d%s up to date: %d %sdirty: %d skipped: %d%s %swould fail: %d%s\n' \
302
+ "$count" "$green" "$updated" "$reset" "$current" "$yellow" "$dirty" "$skipped" "$reset" "$red" "$failed" "$reset"
265
303
  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"
304
+ printf 'Repos: %d %supdated: %d%s up to date: %d %sdirty: %d skipped: %d%s %sfailed: %d%s\n' \
305
+ "$count" "$green" "$updated" "$reset" "$current" "$yellow" "$dirty" "$skipped" "$reset" "$red" "$failed" "$reset"
268
306
  fi
307
+ ((dirty == 0)) || printf 'Dirty: %s\n' "${dirty_repos[*]}"
269
308
  if ((failed)); then
270
309
  printf 'Failed: %s\n' "${failed_repos[*]}"
271
310
  exit 1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kova1/pullr",
3
- "version": "0.4.0",
4
- "description": "Fast-forward pull every git repository under a directory",
3
+ "version": "0.6.0",
4
+ "description": "Safe, fast-forward pull every git repository under a directory",
5
5
  "keywords": [
6
6
  "git",
7
7
  "pull",