@kova1/pullr 0.6.0 → 0.7.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 +27 -2
  2. package/bin/pullr +60 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -61,8 +61,12 @@ first. It will never:
61
61
  incoming files don't stop the update.
62
62
  - **Touch a repository on a detached `HEAD`**, or a branch with no upstream.
63
63
  Both are skipped.
64
- - **Enter a nested repository.** Repositories inside another repository,
65
- including submodules, are left to their parent.
64
+ - **Enter a nested repository.** Repositories inside another repository are
65
+ left to their parent.
66
+ - **Move a submodule off its branch.** With `--submodules`, a submodule is
67
+ only ever fast-forwarded on the branch it is on. (If you set git's
68
+ `submodule.recurse`, pullr does what `git pull` does with it: checks
69
+ submodules out at the recorded commit.)
66
70
  - **Follow symlinked directories.**
67
71
 
68
72
  ## Usage
@@ -78,6 +82,7 @@ pullr [options] [DIR]
78
82
  | `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
79
83
  | `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
80
84
  | `-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 |
81
86
  | `--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
87
  | `-V`, `--version` | | Print the version |
83
88
  | `-h`, `--help` | | Show help |
@@ -114,6 +119,26 @@ remote as it is now, then prints the same lines a real run would (with
114
119
  only updates remote-tracking refs: the working tree and current branch are
115
120
  never modified. Exit codes match a real run.
116
121
 
122
+ ### Submodules
123
+
124
+ By default pullr updates a repository the way `git pull` does: submodules stay
125
+ where they are, unless you set git's `submodule.recurse`, in which case they
126
+ are checked out at the commits the parent now records, as `git pull` would.
127
+
128
+ `--submodules` is for working inside submodules. Each submodule that is
129
+ checked out on a branch is treated as a repository of its own - fetched,
130
+ fast-forwarded with the same rules, and listed under its parent:
131
+
132
+ ```console
133
+ $ pullr --submodules ~/work
134
+ + app (main, 2 new commits)
135
+ + app/lib/core (main, 5 new commits)
136
+ - app/vendor/sdk (detached HEAD)
137
+ ```
138
+
139
+ Submodules on a detached HEAD (git's default after `git submodule update`) are
140
+ skipped, and uninitialized ones are left out.
141
+
117
142
  ## How it compares
118
143
 
119
144
  | | 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) |
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] [--submodules] [--no-color] [DIR]
4
4
  set -euo pipefail
5
5
 
6
- VERSION="0.6.0" # set by the release workflow
6
+ VERSION="0.7.0" # set by the release workflow
7
7
 
8
8
  usage() {
9
9
  cat <<'EOF'
@@ -24,6 +24,9 @@ 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
28
+ branch, with the same rules as any other repository.
29
+ Submodules on a detached HEAD are skipped.
27
30
  --no-color Disable colored output. Also disabled when NO_COLOR is
28
31
  set (https://no-color.org) or output is not a terminal.
29
32
  -V, --version Print the version.
@@ -34,6 +37,7 @@ EOF
34
37
  mode=pull
35
38
  jobs=8
36
39
  rebase=0
40
+ submodules=0
37
41
  color=1
38
42
  max_depth=2
39
43
  root=""
@@ -46,6 +50,7 @@ while (($#)); do
46
50
  --max-depth=*) max_depth="${1#*=}"; shift ;;
47
51
  -n|--dry-run) mode=dryrun; shift ;;
48
52
  -r|--rebase) rebase=1; shift ;;
53
+ --submodules) submodules=1; shift ;;
49
54
  --no-color) color=0; shift ;;
50
55
  -h|--help) usage; exit 0 ;;
51
56
  -V|--version) echo "pullr $VERSION"; exit 0 ;;
@@ -142,9 +147,24 @@ commits() {
142
147
  printf '%s commit%s' "$1" "$([[ "$1" == 1 ]] || printf s)"
143
148
  }
144
149
 
145
- # Uncommitted changes to tracked files, staged or not.
150
+ # Uncommitted changes to tracked files, staged or not. A submodule checked out
151
+ # at a different commit than the one recorded is not one: git neither refuses
152
+ # to update over it nor touches it.
146
153
  has_tracked_changes() {
147
- ! git -C "$1" diff --quiet HEAD --
154
+ ! git -C "$1" diff --quiet --ignore-submodules HEAD --
155
+ }
156
+
157
+ has_local_changes() {
158
+ [[ -n "$(git -C "$1" status --porcelain --ignore-submodules=all)" ]]
159
+ }
160
+
161
+ # Mirror `git pull` for people who set submodule.recurse: after the update,
162
+ # check submodules out at the commits the parent now records. With
163
+ # --submodules each one is fast-forwarded on its own branch instead.
164
+ sync_submodules() {
165
+ [[ "$submodules" == 0 ]] || return 0
166
+ [[ "$(git -C "$1" config --type=bool --get submodule.recurse 2>/dev/null)" == true ]] || return 0
167
+ git -C "$1" submodule update --recursive 2>&1
148
168
  }
149
169
 
150
170
  pull_repo() {
@@ -157,8 +177,8 @@ pull_repo() {
157
177
  elif [[ "$ahead" == 0 ]]; then
158
178
  # git refuses, touching nothing, when local changes are in the way
159
179
  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
180
+ report_synced "$repo" "$rel" "$branch" "$behind" 0 ""
181
+ elif has_local_changes "$repo"; then
162
182
  report_dirty "$rel" "$branch" "$behind"
163
183
  else
164
184
  report_failed "$rel" "$branch" "$out"
@@ -168,13 +188,13 @@ pull_repo() {
168
188
  elif has_tracked_changes "$repo"; then
169
189
  report_dirty "$rel" "$branch" "$behind"
170
190
  elif out="$(git -C "$repo" rebase --quiet '@{upstream}' 2>&1)"; then
171
- report_updated "$rel" "$branch" "$behind" "$ahead" rebased
191
+ report_synced "$repo" "$rel" "$branch" "$behind" "$ahead" rebased
172
192
  else
173
193
  # A rebase that stopped on a conflict must not leave the repository
174
194
  # mid-rebase; --abort succeeds only when one is in progress.
175
195
  if git -C "$repo" rebase --abort >/dev/null 2>&1; then
176
196
  report_failed "$rel" "$branch" "$out"
177
- elif [[ -n "$(git -C "$repo" status --porcelain)" ]]; then
197
+ elif has_local_changes "$repo"; then
178
198
  report_dirty "$rel" "$branch" "$behind"
179
199
  else
180
200
  report_failed "$rel" "$branch" "$out"
@@ -182,12 +202,22 @@ pull_repo() {
182
202
  fi
183
203
  }
184
204
 
205
+ # The repository was updated; report it, unless syncing submodules failed.
206
+ report_synced() {
207
+ local out
208
+ if out="$(sync_submodules "$1")"; then
209
+ report_updated "$2" "$3" "$4" "$5" "$6"
210
+ else
211
+ report_failed "$2" "$3" "updated, but updating submodules failed:"$'\n'"$out"
212
+ fi
213
+ }
214
+
185
215
  # Would local changes stop a fast-forward? Git refuses when a file it has to
186
216
  # update is modified locally or exists untracked, so compare the two lists.
187
217
  changes_block_update() {
188
218
  local repo="$1" incoming local_paths
189
219
  incoming="$(git -C "$repo" diff --name-only HEAD '@{upstream}')"
190
- local_paths="$(git -C "$repo" status --porcelain --untracked-files=all | cut -c4-)"
220
+ local_paths="$(git -C "$repo" status --porcelain --untracked-files=all --ignore-submodules=all | cut -c4-)"
191
221
  [[ -n "$incoming" && -n "$local_paths" ]] || return 1
192
222
  printf '%s\n' "$local_paths" | grep -qFxf <(printf '%s\n' "$incoming")
193
223
  }
@@ -225,11 +255,12 @@ worker() {
225
255
 
226
256
  # --- discovery ---------------------------------------------------------------
227
257
 
228
- repos=()
258
+ repos=() nested=()
229
259
  walk() {
230
260
  local dir="$1" depth="$2"
231
261
  if [[ -e "$dir/.git" ]]; then
232
- repos+=("$dir")
262
+ repos+=("$dir") nested+=(0)
263
+ [[ "$submodules" == 0 ]] || add_submodules "$dir"
233
264
  return
234
265
  fi
235
266
  ((depth < max_depth)) || return 0
@@ -239,6 +270,17 @@ walk() {
239
270
  walk "${child%/}" $((depth + 1))
240
271
  done
241
272
  }
273
+ # Checked-out submodules, recursively, in git's order; uninitialized ones
274
+ # have no checkout to update and are left out.
275
+ add_submodules() {
276
+ local sub
277
+ # shellcheck disable=SC2016 # $displaypath is expanded by git submodule foreach
278
+ while IFS= read -r sub; do
279
+ [[ -n "$sub" ]] || continue
280
+ repos+=("$1/$sub") nested+=(1)
281
+ done < <(git -C "$1" submodule foreach --quiet --recursive 'printf "%s\n" "$displaypath"' 2>/dev/null)
282
+ }
283
+
242
284
  walk "$root" 0
243
285
 
244
286
  count=${#repos[@]}
@@ -256,6 +298,8 @@ failed_repos=() dirty_repos=()
256
298
 
257
299
  consume() {
258
300
  local text="$1" head="${1%%$'\n'*}" body="${1#*$'\n'}"
301
+ # a submodule's lines sit under its parent's
302
+ [[ "${2:-0}" == 0 ]] || body=" ${body//$'\n'/$'\n' }"
259
303
  local category="${head%%$'\t'*}" label="${head#*$'\t'}"
260
304
  case "$category" in
261
305
  updated) updated=$((updated + 1)) ;;
@@ -272,8 +316,8 @@ if ((jobs > 1 && count > 1)); then
272
316
  scratch="$(mktemp -d)"
273
317
  trap 'rm -rf "$scratch"' EXIT
274
318
  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
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
277
321
  # shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
278
322
  for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
279
323
  xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
@@ -287,12 +331,12 @@ if ((jobs > 1 && count > 1)); then
287
331
  fi
288
332
  sleep 0.1
289
333
  done
290
- consume "$(cat "$scratch/$i.done")"
334
+ consume "$(cat "$scratch/$i.done")" "${nested[i]}"
291
335
  done
292
336
  wait "$pool" || true
293
337
  else
294
- for repo in "${repos[@]}"; do
295
- consume "$("${mode}_repo" "$repo")"
338
+ for ((i = 0; i < count; i++)); do
339
+ consume "$("${mode}_repo" "${repos[i]}")" "${nested[i]}"
296
340
  done
297
341
  fi
298
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kova1/pullr",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Safe, fast-forward pull every git repository under a directory",
5
5
  "keywords": [
6
6
  "git",