@kova1/pullr 0.7.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 +18 -12
  2. package/bin/pullr +101 -8
  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
@@ -82,6 +85,7 @@ pullr [options] [DIR]
82
85
  | `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
83
86
  | `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
84
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". |
85
89
  | `--submodules` | off | Also fast-forward each submodule that is checked out on a branch, see below |
86
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. |
87
91
  | `-V`, `--version` | | Print the version |
@@ -90,7 +94,7 @@ pullr [options] [DIR]
90
94
  ### Output
91
95
 
92
96
  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), `-`
97
+ dirty (local changes conflict with the update, which is held back), `-`
94
98
  skipped, `x` failed (with the reason indented below it). Exits `1` if any
95
99
  pull failed, `2` on invalid arguments, `0` otherwise; a dirty repository is
96
100
  not a failure.
@@ -113,9 +117,11 @@ Failed: infra
113
117
  ```
114
118
 
115
119
  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
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
119
125
  only updates remote-tracking refs: the working tree and current branch are
120
126
  never modified. Exit codes match a real run.
121
127
 
@@ -148,7 +154,7 @@ skipped, and uninitialized ones are left out.
148
154
  | Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
149
155
  | Update | fetch + fast‑forward | `git pull` | fetch + fast‑forward | `git pull` | no built-in pull |
150
156
  | 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 | - |
157
+ | Local changes in the way | stashed and reapplied; rolled back on conflict | git's error | skipped | git's error | - |
152
158
  | Dry run | yes | no | no | no | prints commands |
153
159
 
154
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] [--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.8.0" # set by the release workflow
7
7
 
8
8
  usage() {
9
9
  cat <<'EOF'
@@ -24,6 +24,11 @@ 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.
27
32
  --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.
@@ -37,6 +42,7 @@ EOF
37
42
  mode=pull
38
43
  jobs=8
39
44
  rebase=0
45
+ autostash=1
40
46
  submodules=0
41
47
  color=1
42
48
  max_depth=2
@@ -50,6 +56,8 @@ while (($#)); do
50
56
  --max-depth=*) max_depth="${1#*=}"; shift ;;
51
57
  -n|--dry-run) mode=dryrun; shift ;;
52
58
  -r|--rebase) rebase=1; shift ;;
59
+ --autostash) autostash=1; shift ;;
60
+ --no-autostash) autostash=0; shift ;;
53
61
  --submodules) submodules=1; shift ;;
54
62
  --no-color) color=0; shift ;;
55
63
  -h|--help) usage; exit 0 ;;
@@ -125,8 +133,8 @@ report_diverged() {
125
133
 
126
134
  report_dirty() {
127
135
  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"
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"
130
138
  }
131
139
 
132
140
  report_current() {
@@ -138,6 +146,7 @@ report_updated() {
138
146
  local note
139
147
  note="$3 new commit$([[ "$3" == 1 ]] || printf s)"
140
148
  [[ "$4" == 0 ]] || note+=", $4 $5"
149
+ [[ -z "${6:-}" ]] || note+=", $6"
141
150
  echo updated
142
151
  printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$1" "$dim" "$2" "$note" "$reset"
143
152
  }
@@ -178,6 +187,8 @@ pull_repo() {
178
187
  # git refuses, touching nothing, when local changes are in the way
179
188
  if out="$(git -C "$repo" merge --ff-only --quiet '@{upstream}' 2>&1)"; then
180
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
181
192
  elif has_local_changes "$repo"; then
182
193
  report_dirty "$rel" "$branch" "$behind"
183
194
  else
@@ -185,6 +196,8 @@ pull_repo() {
185
196
  fi
186
197
  elif [[ "$rebase" == 0 ]]; then
187
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
188
201
  elif has_tracked_changes "$repo"; then
189
202
  report_dirty "$rel" "$branch" "$behind"
190
203
  elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
@@ -202,11 +215,87 @@ pull_repo() {
202
215
  fi
203
216
  }
204
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
+
205
294
  # The repository was updated; report it, unless syncing submodules failed.
206
295
  report_synced() {
207
296
  local out
208
297
  if out="$(sync_submodules "$1")"; then
209
- report_updated "$2" "$3" "$4" "$5" "$6"
298
+ report_updated "$2" "$3" "$4" "$5" "$6" "${7:-}"
210
299
  else
211
300
  report_failed "$2" "$3" "updated, but updating submodules failed:"$'\n'"$out"
212
301
  fi
@@ -230,13 +319,17 @@ dryrun_repo() {
230
319
  if [[ "$behind" == 0 ]]; then
231
320
  report_current "$rel" "$branch"
232
321
  elif [[ "$ahead" == 0 ]]; then
233
- 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
234
325
  report_dirty "$rel" "$branch" "$behind"
235
326
  else
236
327
  report_updated "$rel" "$branch" "$behind" 0 ""
237
328
  fi
238
329
  elif [[ "$rebase" == 0 ]]; then
239
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"
240
333
  elif has_tracked_changes "$repo"; then
241
334
  report_dirty "$rel" "$branch" "$behind"
242
335
  else
@@ -316,8 +409,8 @@ if ((jobs > 1 && count > 1)); then
316
409
  scratch="$(mktemp -d)"
317
410
  trap 'rm -rf "$scratch"' EXIT
318
411
  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
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
321
414
  # shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
322
415
  for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
323
416
  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.8.0",
4
4
  "description": "Safe, fast-forward pull every git repository under a directory",
5
5
  "keywords": [
6
6
  "git",