@kova1/pullr 0.6.0 → 0.8.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 +45 -14
  2. package/bin/pullr +156 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,13 +13,13 @@ everything current means `cd`-ing into each one.
13
13
  ```console
14
14
  $ pullr ~/work
15
15
  + api (main, 3 new commits)
16
- = web (main)
17
- ~ docs (main, 2 commits held back by local changes)
16
+ + web (main, 1 new commit, local changes reapplied)
17
+ ~ docs (main, 2 commits held back because local changes conflict with them)
18
18
  - scratch (main has no upstream)
19
19
  x infra (main)
20
20
  diverged from upstream (behind 2, ahead 1): cannot fast-forward
21
21
 
22
- Repos: 5 updated: 1 up to date: 1 dirty: 1 skipped: 1 failed: 1
22
+ Repos: 5 updated: 2 up to date: 0 dirty: 1 skipped: 1 failed: 1
23
23
  Dirty: docs
24
24
  Failed: infra
25
25
  ```
@@ -55,14 +55,21 @@ first. It will never:
55
55
  failed and left exactly as it was. Rebasing it is opt-in with `--rebase`,
56
56
  and a rebase that hits a conflict is aborted, leaving the repository as it
57
57
  was.
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.
58
+ - **Leave your changes half-merged, or lose them.** When local edits or
59
+ untracked files are in the way of an update, pullr stashes them, updates,
60
+ and re-applies them on top. If they conflict with the incoming commits, it
61
+ puts the repository back exactly as it was - same commit, same files, same
62
+ staged and unstaged changes, no stash left behind - and reports it as
63
+ dirty. Edits that don't touch incoming files are never stashed at all.
64
+ `--no-autostash` skips the stash and just reports the repository as dirty.
62
65
  - **Touch a repository on a detached `HEAD`**, or a branch with no upstream.
63
66
  Both are skipped.
64
- - **Enter a nested repository.** Repositories inside another repository,
65
- including submodules, are left to their parent.
67
+ - **Enter a nested repository.** Repositories inside another repository are
68
+ left to their parent.
69
+ - **Move a submodule off its branch.** With `--submodules`, a submodule is
70
+ only ever fast-forwarded on the branch it is on. (If you set git's
71
+ `submodule.recurse`, pullr does what `git pull` does with it: checks
72
+ submodules out at the recorded commit.)
66
73
  - **Follow symlinked directories.**
67
74
 
68
75
  ## Usage
@@ -78,6 +85,8 @@ pullr [options] [DIR]
78
85
  | `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
79
86
  | `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
80
87
  | `-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. |
88
+ | `--no-autostash` | | Don't stash local changes that are in the way of an update; report the repository as dirty instead. Autostash is on by default, see "What it will never do". |
89
+ | `--submodules` | off | Also fast-forward each submodule that is checked out on a branch, see below |
81
90
  | `--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. |
82
91
  | `-V`, `--version` | | Print the version |
83
92
  | `-h`, `--help` | | Show help |
@@ -85,7 +94,7 @@ pullr [options] [DIR]
85
94
  ### Output
86
95
 
87
96
  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), `-`
97
+ dirty (local changes conflict with the update, which is held back), `-`
89
98
  skipped, `x` failed (with the reason indented below it). Exits `1` if any
90
99
  pull failed, `2` on invalid arguments, `0` otherwise; a dirty repository is
91
100
  not a failure.
@@ -108,12 +117,34 @@ Failed: infra
108
117
  ```
109
118
 
110
119
  It runs `git fetch` in each repository first, so the answer reflects the
111
- remote as it is now, then prints the same lines a real run would (with
112
- `--rebase`, a diverged repository shows as `+ ... , 2 to rebase` instead of
113
- `x`; whether the rebase would conflict is only known by running it). Fetching
120
+ remote as it is now, then prints the same lines a real run would. An update
121
+ that would need to stash local changes shows as `+ ... , local changes to
122
+ autostash`, and with `--rebase` a diverged repository shows as
123
+ `+ ... , 2 to rebase` instead of `x`; whether re-applying or rebasing would
124
+ conflict is only known by running it. Fetching
114
125
  only updates remote-tracking refs: the working tree and current branch are
115
126
  never modified. Exit codes match a real run.
116
127
 
128
+ ### Submodules
129
+
130
+ By default pullr updates a repository the way `git pull` does: submodules stay
131
+ where they are, unless you set git's `submodule.recurse`, in which case they
132
+ are checked out at the commits the parent now records, as `git pull` would.
133
+
134
+ `--submodules` is for working inside submodules. Each submodule that is
135
+ checked out on a branch is treated as a repository of its own - fetched,
136
+ fast-forwarded with the same rules, and listed under its parent:
137
+
138
+ ```console
139
+ $ pullr --submodules ~/work
140
+ + app (main, 2 new commits)
141
+ + app/lib/core (main, 5 new commits)
142
+ - app/vendor/sdk (detached HEAD)
143
+ ```
144
+
145
+ Submodules on a detached HEAD (git's default after `git submodule update`) are
146
+ skipped, and uninitialized ones are left out.
147
+
117
148
  ## How it compares
118
149
 
119
150
  | | 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) |
@@ -123,7 +154,7 @@ never modified. Exit codes match a real run.
123
154
  | Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
124
155
  | Update | fetch + fast‑forward | `git pull` | fetch + fast‑forward | `git pull` | no built-in pull |
125
156
  | 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 | - |
157
+ | Local changes in the way | stashed and reapplied; rolled back on conflict | git's error | skipped | git's error | - |
127
158
  | Dry run | yes | no | no | no | prints commands |
128
159
 
129
160
  **Why "8 at a time" and not "all at once".** Many self-hosted git servers
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] [--no-autostash] [--submodules] [--no-color] [DIR]
4
4
  set -euo pipefail
5
5
 
6
- VERSION="0.6.0" # set by the release workflow
6
+ VERSION="0.8.0" # set by the release workflow
7
7
 
8
8
  usage() {
9
9
  cat <<'EOF'
@@ -24,6 +24,14 @@ 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
+ --no-autostash Don't stash local changes that are in the way of an
28
+ update. By default they are stashed, the update is
29
+ made and they are re-applied; if re-applying conflicts,
30
+ the repository is put back exactly as it was.
31
+ --autostash The default; accepted for scripts that want to be explicit.
32
+ --submodules Also fast-forward each submodule that is checked out on a
33
+ branch, with the same rules as any other repository.
34
+ Submodules on a detached HEAD are skipped.
27
35
  --no-color Disable colored output. Also disabled when NO_COLOR is
28
36
  set (https://no-color.org) or output is not a terminal.
29
37
  -V, --version Print the version.
@@ -34,6 +42,8 @@ EOF
34
42
  mode=pull
35
43
  jobs=8
36
44
  rebase=0
45
+ autostash=1
46
+ submodules=0
37
47
  color=1
38
48
  max_depth=2
39
49
  root=""
@@ -46,6 +56,9 @@ while (($#)); do
46
56
  --max-depth=*) max_depth="${1#*=}"; shift ;;
47
57
  -n|--dry-run) mode=dryrun; shift ;;
48
58
  -r|--rebase) rebase=1; shift ;;
59
+ --autostash) autostash=1; shift ;;
60
+ --no-autostash) autostash=0; shift ;;
61
+ --submodules) submodules=1; shift ;;
49
62
  --no-color) color=0; shift ;;
50
63
  -h|--help) usage; exit 0 ;;
51
64
  -V|--version) echo "pullr $VERSION"; exit 0 ;;
@@ -120,8 +133,8 @@ report_diverged() {
120
133
 
121
134
  report_dirty() {
122
135
  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"
136
+ printf '%s~%s %s %s(%s, %s held back %s)%s\n' \
137
+ "$yellow" "$reset" "$1" "$dim" "$2" "$(commits "$3")" "${4:-by local changes}" "$reset"
125
138
  }
126
139
 
127
140
  report_current() {
@@ -133,6 +146,7 @@ report_updated() {
133
146
  local note
134
147
  note="$3 new commit$([[ "$3" == 1 ]] || printf s)"
135
148
  [[ "$4" == 0 ]] || note+=", $4 $5"
149
+ [[ -z "${6:-}" ]] || note+=", $6"
136
150
  echo updated
137
151
  printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$1" "$dim" "$2" "$note" "$reset"
138
152
  }
@@ -142,9 +156,24 @@ commits() {
142
156
  printf '%s commit%s' "$1" "$([[ "$1" == 1 ]] || printf s)"
143
157
  }
144
158
 
145
- # Uncommitted changes to tracked files, staged or not.
159
+ # Uncommitted changes to tracked files, staged or not. A submodule checked out
160
+ # at a different commit than the one recorded is not one: git neither refuses
161
+ # to update over it nor touches it.
146
162
  has_tracked_changes() {
147
- ! git -C "$1" diff --quiet HEAD --
163
+ ! git -C "$1" diff --quiet --ignore-submodules HEAD --
164
+ }
165
+
166
+ has_local_changes() {
167
+ [[ -n "$(git -C "$1" status --porcelain --ignore-submodules=all)" ]]
168
+ }
169
+
170
+ # Mirror `git pull` for people who set submodule.recurse: after the update,
171
+ # check submodules out at the commits the parent now records. With
172
+ # --submodules each one is fast-forwarded on its own branch instead.
173
+ sync_submodules() {
174
+ [[ "$submodules" == 0 ]] || return 0
175
+ [[ "$(git -C "$1" config --type=bool --get submodule.recurse 2>/dev/null)" == true ]] || return 0
176
+ git -C "$1" submodule update --recursive 2>&1
148
177
  }
149
178
 
150
179
  pull_repo() {
@@ -157,24 +186,28 @@ pull_repo() {
157
186
  elif [[ "$ahead" == 0 ]]; then
158
187
  # git refuses, touching nothing, when local changes are in the way
159
188
  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
189
+ report_synced "$repo" "$rel" "$branch" "$behind" 0 ""
190
+ elif has_local_changes "$repo" && [[ "$autostash" == 1 ]]; then
191
+ autostash_update "$repo" "$rel" "$branch" "$behind" 0 ff
192
+ elif has_local_changes "$repo"; then
162
193
  report_dirty "$rel" "$branch" "$behind"
163
194
  else
164
195
  report_failed "$rel" "$branch" "$out"
165
196
  fi
166
197
  elif [[ "$rebase" == 0 ]]; then
167
198
  report_diverged "$rel" "$branch" "$behind" "$ahead"
199
+ elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
200
+ autostash_update "$repo" "$rel" "$branch" "$behind" "$ahead" rebase
168
201
  elif has_tracked_changes "$repo"; then
169
202
  report_dirty "$rel" "$branch" "$behind"
170
203
  elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
171
- report_updated "$rel" "$branch" "$behind" "$ahead" rebased
204
+ report_synced "$repo" "$rel" "$branch" "$behind" "$ahead" rebased
172
205
  else
173
206
  # A rebase that stopped on a conflict must not leave the repository
174
207
  # mid-rebase; --abort succeeds only when one is in progress.
175
208
  if git -C "$repo" rebase --abort >/dev/null 2>&1; then
176
209
  report_failed "$rel" "$branch" "$out"
177
- elif [[ -n "$(git -C "$repo" status --porcelain)" ]]; then
210
+ elif has_local_changes "$repo"; then
178
211
  report_dirty "$rel" "$branch" "$behind"
179
212
  else
180
213
  report_failed "$rel" "$branch" "$out"
@@ -182,12 +215,98 @@ pull_repo() {
182
215
  fi
183
216
  }
184
217
 
218
+ # Local changes are in the way of the update ($6: ff or rebase). Stash them,
219
+ # update, and re-apply them. If re-applying is anything but clean, put the
220
+ # repository back exactly as it was and report it as dirty. The stash is only
221
+ # dropped once its changes are back in the working tree.
222
+ autostash_update() {
223
+ local repo="$1" rel="$2" branch="$3" behind="$4" ahead="$5" how="$6"
224
+ local before stash out
225
+ before="$(git -C "$repo" rev-parse HEAD)"
226
+ if ! out="$(git -C "$repo" stash push --include-untracked --quiet -m "pullr autostash" 2>&1)" ||
227
+ ! stash="$(git -C "$repo" rev-parse --verify --quiet refs/stash)"; then
228
+ report_failed "$rel" "$branch" "could not stash local changes:"$'\n'"$out"
229
+ return
230
+ fi
231
+
232
+ if [[ "$how" == ff ]]; then
233
+ out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"
234
+ else
235
+ out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"
236
+ fi || {
237
+ # Not caused by local changes - they were stashed. Undo, then restore
238
+ # them onto the commit they came from, where they apply cleanly.
239
+ git -C "$repo" rebase --abort >/dev/null 2>&1 || true
240
+ if restore_stash "$repo" "$stash"; then
241
+ report_failed "$rel" "$branch" "$out"
242
+ else
243
+ report_failed "$rel" "$branch" "$out"$'\n'"$(stash_kept "$repo")"
244
+ fi
245
+ return
246
+ }
247
+
248
+ if git -C "$repo" stash apply --index --quiet "$stash" >/dev/null 2>&1; then
249
+ drop_stash "$repo" "$stash"
250
+ local verb=""
251
+ [[ "$how" == ff ]] || verb=rebased
252
+ report_synced "$repo" "$rel" "$branch" "$behind" "$([[ "$how" == ff ]] && echo 0 || echo "$ahead")" "$verb" "local changes reapplied"
253
+ return
254
+ fi
255
+
256
+ # Conflict: go back to where we started.
257
+ git -C "$repo" reset --hard --quiet "$before"
258
+ clear_restored_untracked "$repo" "$stash"
259
+ if restore_stash "$repo" "$stash"; then
260
+ report_dirty "$rel" "$branch" "$behind" "because local changes conflict with them"
261
+ else
262
+ report_failed "$rel" "$branch" "could not re-apply local changes after an update conflict."$'\n'"$(stash_kept "$repo")"
263
+ fi
264
+ }
265
+
266
+ # Re-apply a stash onto the commit it was made from, then drop it.
267
+ restore_stash() {
268
+ git -C "$1" stash apply --index --quiet "$2" >/dev/null 2>&1 || return 1
269
+ drop_stash "$1" "$2"
270
+ }
271
+
272
+ # `git stash drop` takes stash@{n}, not a commit: find the entry that is ours.
273
+ drop_stash() {
274
+ local ref
275
+ ref="$(git -C "$1" stash list --format='%gd %H' | awk -v c="$2" '$2 == c { print $1; exit }')"
276
+ [[ -z "$ref" ]] || git -C "$1" stash drop --quiet "$ref" >/dev/null 2>&1 || true
277
+ }
278
+
279
+ # A failed `stash apply` may already have written the stash's untracked files
280
+ # back. They were moved into the stash, so anything at those paths now came
281
+ # from that apply; remove them so the stash can be applied again.
282
+ clear_restored_untracked() {
283
+ local path
284
+ git -C "$1" rev-parse --verify --quiet "$2^3" >/dev/null || return 0
285
+ while IFS= read -r path; do
286
+ [[ -z "$path" ]] || rm -f -- "$1/$path"
287
+ done < <(git -C "$1" ls-tree -r --name-only "$2^3")
288
+ }
289
+
290
+ stash_kept() {
291
+ printf 'Your local changes are safe in the stash (git -C %q stash list).' "$1"
292
+ }
293
+
294
+ # The repository was updated; report it, unless syncing submodules failed.
295
+ report_synced() {
296
+ local out
297
+ if out="$(sync_submodules "$1")"; then
298
+ report_updated "$2" "$3" "$4" "$5" "$6" "${7:-}"
299
+ else
300
+ report_failed "$2" "$3" "updated, but updating submodules failed:"$'\n'"$out"
301
+ fi
302
+ }
303
+
185
304
  # Would local changes stop a fast-forward? Git refuses when a file it has to
186
305
  # update is modified locally or exists untracked, so compare the two lists.
187
306
  changes_block_update() {
188
307
  local repo="$1" incoming local_paths
189
308
  incoming="$(git -C "$repo" diff --name-only HEAD '@{upstream}')"
190
- local_paths="$(git -C "$repo" status --porcelain --untracked-files=all | cut -c4-)"
309
+ local_paths="$(git -C "$repo" status --porcelain --untracked-files=all --ignore-submodules=all | cut -c4-)"
191
310
  [[ -n "$incoming" && -n "$local_paths" ]] || return 1
192
311
  printf '%s\n' "$local_paths" | grep -qFxf <(printf '%s\n' "$incoming")
193
312
  }
@@ -200,13 +319,17 @@ dryrun_repo() {
200
319
  if [[ "$behind" == 0 ]]; then
201
320
  report_current "$rel" "$branch"
202
321
  elif [[ "$ahead" == 0 ]]; then
203
- if changes_block_update "$repo"; then
322
+ if changes_block_update "$repo" && [[ "$autostash" == 1 ]]; then
323
+ report_updated "$rel" "$branch" "$behind" 0 "" "local changes to autostash"
324
+ elif changes_block_update "$repo"; then
204
325
  report_dirty "$rel" "$branch" "$behind"
205
326
  else
206
327
  report_updated "$rel" "$branch" "$behind" 0 ""
207
328
  fi
208
329
  elif [[ "$rebase" == 0 ]]; then
209
330
  report_diverged "$rel" "$branch" "$behind" "$ahead"
331
+ elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
332
+ report_updated "$rel" "$branch" "$behind" "$ahead" "to rebase" "local changes to autostash"
210
333
  elif has_tracked_changes "$repo"; then
211
334
  report_dirty "$rel" "$branch" "$behind"
212
335
  else
@@ -225,11 +348,12 @@ worker() {
225
348
 
226
349
  # --- discovery ---------------------------------------------------------------
227
350
 
228
- repos=()
351
+ repos=() nested=()
229
352
  walk() {
230
353
  local dir="$1" depth="$2"
231
354
  if [[ -e "$dir/.git" ]]; then
232
- repos+=("$dir")
355
+ repos+=("$dir") nested+=(0)
356
+ [[ "$submodules" == 0 ]] || add_submodules "$dir"
233
357
  return
234
358
  fi
235
359
  ((depth < max_depth)) || return 0
@@ -239,6 +363,17 @@ walk() {
239
363
  walk "${child%/}" $((depth + 1))
240
364
  done
241
365
  }
366
+ # Checked-out submodules, recursively, in git's order; uninitialized ones
367
+ # have no checkout to update and are left out.
368
+ add_submodules() {
369
+ local sub
370
+ # shellcheck disable=SC2016 # $displaypath is expanded by git submodule foreach
371
+ while IFS= read -r sub; do
372
+ [[ -n "$sub" ]] || continue
373
+ repos+=("$1/$sub") nested+=(1)
374
+ done < <(git -C "$1" submodule foreach --quiet --recursive 'printf "%s\n" "$displaypath"' 2>/dev/null)
375
+ }
376
+
242
377
  walk "$root" 0
243
378
 
244
379
  count=${#repos[@]}
@@ -256,6 +391,8 @@ failed_repos=() dirty_repos=()
256
391
 
257
392
  consume() {
258
393
  local text="$1" head="${1%%$'\n'*}" body="${1#*$'\n'}"
394
+ # a submodule's lines sit under its parent's
395
+ [[ "${2:-0}" == 0 ]] || body=" ${body//$'\n'/$'\n' }"
259
396
  local category="${head%%$'\t'*}" label="${head#*$'\t'}"
260
397
  case "$category" in
261
398
  updated) updated=$((updated + 1)) ;;
@@ -272,8 +409,8 @@ if ((jobs > 1 && count > 1)); then
272
409
  scratch="$(mktemp -d)"
273
410
  trap 'rm -rf "$scratch"' EXIT
274
411
  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
412
+ export mode rebase autostash submodules root green yellow red dim reset
413
+ export -f worker pull_repo dryrun_repo repo_label indented prepare report_failed report_diverged report_dirty report_current report_updated report_synced autostash_update restore_stash drop_stash clear_restored_untracked stash_kept commits has_tracked_changes has_local_changes sync_submodules changes_block_update
277
414
  # shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
278
415
  for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
279
416
  xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
@@ -287,12 +424,12 @@ if ((jobs > 1 && count > 1)); then
287
424
  fi
288
425
  sleep 0.1
289
426
  done
290
- consume "$(cat "$scratch/$i.done")"
427
+ consume "$(cat "$scratch/$i.done")" "${nested[i]}"
291
428
  done
292
429
  wait "$pool" || true
293
430
  else
294
- for repo in "${repos[@]}"; do
295
- consume "$("${mode}_repo" "$repo")"
431
+ for ((i = 0; i < count; i++)); do
432
+ consume "$("${mode}_repo" "${repos[i]}")" "${nested[i]}"
296
433
  done
297
434
  fi
298
435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kova1/pullr",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Safe, fast-forward pull every git repository under a directory",
5
5
  "keywords": [
6
6
  "git",