@kova1/pullr 0.7.0 → 0.9.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 +22 -13
  2. package/bin/pullr +115 -10
  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,10 +55,13 @@ 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
67
  - **Enter a nested repository.** Repositories inside another repository are
@@ -75,6 +78,9 @@ first. It will never:
75
78
  pullr [options] [DIR]
76
79
  ```
77
80
 
81
+ Short options can be combined: `-rs` is `-r -s`, and `-j8` or `-rj 8` set
82
+ the number of jobs.
83
+
78
84
  | Option                       | Default | Meaning |
79
85
  | :--- | :--- | :--- |
80
86
  | `DIR` | current directory | Where to look for repositories |
@@ -82,7 +88,8 @@ pullr [options] [DIR]
82
88
  | `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
83
89
  | `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
84
90
  | `-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. |
85
- | `--submodules` | off | Also fast-forward each submodule that is checked out on a branch, see below |
91
+ | `--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". |
92
+ | `-s`, `--submodules` | off | Also fast-forward each submodule that is checked out on a branch, see below |
86
93
  | `--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. |
87
94
  | `-V`, `--version` | | Print the version |
88
95
  | `-h`, `--help` | | Show help |
@@ -90,7 +97,7 @@ pullr [options] [DIR]
90
97
  ### Output
91
98
 
92
99
  Each repository gets one line: `+` updated, `=` already up to date, `~`
93
- dirty (local changes are in the way of the update, which is held back), `-`
100
+ dirty (local changes conflict with the update, which is held back), `-`
94
101
  skipped, `x` failed (with the reason indented below it). Exits `1` if any
95
102
  pull failed, `2` on invalid arguments, `0` otherwise; a dirty repository is
96
103
  not a failure.
@@ -113,9 +120,11 @@ Failed: infra
113
120
  ```
114
121
 
115
122
  It runs `git fetch` in each repository first, so the answer reflects the
116
- remote as it is now, then prints the same lines a real run would (with
117
- `--rebase`, a diverged repository shows as `+ ... , 2 to rebase` instead of
118
- `x`; whether the rebase would conflict is only known by running it). Fetching
123
+ remote as it is now, then prints the same lines a real run would. An update
124
+ that would need to stash local changes shows as `+ ... , local changes to
125
+ autostash`, and with `--rebase` a diverged repository shows as
126
+ `+ ... , 2 to rebase` instead of `x`; whether re-applying or rebasing would
127
+ conflict is only known by running it. Fetching
119
128
  only updates remote-tracking refs: the working tree and current branch are
120
129
  never modified. Exit codes match a real run.
121
130
 
@@ -148,7 +157,7 @@ skipped, and uninitialized ones are left out.
148
157
  | Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
149
158
  | Update | fetch + fast‑forward | `git pull` | fetch + fast‑forward | `git pull` | no built-in pull |
150
159
  | Merge commits | never | depends on your git config | never | depends on your git config | depends on your command |
151
- | Local changes in the way | reported as dirty | git's error | skipped | git's error | - |
160
+ | Local changes in the way | stashed and reapplied; rolled back on conflict | git's error | skipped | git's error | - |
152
161
  | Dry run | yes | no | no | no | prints commands |
153
162
 
154
163
  **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] [--submodules] [--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.7.0" # set by the release workflow
6
+ VERSION="0.9.0" # set by the release workflow
7
7
 
8
8
  usage() {
9
9
  cat <<'EOF'
@@ -24,19 +24,27 @@ 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
- --submodules Also fast-forward each submodule that is checked out on a
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
+ -s, --submodules Also fast-forward each submodule that is checked out on a
28
33
  branch, with the same rules as any other repository.
29
34
  Submodules on a detached HEAD are skipped.
30
35
  --no-color Disable colored output. Also disabled when NO_COLOR is
31
36
  set (https://no-color.org) or output is not a terminal.
32
37
  -V, --version Print the version.
33
38
  -h, --help Show this help.
39
+
40
+ Short options can be combined: -rs is -r -s, and -j8 or -rj 8 set the jobs.
34
41
  EOF
35
42
  }
36
43
 
37
44
  mode=pull
38
45
  jobs=8
39
46
  rebase=0
47
+ autostash=1
40
48
  submodules=0
41
49
  color=1
42
50
  max_depth=2
@@ -44,13 +52,25 @@ root=""
44
52
 
45
53
  while (($#)); do
46
54
  case "$1" in
55
+ # bundled short options: split -rs into -r -s, and -j8 into -j 8
56
+ -[!-]?*)
57
+ opt="${1:1:1}" rest="${1:2}"
58
+ shift
59
+ if [[ "$opt" == j ]]; then
60
+ set -- -j "$rest" "$@"
61
+ else
62
+ set -- "-$opt" "-$rest" "$@"
63
+ fi
64
+ ;;
47
65
  -j|--jobs) jobs="${2:-}"; shift 2 || { usage >&2; exit 2; } ;;
48
66
  --jobs=*) jobs="${1#*=}"; shift ;;
49
67
  --max-depth) max_depth="${2:-}"; shift 2 || { usage >&2; exit 2; } ;;
50
68
  --max-depth=*) max_depth="${1#*=}"; shift ;;
51
69
  -n|--dry-run) mode=dryrun; shift ;;
52
70
  -r|--rebase) rebase=1; shift ;;
53
- --submodules) submodules=1; shift ;;
71
+ --autostash) autostash=1; shift ;;
72
+ --no-autostash) autostash=0; shift ;;
73
+ -s|--submodules) submodules=1; shift ;;
54
74
  --no-color) color=0; shift ;;
55
75
  -h|--help) usage; exit 0 ;;
56
76
  -V|--version) echo "pullr $VERSION"; exit 0 ;;
@@ -125,8 +145,8 @@ report_diverged() {
125
145
 
126
146
  report_dirty() {
127
147
  printf 'dirty\t%s\n' "$1"
128
- printf '%s~%s %s %s(%s, %s held back by local changes)%s\n' \
129
- "$yellow" "$reset" "$1" "$dim" "$2" "$(commits "$3")" "$reset"
148
+ printf '%s~%s %s %s(%s, %s held back %s)%s\n' \
149
+ "$yellow" "$reset" "$1" "$dim" "$2" "$(commits "$3")" "${4:-by local changes}" "$reset"
130
150
  }
131
151
 
132
152
  report_current() {
@@ -138,6 +158,7 @@ report_updated() {
138
158
  local note
139
159
  note="$3 new commit$([[ "$3" == 1 ]] || printf s)"
140
160
  [[ "$4" == 0 ]] || note+=", $4 $5"
161
+ [[ -z "${6:-}" ]] || note+=", $6"
141
162
  echo updated
142
163
  printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$1" "$dim" "$2" "$note" "$reset"
143
164
  }
@@ -178,6 +199,8 @@ pull_repo() {
178
199
  # git refuses, touching nothing, when local changes are in the way
179
200
  if out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"; then
180
201
  report_synced "$repo" "$rel" "$branch" "$behind" 0 ""
202
+ elif has_local_changes "$repo" && [[ "$autostash" == 1 ]]; then
203
+ autostash_update "$repo" "$rel" "$branch" "$behind" 0 ff
181
204
  elif has_local_changes "$repo"; then
182
205
  report_dirty "$rel" "$branch" "$behind"
183
206
  else
@@ -185,6 +208,8 @@ pull_repo() {
185
208
  fi
186
209
  elif [[ "$rebase" == 0 ]]; then
187
210
  report_diverged "$rel" "$branch" "$behind" "$ahead"
211
+ elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
212
+ autostash_update "$repo" "$rel" "$branch" "$behind" "$ahead" rebase
188
213
  elif has_tracked_changes "$repo"; then
189
214
  report_dirty "$rel" "$branch" "$behind"
190
215
  elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
@@ -202,11 +227,87 @@ pull_repo() {
202
227
  fi
203
228
  }
204
229
 
230
+ # Local changes are in the way of the update ($6: ff or rebase). Stash them,
231
+ # update, and re-apply them. If re-applying is anything but clean, put the
232
+ # repository back exactly as it was and report it as dirty. The stash is only
233
+ # dropped once its changes are back in the working tree.
234
+ autostash_update() {
235
+ local repo="$1" rel="$2" branch="$3" behind="$4" ahead="$5" how="$6"
236
+ local before stash out
237
+ before="$(git -C "$repo" rev-parse HEAD)"
238
+ if ! out="$(git -C "$repo" stash push --include-untracked --quiet -m "pullr autostash" 2>&1)" ||
239
+ ! stash="$(git -C "$repo" rev-parse --verify --quiet refs/stash)"; then
240
+ report_failed "$rel" "$branch" "could not stash local changes:"$'\n'"$out"
241
+ return
242
+ fi
243
+
244
+ if [[ "$how" == ff ]]; then
245
+ out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"
246
+ else
247
+ out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"
248
+ fi || {
249
+ # Not caused by local changes - they were stashed. Undo, then restore
250
+ # them onto the commit they came from, where they apply cleanly.
251
+ git -C "$repo" rebase --abort >/dev/null 2>&1 || true
252
+ if restore_stash "$repo" "$stash"; then
253
+ report_failed "$rel" "$branch" "$out"
254
+ else
255
+ report_failed "$rel" "$branch" "$out"$'\n'"$(stash_kept "$repo")"
256
+ fi
257
+ return
258
+ }
259
+
260
+ if git -C "$repo" stash apply --index --quiet "$stash" >/dev/null 2>&1; then
261
+ drop_stash "$repo" "$stash"
262
+ local verb=""
263
+ [[ "$how" == ff ]] || verb=rebased
264
+ report_synced "$repo" "$rel" "$branch" "$behind" "$([[ "$how" == ff ]] && echo 0 || echo "$ahead")" "$verb" "local changes reapplied"
265
+ return
266
+ fi
267
+
268
+ # Conflict: go back to where we started.
269
+ git -C "$repo" reset --hard --quiet "$before"
270
+ clear_restored_untracked "$repo" "$stash"
271
+ if restore_stash "$repo" "$stash"; then
272
+ report_dirty "$rel" "$branch" "$behind" "because local changes conflict with them"
273
+ else
274
+ report_failed "$rel" "$branch" "could not re-apply local changes after an update conflict."$'\n'"$(stash_kept "$repo")"
275
+ fi
276
+ }
277
+
278
+ # Re-apply a stash onto the commit it was made from, then drop it.
279
+ restore_stash() {
280
+ git -C "$1" stash apply --index --quiet "$2" >/dev/null 2>&1 || return 1
281
+ drop_stash "$1" "$2"
282
+ }
283
+
284
+ # `git stash drop` takes stash@{n}, not a commit: find the entry that is ours.
285
+ drop_stash() {
286
+ local ref
287
+ ref="$(git -C "$1" stash list --format='%gd %H' | awk -v c="$2" '$2 == c { print $1; exit }')"
288
+ [[ -z "$ref" ]] || git -C "$1" stash drop --quiet "$ref" >/dev/null 2>&1 || true
289
+ }
290
+
291
+ # A failed `stash apply` may already have written the stash's untracked files
292
+ # back. They were moved into the stash, so anything at those paths now came
293
+ # from that apply; remove them so the stash can be applied again.
294
+ clear_restored_untracked() {
295
+ local path
296
+ git -C "$1" rev-parse --verify --quiet "$2^3" >/dev/null || return 0
297
+ while IFS= read -r path; do
298
+ [[ -z "$path" ]] || rm -f -- "$1/$path"
299
+ done < <(git -C "$1" ls-tree -r --name-only "$2^3")
300
+ }
301
+
302
+ stash_kept() {
303
+ printf 'Your local changes are safe in the stash (git -C %q stash list).' "$1"
304
+ }
305
+
205
306
  # The repository was updated; report it, unless syncing submodules failed.
206
307
  report_synced() {
207
308
  local out
208
309
  if out="$(sync_submodules "$1")"; then
209
- report_updated "$2" "$3" "$4" "$5" "$6"
310
+ report_updated "$2" "$3" "$4" "$5" "$6" "${7:-}"
210
311
  else
211
312
  report_failed "$2" "$3" "updated, but updating submodules failed:"$'\n'"$out"
212
313
  fi
@@ -230,13 +331,17 @@ dryrun_repo() {
230
331
  if [[ "$behind" == 0 ]]; then
231
332
  report_current "$rel" "$branch"
232
333
  elif [[ "$ahead" == 0 ]]; then
233
- if changes_block_update "$repo"; then
334
+ if changes_block_update "$repo" && [[ "$autostash" == 1 ]]; then
335
+ report_updated "$rel" "$branch" "$behind" 0 "" "local changes to autostash"
336
+ elif changes_block_update "$repo"; then
234
337
  report_dirty "$rel" "$branch" "$behind"
235
338
  else
236
339
  report_updated "$rel" "$branch" "$behind" 0 ""
237
340
  fi
238
341
  elif [[ "$rebase" == 0 ]]; then
239
342
  report_diverged "$rel" "$branch" "$behind" "$ahead"
343
+ elif has_tracked_changes "$repo" && [[ "$autostash" == 1 ]]; then
344
+ report_updated "$rel" "$branch" "$behind" "$ahead" "to rebase" "local changes to autostash"
240
345
  elif has_tracked_changes "$repo"; then
241
346
  report_dirty "$rel" "$branch" "$behind"
242
347
  else
@@ -316,8 +421,8 @@ if ((jobs > 1 && count > 1)); then
316
421
  scratch="$(mktemp -d)"
317
422
  trap 'rm -rf "$scratch"' EXIT
318
423
  export GIT_TERMINAL_PROMPT=0
319
- export mode rebase submodules root green yellow red dim reset
320
- export -f worker pull_repo dryrun_repo repo_label indented prepare report_failed report_diverged report_dirty report_current report_updated report_synced commits has_tracked_changes has_local_changes sync_submodules changes_block_update
424
+ export mode rebase autostash submodules root green yellow red dim reset
425
+ 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
321
426
  # shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
322
427
  for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
323
428
  xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kova1/pullr",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Safe, fast-forward pull every git repository under a directory",
5
5
  "keywords": [
6
6
  "git",