@kova1/pullr 0.3.1 → 0.5.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 +39 -30
  2. package/bin/pullr +122 -78
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,13 +12,15 @@ everything current means `cd`-ing into each one.
12
12
 
13
13
  ```console
14
14
  $ pullr ~/work
15
- + api (main, 3 new commit(s))
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
 
@@ -32,6 +34,12 @@ brew install kova1max/tap/pullr
32
34
  npm install -g @kova1/pullr
33
35
  ```
34
36
 
37
+ To try it without installing anything:
38
+
39
+ ```sh
40
+ npx @kova1/pullr --dry-run ~/work
41
+ ```
42
+
35
43
  Or copy [`bin/pullr`](bin/pullr) anywhere on your `PATH` - it is a single bash
36
44
  script with no dependencies beyond `git`.
37
45
 
@@ -40,6 +48,26 @@ Every release is also mirrored to
40
48
  `@kova1max/pullr` (GitHub requires the repo owner as scope). Installing from
41
49
  there needs a GitHub token, so the npm registry above is the easier choice.
42
50
 
51
+ ## What it will never do
52
+
53
+ pullr is built to be safe to run across a whole workspace without looking
54
+ first. It will never:
55
+
56
+ - **Create a merge commit.** Pulls are fast-forward only.
57
+ - **Touch a branch that has diverged** from its upstream. It is reported as
58
+ failed and left exactly as it was. Rebasing it is opt-in with `--rebase`,
59
+ and a rebase that hits a conflict is aborted, leaving the repository as it
60
+ was.
61
+ - **Stash, overwrite or discard your changes.** There is no auto-stash. If
62
+ local edits or untracked files are in the way of incoming changes, the
63
+ repository is reported as dirty and left as it was; edits that don't touch
64
+ incoming files don't stop the update.
65
+ - **Touch a repository on a detached `HEAD`**, or a branch with no upstream.
66
+ Both are skipped.
67
+ - **Enter a nested repository.** Repositories inside another repository,
68
+ including submodules, are left to their parent.
69
+ - **Follow symlinked directories.**
70
+
43
71
  ## Usage
44
72
 
45
73
  ```
@@ -53,25 +81,17 @@ pullr [options] [DIR]
53
81
  | `--max-depth N` | `2` | How many directory levels below `DIR` to search. `0` = only `DIR` itself, `1` = `DIR` and its direct children, ... |
54
82
  | `-n`, `--dry-run` | | Show what a run would do without pulling, see below |
55
83
  | `-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. |
84
+ | `--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. |
56
85
  | `-V`, `--version` | | Print the version |
57
86
  | `-h`, `--help` | | Show help |
58
87
 
59
- Behaviour:
88
+ ### Output
60
89
 
61
- - Runs `git pull --ff-only`, so it never creates merge commits. A branch that
62
- has diverged from its upstream is reported as failed and left untouched.
63
- With `--rebase`, that branch's local commits are rebased onto the upstream
64
- instead, and the line reads `+ api (main, 3 new commits, 2 rebased)`. There
65
- is no auto-stash: a dirty tree makes git refuse the rebase, which is
66
- reported as failed.
67
- - Does not descend into a repository once found - nested repositories and
68
- submodules are left to their parent.
69
- - Skips repositories on a detached `HEAD` and branches with no upstream.
70
- - Does not follow symlinked directories.
71
- - Exits `1` if any pull failed, `2` on invalid arguments, `0` otherwise.
72
-
73
- Each repository gets one line: `+` updated, `=` already up to date, `-`
74
- skipped, `x` failed (with git's output indented below it).
90
+ Each repository gets one line: `+` updated, `=` already up to date, `~`
91
+ dirty (local changes are in the way of the update, which is held back), `-`
92
+ skipped, `x` failed (with the reason indented below it). Exits `1` if any
93
+ pull failed, `2` on invalid arguments, `0` otherwise; a dirty repository is
94
+ not a failure.
75
95
 
76
96
  ### Dry run
77
97
 
@@ -86,7 +106,7 @@ x infra (main)
86
106
  diverged from upstream (behind 2, ahead 1): cannot fast-forward
87
107
  - scratch (main has no upstream)
88
108
 
89
- Repos: 4 would update: 1 up to date: 1 skipped: 1 would fail: 1
109
+ Repos: 4 would update: 1 up to date: 1 dirty: 0 skipped: 1 would fail: 1
90
110
  Failed: infra
91
111
  ```
92
112
 
@@ -97,17 +117,6 @@ remote as it is now, then prints the same lines a real run would (with
97
117
  only updates remote-tracking refs: the working tree and current branch are
98
118
  never modified. Exit codes match a real run.
99
119
 
100
- ## Development
101
-
102
- ```sh
103
- npm install # installs bats
104
- npm test # end-to-end tests against real temporary git repos
105
- npm run lint # shellcheck
106
- PULLR_BASH=/bin/bash npm test # run the suite under macOS's bash 3.2
107
- ```
108
-
109
- Releases are cut by GitHub Actions - see [RELEASE.md](RELEASE.md).
110
-
111
120
  ## License
112
121
 
113
122
  [MIT](LICENSE)
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] [DIR]
3
+ # Usage: pullr [--jobs N] [--max-depth N] [--dry-run] [--rebase] [--no-color] [DIR]
4
4
  set -euo pipefail
5
5
 
6
- VERSION="0.3.1" # set by the release workflow
6
+ VERSION="0.5.0" # set by the release workflow
7
7
 
8
8
  usage() {
9
9
  cat <<'EOF'
@@ -24,6 +24,8 @@ 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-color Disable colored output. Also disabled when NO_COLOR is
28
+ set (https://no-color.org) or output is not a terminal.
27
29
  -V, --version Print the version.
28
30
  -h, --help Show this help.
29
31
  EOF
@@ -32,6 +34,7 @@ EOF
32
34
  mode=pull
33
35
  jobs=4
34
36
  rebase=0
37
+ color=1
35
38
  max_depth=2
36
39
  root=""
37
40
 
@@ -43,6 +46,7 @@ while (($#)); do
43
46
  --max-depth=*) max_depth="${1#*=}"; shift ;;
44
47
  -n|--dry-run) mode=dryrun; shift ;;
45
48
  -r|--rebase) rebase=1; shift ;;
49
+ --no-color) color=0; shift ;;
46
50
  -h|--help) usage; exit 0 ;;
47
51
  -V|--version) echo "pullr $VERSION"; exit 0 ;;
48
52
  -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
@@ -58,7 +62,8 @@ root="${root:-.}"
58
62
  [[ -d "$root" ]] || { echo "Not a directory: $root" >&2; exit 2; }
59
63
  root="$(cd "$root" && pwd)"
60
64
 
61
- if [[ -t 1 ]]; then
65
+ # https://no-color.org: any non-empty NO_COLOR disables color
66
+ if [[ "$color" == 1 && -z "${NO_COLOR:-}" && -t 1 ]]; then
62
67
  green=$'\e[32m' yellow=$'\e[33m' red=$'\e[31m' dim=$'\e[2m' reset=$'\e[0m'
63
68
  else
64
69
  green="" yellow="" red="" dim="" reset=""
@@ -79,96 +84,133 @@ indented() {
79
84
  printf ' %s\n' "${1//$'\n'/$'\n' }"
80
85
  }
81
86
 
82
- pull_repo() {
83
- local repo="$1" rel branch
84
- rel="$(repo_label "$repo")"
85
-
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
86
92
  if ! branch="$(git -C "$repo" symbolic-ref --quiet --short HEAD)"; then
87
93
  echo skipped
88
94
  printf '%s-%s %s %s(detached HEAD)%s\n' "$yellow" "$reset" "$rel" "$dim" "$reset"
89
- return
95
+ return 1
90
96
  fi
91
97
  if ! git -C "$repo" rev-parse --quiet --verify '@{upstream}' >/dev/null 2>&1; then
92
98
  echo skipped
93
99
  printf '%s-%s %s %s(%s has no upstream)%s\n' "$yellow" "$reset" "$rel" "$dim" "$branch" "$reset"
94
- return
100
+ return 1
95
101
  fi
96
-
97
- local before after out how=--ff-only
98
- [[ "$rebase" == 0 ]] || how=--rebase
99
- before="$(git -C "$repo" rev-parse HEAD)"
100
- if ! out="$(git -C "$repo" pull "$how" 2>&1)"; then
101
- # a conflicting rebase must not leave the repository mid-rebase
102
- [[ "$rebase" == 0 ]] || git -C "$repo" rebase --abort >/dev/null 2>&1 || true
103
- printf 'failed\t%s\n' "$rel"
104
- printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$rel" "$dim" "$branch" "$reset"
105
- indented "$out"
106
- return
102
+ if ! out="$(git -C "$repo" fetch --quiet 2>&1)"; then
103
+ report_failed "$rel" "$branch" "$out"
104
+ return 1
107
105
  fi
108
- 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
+ }
109
110
 
110
- if [[ "$before" == "$after" ]]; then
111
- echo current
112
- printf '%s=%s %s %s(%s)%s\n' "$dim" "$reset" "$rel" "$dim" "$branch" "$reset"
113
- return
114
- fi
115
- # New upstream commits, and (after a rebase) local commits now on top of them.
116
- local n local_n=0
117
- n="$(git -C "$repo" rev-list --count "$before..@{upstream}")"
118
- [[ "$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"
119
136
  echo updated
120
- printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$rel" "$dim" "$branch" \
121
- "$(update_note "$n" "$local_n" rebased)" "$reset"
137
+ printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$1" "$dim" "$2" "$note" "$reset"
122
138
  }
123
139
 
124
- # "N new commit(s)[, M rebased|to rebase]"
125
- update_note() {
126
- local n="$1" local_n="$2" verb="$3" note
127
- note="$n new commit$([[ "$n" == 1 ]] || printf s)"
128
- [[ "$local_n" == 0 ]] || note+=", $local_n $verb"
129
- printf '%s' "$note"
140
+ # "1 commit", "3 commits"
141
+ commits() {
142
+ printf '%s commit%s' "$1" "$([[ "$1" == 1 ]] || printf s)"
130
143
  }
131
144
 
132
- dryrun_repo() {
133
- 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
134
152
  rel="$(repo_label "$repo")"
153
+ prepare "$repo" "$rel" || return 0
135
154
 
136
- if ! branch="$(git -C "$repo" symbolic-ref --quiet --short HEAD)"; then
137
- echo skipped
138
- printf '%s-%s %s %s(detached HEAD)%s\n' "$yellow" "$reset" "$rel" "$dim" "$reset"
139
- return
140
- fi
141
- if ! git -C "$repo" rev-parse --quiet --verify '@{upstream}' >/dev/null 2>&1; then
142
- echo skipped
143
- printf '%s-%s %s %s(%s has no upstream)%s\n' "$yellow" "$reset" "$rel" "$dim" "$branch" "$reset"
144
- 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
145
182
  fi
183
+ }
146
184
 
147
- local out
148
- if ! out="$(git -C "$repo" fetch --quiet 2>&1)"; then
149
- printf 'failed\t%s\n' "$rel"
150
- printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$rel" "$dim" "$branch" "$reset"
151
- indented "$out"
152
- return
153
- 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
+ }
154
194
 
155
- local counts behind ahead
156
- counts="$(git -C "$repo" rev-list --left-right --count '@{upstream}...HEAD')"
157
- behind="${counts%%[[:space:]]*}"
158
- 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
159
199
 
160
- if [[ "$behind" != 0 && "$ahead" != 0 && "$rebase" == 0 ]]; then
161
- printf 'failed\t%s\n' "$rel"
162
- printf '%sx%s %s %s(%s)%s\n' "$red" "$reset" "$rel" "$dim" "$branch" "$reset"
163
- indented "diverged from upstream (behind $behind, ahead $ahead): cannot fast-forward"
164
- elif [[ "$behind" != 0 ]]; then
165
- # without --rebase this branch is only reached when ahead is 0
166
- echo updated
167
- printf '%s+%s %s %s(%s, %s)%s\n' "$green" "$reset" "$rel" "$dim" "$branch" \
168
- "$(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"
169
212
  else
170
- echo current
171
- printf '%s=%s %s %s(%s)%s\n' "$dim" "$reset" "$rel" "$dim" "$branch" "$reset"
213
+ report_updated "$rel" "$branch" "$behind" "$ahead" "to rebase"
172
214
  fi
173
215
  }
174
216
 
@@ -209,8 +251,8 @@ fi
209
251
 
210
252
  [[ "$mode" != dryrun ]] || printf '%sDry run: nothing will be pulled.%s\n' "$dim" "$reset"
211
253
 
212
- updated=0 current=0 skipped=0 failed=0
213
- failed_repos=()
254
+ updated=0 current=0 skipped=0 failed=0 dirty=0
255
+ failed_repos=() dirty_repos=()
214
256
 
215
257
  consume() {
216
258
  local text="$1" head="${1%%$'\n'*}" body="${1#*$'\n'}"
@@ -219,6 +261,7 @@ consume() {
219
261
  updated) updated=$((updated + 1)) ;;
220
262
  current) current=$((current + 1)) ;;
221
263
  skipped) skipped=$((skipped + 1)) ;;
264
+ dirty) dirty=$((dirty + 1)); dirty_repos+=("$label") ;;
222
265
  failed) failed=$((failed + 1)); failed_repos+=("$label") ;;
223
266
  *) echo "pullr: unexpected worker result: $text" >&2; exit 1 ;;
224
267
  esac
@@ -230,7 +273,7 @@ if ((jobs > 1 && count > 1)); then
230
273
  trap 'rm -rf "$scratch"' EXIT
231
274
  export GIT_TERMINAL_PROMPT=0
232
275
  export mode rebase root green yellow red dim reset
233
- 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
234
277
  # shellcheck disable=SC2016 # the single-quoted body runs inside the child shell
235
278
  for ((i = 0; i < count; i++)); do printf '%s\0%s\0' "$i" "${repos[i]}"; done |
236
279
  xargs -0 -n 2 -P "$jobs" "$BASH" -c 'worker "$@"' _ "$scratch" &
@@ -255,12 +298,13 @@ fi
255
298
 
256
299
  echo
257
300
  if [[ "$mode" == dryrun ]]; then
258
- printf 'Repos: %d %swould update: %d%s up to date: %d %sskipped: %d%s %swould fail: %d%s\n' \
259
- "$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"
260
303
  else
261
- printf 'Repos: %d %supdated: %d%s up to date: %d %sskipped: %d%s %sfailed: %d%s\n' \
262
- "$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"
263
306
  fi
307
+ ((dirty == 0)) || printf 'Dirty: %s\n' "${dirty_repos[*]}"
264
308
  if ((failed)); then
265
309
  printf 'Failed: %s\n' "${failed_repos[*]}"
266
310
  exit 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kova1/pullr",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Fast-forward pull every git repository under a directory",
5
5
  "keywords": [
6
6
  "git",