gitbash 3.0.0 → 3.1.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.
package/README.md CHANGED
@@ -76,7 +76,7 @@ curl -fsSL https://raw.githubusercontent.com/jaggli/gitbash/main/install.sh | sh
76
76
  irm https://raw.githubusercontent.com/jaggli/gitbash/main/install.ps1 | iex
77
77
  ```
78
78
 
79
- It installs to `~/.local/share/gitbash` and links `~/.local/bin/gitbash` (add that folder to your PATH if the script says so); delete both to uninstall. Set `GITBASH_VERSION` for a specific version, and `GITBASH_INSTALL_DIR` / `GITBASH_BIN_DIR` for other locations.
79
+ It downloads the package published on npm, checks it against npm's sha512 checksum, and installs it to `~/.local/share/gitbash` and links `~/.local/bin/gitbash` (add that folder to your PATH if the script says so); delete both to uninstall. Set `GITBASH_VERSION` for a specific version, and `GITBASH_INSTALL_DIR` / `GITBASH_BIN_DIR` for other locations.
80
80
 
81
81
  ## Options
82
82
 
@@ -212,7 +212,7 @@ Open the repository's web page in the browser: via the GitHub CLI (`gh repo view
212
212
  reset-repo [-n|--dry-run] [-y|--yes]
213
213
  ```
214
214
 
215
- Reset the current branch like a fresh clone: fetch, `git reset --hard <remote>/<branch>` and delete all untracked and ignored files (`git clean -dx`). Lists the unpushed commits, local changes and files it removes and asks first. Aborts an unfinished merge or rebase. Files matching `GITBASH_RESET_KEEP` (e.g. `.env`), `.gitbashrc-user` and nested repositories are kept; stashes and other branches are not touched.
215
+ Reset the current branch like a fresh clone: fetch, `git reset --hard <remote>/<branch>` and delete all untracked and ignored files (`git clean -dx`). Lists the unpushed commits, local changes and files it removes and asks first. Aborts an unfinished merge or rebase. Files matching `GITBASH_RESET_KEEP` (e.g. `.env`), `.gitbashrc-user` and nested repositories are kept; stashes and other branches are not touched. Submodules you initialized are reset to the recorded commit; others are left alone (run `git submodule update --init` to clone them).
216
216
 
217
217
  ### update
218
218
 
@@ -348,11 +348,11 @@ Config files are **read, never executed**. Only `GITBASH_*="value"` lines are us
348
348
 
349
349
  #### Local Configuration
350
350
 
351
- Run `gitbash --config-local` inside a repository to create a committed `.gitbashrc` with overrides for everyone working on it (e.g. a team branch prefix or different thresholds). For security, a committed `.gitbashrc` cannot set `GITBASH_MERGE_COMMAND`.
351
+ Run `gitbash --config-local` inside a repository to create a committed `.gitbashrc` with overrides for everyone working on it (e.g. a team branch prefix or different thresholds). For security, a committed `.gitbashrc` cannot set `GITBASH_MERGE_COMMAND` or `GITBASH_NO_UPDATE_CHECKS`, and its `GITBASH_PROTECTED_BRANCHES` only adds to the branches your own settings protect.
352
352
 
353
353
  #### User Configuration
354
354
 
355
- Run `gitbash --config-user` to create `.gitbashrc-user` for personal overrides in one repository. It is excluded from git via `.git/info/exclude`.
355
+ Run `gitbash --config-user` to create `.gitbashrc-user` for personal overrides in one repository. It is excluded from git via `.git/info/exclude`. If a repository commits a `.gitbashrc-user` anyway, gitbash treats it like the committed `.gitbashrc` (it cannot set `GITBASH_MERGE_COMMAND`).
356
356
 
357
357
  **Configuration priority (highest to lowest):**
358
358
 
@@ -362,7 +362,7 @@ Run `gitbash --config-user` to create `.gitbashrc-user` for personal overrides i
362
362
 
363
363
  #### Updates
364
364
 
365
- `gitbash --update` installs the latest release the same way gitbash was installed: with `npm install -g` (or `pnpm add -g`) for a global package, or by running the release's install script again for the same locations.
365
+ `gitbash --update` installs the latest version on npm the same way gitbash was installed: with `npm install -g` (or `pnpm add -g`) for a global package, or with the install script kept in the installation, for the same locations.
366
366
 
367
367
  gitbash also checks for new releases on its own, at most once a day, in the background while a command runs, so commands never wait for the network. When a new version is known, the next command at a terminal asks (at most every other day) whether to update now, not now, or skip that version for good. It never asks in scripts or pipes.
368
368
 
package/bin/gitbash CHANGED
@@ -23,6 +23,9 @@ VERSION="$(grep -o '"version": *"[^"]*"' "$SCRIPT_DIR/package.json" | head -1 |
23
23
  # Not exported: they would leak into git hooks, editors and merge tools.
24
24
  # Commands are sourced into this process; nested runs of gitbash set them again.
25
25
  export -n SCRIPT_DIR GITBASH_BIN VERSION
26
+ # Read from the environment (a parent gitbash sets them), but not passed on to
27
+ # git hooks, editors and merge tools either
28
+ export -n GITBASH_ASSUME_YES GITBASH_NESTED GB_COLOR 2>/dev/null
26
29
 
27
30
  # shellcheck source=../commands/_utils.sh
28
31
  source "$COMMANDS_DIR/_utils.sh"
@@ -396,7 +399,8 @@ check_pwsh_integration() {
396
399
  # Restricted (the default of Windows PowerShell) and AllSigned block the profile
397
400
  case "$policy" in
398
401
  Restricted|AllSigned|Undefined)
399
- if gb_confirm "PowerShell blocks scripts, including your profile ($policy). Allow local scripts (execution policy RemoteSigned)?" y; then
402
+ # A security setting: never changed by pressing Enter or by GITBASH_ASSUME_YES
403
+ if gb_confirm --strict "PowerShell blocks scripts, including your profile ($policy). Allow local scripts (execution policy RemoteSigned)?" n; then
400
404
  if "$exe" -NoProfile -NonInteractive -Command 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned -Force'; then
401
405
  print_success "Local scripts allowed."
402
406
  else
@@ -655,7 +659,7 @@ main() {
655
659
  gb_load_config
656
660
  gb_update_check "$cmd" "$@"
657
661
  # Commands run gitbash again (e.g. pr runs commit): no update prompt in there
658
- export GITBASH_NESTED=1
662
+ GITBASH_NESTED=1
659
663
  # shellcheck source=/dev/null
660
664
  source "$COMMANDS_DIR/$cmd.sh"
661
665
  "$cmd" "$@"
@@ -128,14 +128,14 @@ _gb_fetch() {
128
128
  fi
129
129
  }
130
130
 
131
- # Print the version of the latest GitHub release, e.g. 2.1.0
131
+ # Print the latest version on npm, e.g. 2.1.0. npm's "latest" tag only moves
132
+ # when a maintainer approves a staged version (2FA); a GitHub release doesn't need that.
132
133
  gb_latest_version() {
133
- local json tag
134
- json=$(_gb_fetch "https://api.github.com/repos/$GB_REPO/releases/latest" 2>/dev/null) || return 1
135
- tag=$(printf '%s\n' "$json" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
136
- tag="${tag#v}"
137
- [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
138
- echo "$tag"
134
+ local json version
135
+ json=$(_gb_fetch "https://registry.npmjs.org/-/package/gitbash/dist-tags" 2>/dev/null) || return 1
136
+ version=$(printf '%s\n' "$json" | sed -n 's/.*"latest": *"\([^"]*\)".*/\1/p' | head -n 1)
137
+ [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
138
+ echo "$version"
139
139
  }
140
140
 
141
141
  # Returns 0 if version $1 is newer than the running one
@@ -193,9 +193,12 @@ gb_self_update_to() {
193
193
  print_success "Updated gitbash $VERSION → $latest"
194
194
  }
195
195
 
196
- # Re-run the install script of the new release for the same locations
196
+ # Install version $1 with the install script that came with this copy, for the
197
+ # same locations. Never one downloaded from a git tag: whoever can create a tag
198
+ # would decide what runs. The script downloads the version from npm and checks
199
+ # its sha512 before installing it.
197
200
  _gb_self_update_script() {
198
- local latest="$1" bin_dir="" key value tmp
201
+ local latest="$1" bin_dir="" key value
199
202
  if [[ -f "$SCRIPT_DIR/.gitbash-install" ]]; then
200
203
  while IFS='=' read -r key value; do
201
204
  [[ "$key" == "bin_dir" ]] && bin_dir="$value"
@@ -204,27 +207,24 @@ _gb_self_update_script() {
204
207
  bin_dir="${bin_dir:-$HOME/.local/bin}"
205
208
 
206
209
  local -a vars=(GITBASH_VERSION="$latest" GITBASH_INSTALL_DIR="$SCRIPT_DIR" GITBASH_BIN_DIR="$bin_dir")
207
- local hint="curl -fsSL https://raw.githubusercontent.com/$GB_REPO/main/install.sh | sudo ${vars[*]} sh"
208
- if [[ ! -w "$(dirname "$SCRIPT_DIR")" ]]; then
209
- print_error "No permission to write to $(dirname "$SCRIPT_DIR"). Update with:"
210
+ local hint="curl -fsSL https://raw.githubusercontent.com/$GB_REPO/main/install.sh | ${vars[*]} sh"
211
+ if [[ ! -f "$SCRIPT_DIR/install.sh" ]]; then
212
+ # Installed before gitbash 3.1.0, which started to keep the script
213
+ print_error "This installation has no install script yet. Update once with:"
210
214
  echo " $hint" >&2
211
215
  return 1
212
216
  fi
213
-
214
- tmp=$(mktemp 2>/dev/null || mktemp -t gitbash) || return 1
215
- if ! _gb_fetch "https://raw.githubusercontent.com/$GB_REPO/v$latest/install.sh" > "$tmp" 2>/dev/null ||
216
- [[ ! -s "$tmp" ]]; then
217
- rm -f "$tmp"
218
- print_error "Could not download the install script of gitbash $latest."
217
+ if [[ ! -w "$(dirname "$SCRIPT_DIR")" ]]; then
218
+ print_error "No permission to write to $(dirname "$SCRIPT_DIR"). Update with:"
219
+ echo " curl -fsSL https://raw.githubusercontent.com/$GB_REPO/main/install.sh | sudo ${vars[*]} sh" >&2
219
220
  return 1
220
221
  fi
221
- print_info "Running the install script of gitbash $latest"
222
- if ! env "${vars[@]}" sh "$tmp"; then
223
- rm -f "$tmp"
222
+
223
+ print_info "Installing gitbash $latest"
224
+ if ! env "${vars[@]}" sh "$SCRIPT_DIR/install.sh"; then
224
225
  print_error "Update failed."
225
226
  return 1
226
227
  fi
227
- rm -f "$tmp"
228
228
  }
229
229
 
230
230
  # gitbash --update
@@ -43,8 +43,8 @@ _gb_color_ok() {
43
43
  }
44
44
 
45
45
  # NO_COLOR (https://no-color.org): a non-empty value turns off all colors.
46
- # GB_COLOR is the --color value for git and bat in fzf previews; it is
47
- # exported because previews run in a separate bash started by fzf.
46
+ # GB_COLOR is the --color value for git and bat in fzf previews; run_fzf
47
+ # passes it to fzf, whose previews run in a separate bash.
48
48
  if [[ -n "${NO_COLOR:-}" ]]; then
49
49
  GB_COLOR="never"
50
50
  # Also for git's own output (added to any GIT_CONFIG_* entries already set)
@@ -55,15 +55,25 @@ if [[ -n "${NO_COLOR:-}" ]]; then
55
55
  else
56
56
  GB_COLOR="always"
57
57
  fi
58
- export GB_COLOR
58
+
59
+ # Remove terminal control characters from text read from stdin: C0 controls
60
+ # except tab and newline (ESC, BEL, CR, ...), DEL, and C1 controls in UTF-8.
61
+ # Commit messages, file names, branch names and config lines come from
62
+ # repositories and could otherwise change the clipboard or fake output.
63
+ gb_sanitize() {
64
+ local c1
65
+ c1=$(printf '\302[\200-\237]')
66
+ LC_ALL=C tr -d '\000-\010\013-\037\177' | LC_ALL=C sed "s/$c1//g"
67
+ }
59
68
 
60
69
  _gb_print() {
61
- local fd="$1" color="$2" symbol="$3"
70
+ local fd="$1" color="$2" symbol="$3" text
62
71
  shift 3
72
+ text=$(printf '%s' "$*" | gb_sanitize)
63
73
  if _gb_color_ok "$fd"; then
64
- printf '\033[%sm%s\033[0m %s\n' "$color" "$symbol" "$*" >&"$fd"
74
+ printf '\033[%sm%s\033[0m %s\n' "$color" "$symbol" "$text" >&"$fd"
65
75
  else
66
- printf '%s %s\n' "$symbol" "$*" >&"$fd"
76
+ printf '%s %s\n' "$symbol" "$text" >&"$fd"
67
77
  fi
68
78
  }
69
79
 
@@ -111,8 +121,8 @@ prompt_read() {
111
121
  }
112
122
 
113
123
  # Yes/no question. Returns 0 for yes, 1 for no. EOF or Enter picks the default.
114
- # GITBASH_ASSUME_YES=1 (exported by --yes flags, so commands run from a command
115
- # inherit it) answers yes, unless --strict is given.
124
+ # GITBASH_ASSUME_YES=1 (set by --yes flags and passed on to commands run from
125
+ # a command) answers yes, unless --strict is given.
116
126
  # Usage: gb_confirm [--strict] "Question?" y|n
117
127
  gb_confirm() {
118
128
  local strict=false
@@ -203,6 +213,7 @@ _gb_config_entries() {
203
213
  gb_global_config_file() { echo "$HOME/.gitbashrc"; }
204
214
 
205
215
  # Load ~/.gitbashrc, then <repo>/.gitbashrc, then <repo>/.gitbashrc-user.
216
+ # A .gitbashrc-user that is tracked by git is treated like .gitbashrc.
206
217
  gb_load_config() {
207
218
  local repo_root="" file scope key value
208
219
  repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || repo_root=""
@@ -210,14 +221,32 @@ gb_load_config() {
210
221
  case "$scope" in
211
222
  global) file=$(gb_global_config_file) ;;
212
223
  local) [[ -n "$repo_root" ]] || continue; file="$repo_root/.gitbashrc" ;;
213
- user) [[ -n "$repo_root" ]] || continue; file="$repo_root/.gitbashrc-user" ;;
224
+ user)
225
+ [[ -n "$repo_root" ]] || continue
226
+ file="$repo_root/.gitbashrc-user"
227
+ # A committed .gitbashrc-user came with the repository: as untrusted as .gitbashrc
228
+ if git -C "$repo_root" ls-files --error-unmatch -- .gitbashrc-user >/dev/null 2>&1; then
229
+ scope="local"
230
+ fi
231
+ ;;
214
232
  esac
233
+ # A repository's config file must be a file in the repository, not a link to
234
+ # something else on this machine (whose lines would be printed as warnings)
235
+ if [[ "$scope" == "local" && -L "$file" ]]; then
236
+ print_warning "$file: ignored (a symbolic link)"
237
+ continue
238
+ fi
215
239
  while IFS='=' read -r key value; do
216
240
  [[ -z "$key" ]] && continue
217
- if [[ "$key" == "GITBASH_MERGE_COMMAND" && "$scope" == "local" ]]; then
218
- print_warning "$file: ignored GITBASH_MERGE_COMMAND (a repository cannot choose which program runs; set it in ~/.gitbashrc or .gitbashrc-user)"
241
+ # A repository cannot choose which program runs, or turn off update checks
242
+ if [[ "$scope" == "local" && ( "$key" == "GITBASH_MERGE_COMMAND" || "$key" == "GITBASH_NO_UPDATE_CHECKS" ) ]]; then
243
+ print_warning "$file: ignored $key (a repository cannot set it; set it in ~/.gitbashrc or an uncommitted .gitbashrc-user)"
219
244
  continue
220
245
  fi
246
+ # A repository can protect more branches, but not unprotect yours
247
+ if [[ "$scope" == "local" && "$key" == "GITBASH_PROTECTED_BRANCHES" ]]; then
248
+ value="${GITBASH_PROTECTED_BRANCHES:-main master develop release/*} $value"
249
+ fi
221
250
  printf -v "$key" '%s' "$value"
222
251
  done < <(_gb_config_entries "$file" warn)
223
252
  done
@@ -335,7 +364,9 @@ require_fzf() {
335
364
  run_fzf() {
336
365
  local bash_path
337
366
  bash_path=$(command -v bash)
338
- FZF_DEFAULT_OPTS="" FZF_DEFAULT_OPTS_FILE="" SHELL="$bash_path" fzf "$@"
367
+ # GITBASH_NESTED: bindings that run gitbash (e.g. switch's Del) are nested runs
368
+ FZF_DEFAULT_OPTS="" FZF_DEFAULT_OPTS_FILE="" SHELL="$bash_path" GB_COLOR="$GB_COLOR" \
369
+ GITBASH_NESTED=1 GITBASH_ASSUME_YES="${GITBASH_ASSUME_YES:-}" fzf "$@"
339
370
  }
340
371
 
341
372
  # =============================================================================
@@ -346,9 +377,12 @@ gb_remote() {
346
377
  echo "${GITBASH_REMOTE:-origin}"
347
378
  }
348
379
 
349
- # Current branch name; fails on detached HEAD
380
+ # Current branch name; fails on detached HEAD. Not --short: with a tag of the
381
+ # same name, that prints "heads/<branch>".
350
382
  gb_current_branch() {
351
- git symbolic-ref --quiet --short HEAD 2>/dev/null
383
+ local ref
384
+ ref=$(git symbolic-ref --quiet HEAD 2>/dev/null) || return 1
385
+ echo "${ref#refs/heads/}"
352
386
  }
353
387
 
354
388
  # Returns 0 if $1 is a valid branch name that can't be mistaken for an option.
@@ -367,9 +401,11 @@ gb_base_branch() {
367
401
  echo "$GITBASH_BASE_BRANCH"
368
402
  return 0
369
403
  fi
370
- if branch=$(git symbolic-ref --quiet --short "refs/remotes/$remote/HEAD" 2>/dev/null) &&
371
- _gb_valid_branch_name "${branch#"$remote"/}"; then
372
- echo "${branch#"$remote"/}"
404
+ # Full ref name: --short would give "remotes/origin/main" when a tag "origin/main" exists
405
+ if branch=$(git symbolic-ref --quiet "refs/remotes/$remote/HEAD" 2>/dev/null) &&
406
+ [[ "$branch" == "refs/remotes/$remote/"* ]] &&
407
+ _gb_valid_branch_name "${branch#"refs/remotes/$remote/"}"; then
408
+ echo "${branch#"refs/remotes/$remote/"}"
373
409
  return 0
374
410
  fi
375
411
  for branch in main master; do
@@ -382,17 +418,25 @@ gb_base_branch() {
382
418
  return 1
383
419
  }
384
420
 
385
- # The ref to compare against: <remote>/<base> if it exists, else the local base
421
+ # The ref to compare against, as a full ref name: refs/remotes/<remote>/<base>
422
+ # if it exists, else refs/heads/<base>. Never a short name like origin/main:
423
+ # git resolves those to a tag of the same name first, and tags come from the remote.
386
424
  gb_base_ref() {
387
425
  local base="$1" remote
388
426
  remote=$(gb_remote)
389
427
  if git show-ref --verify --quiet "refs/remotes/$remote/$base"; then
390
- echo "$remote/$base"
428
+ echo "refs/remotes/$remote/$base"
391
429
  else
392
- echo "$base"
430
+ echo "refs/heads/$base"
393
431
  fi
394
432
  }
395
433
 
434
+ # Short name of a full ref, for messages: refs/remotes/origin/main -> origin/main
435
+ gb_ref_short() {
436
+ local ref="${1#refs/remotes/}"
437
+ echo "${ref#refs/heads/}"
438
+ }
439
+
396
440
  # Fetch the current branch's upstream and, if it has new commits, offer to
397
441
  # fast-forward. Returns 1 only when the fast-forward fails.
398
442
  gb_offer_fast_forward() {
@@ -431,9 +475,11 @@ gb_is_protected() {
431
475
  return 1
432
476
  }
433
477
 
434
- # Run another gitbash command (same installation)
478
+ # Run another gitbash command (same installation). GITBASH_NESTED and
479
+ # GITBASH_ASSUME_YES (set by -y) are passed only to it, not exported to git
480
+ # hooks, editors or merge tools.
435
481
  gb_run() {
436
- "${GITBASH_BIN:-gitbash}" "$@"
482
+ GITBASH_NESTED=1 GITBASH_ASSUME_YES="${GITBASH_ASSUME_YES:-}" "${GITBASH_BIN:-gitbash}" "$@"
437
483
  }
438
484
 
439
485
  # Fetch the current branch, sync with its remote counterpart and push.
@@ -691,6 +737,12 @@ gb_web_url() {
691
737
  echo "$scheme://$host/$path"
692
738
  }
693
739
 
740
+ # A remote URL for messages, without user name and password or token
741
+ # (https://user:token@host/path -> https://host/path)
742
+ gb_redact_url() {
743
+ printf '%s' "$1" | sed -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)[^/@]*@#\1#'
744
+ }
745
+
694
746
  # Open a URL in the browser, or print it if no opener is available
695
747
  gb_open_url() {
696
748
  local url="$1"
@@ -44,7 +44,7 @@ EOF
44
44
  while [[ $# -gt 0 ]]; do
45
45
  case "$1" in
46
46
  -h|--help) cleanstash --help; return 0 ;;
47
- -y|--yes) export GITBASH_ASSUME_YES=1 ;;
47
+ -y|--yes) GITBASH_ASSUME_YES=1 ;;
48
48
  *)
49
49
  print_error "Unknown argument: $1"
50
50
  echo "Usage: cleanstash [-y|--yes]" >&2
@@ -106,7 +106,7 @@ EOF
106
106
  shift
107
107
  ;;
108
108
  -y|--yes)
109
- export GITBASH_ASSUME_YES=1
109
+ GITBASH_ASSUME_YES=1
110
110
  shift
111
111
  ;;
112
112
  *)
@@ -183,7 +183,7 @@ EOF
183
183
  fi
184
184
  entries+=("$preselect|$label|$ts|$branch|$rel|$email|$unpushed|$name")
185
185
  done < <(git for-each-ref \
186
- --format="%(refname:short)%1f%(committerdate:unix)%1f%(committerdate:relative)%1f%(authoremail)%1f%(upstream:short)%1f%(upstream:track)%1f%(authorname)" \
186
+ --format="%(refname:lstrip=2)%1f%(committerdate:unix)%1f%(committerdate:relative)%1f%(authoremail)%1f%(upstream:short)%1f%(upstream:track)%1f%(authorname)" \
187
187
  refs/heads 2>/dev/null)
188
188
 
189
189
  # Pre-selected first, then newest first
@@ -235,7 +235,7 @@ EOF
235
235
  fi
236
236
  if [[ "$dry_run" == true ]]; then
237
237
  echo "Would delete ${#selected[@]} branch(es):"
238
- printf ' - %s\n' "${selected[@]}"
238
+ printf ' - %s\n' "${selected[@]}" | gb_sanitize
239
239
  return 0
240
240
  fi
241
241
  else
@@ -322,7 +322,7 @@ ${#sorted[@]} branches, $preselect_count pre-selected" \
322
322
  label_of=""
323
323
  [[ "$branch" == "$current_branch" ]] && { need_switch=true; label_of=" (current branch)"; }
324
324
  [[ "$merged_list" == *" $branch "* ]] || only_merged=false
325
- echo " - $branch$label_of"
325
+ echo " - $branch$label_of" | gb_sanitize
326
326
  done
327
327
  if [[ "$need_switch" == true ]]; then
328
328
  echo "Will switch to '$base_branch' first."
@@ -363,8 +363,8 @@ ${#sorted[@]} branches, $preselect_count pre-selected" \
363
363
  echo
364
364
  print_warning "These branches have commits that are not merged. Deleting them loses those commits:"
365
365
  for branch in "${refused[@]}"; do
366
- echo " $branch:"
367
- git log --oneline -n 5 "refs/heads/$branch" --not "$base_ref" --remotes 2>/dev/null | sed 's/^/ /'
366
+ echo " $branch:" | gb_sanitize
367
+ git log --oneline -n 5 "refs/heads/$branch" --not "$base_ref" --remotes 2>/dev/null | gb_sanitize | sed 's/^/ /'
368
368
  done
369
369
  if gb_confirm --strict "Force-delete these ${#refused[@]} branch(es)?" n; then
370
370
  for branch in "${refused[@]}"; do
@@ -83,7 +83,7 @@ EOF
83
83
  shift
84
84
  ;;
85
85
  -y|--yes)
86
- export GITBASH_ASSUME_YES=1
86
+ GITBASH_ASSUME_YES=1
87
87
  shift
88
88
  ;;
89
89
  --force-with-lease)
@@ -60,7 +60,7 @@ EOF
60
60
  while [[ $# -gt 0 ]]; do
61
61
  case "$1" in
62
62
  -a|--all) all=true ;;
63
- -y|--yes) export GITBASH_ASSUME_YES=1 ;;
63
+ -y|--yes) GITBASH_ASSUME_YES=1 ;;
64
64
  -*) print_error "Unknown option '$1'. See 'commits --help'."; return 1 ;;
65
65
  *)
66
66
  if [[ -n "$count" ]]; then
@@ -98,12 +98,14 @@ EOF
98
98
  elif git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null; then
99
99
  log_args=(--not "$base_ref")
100
100
  # Also exclude the local base branch when it is ahead of the remote one
101
- if [[ "$base_ref" != "$base_branch" ]] &&
101
+ if [[ "$base_ref" != "refs/heads/$base_branch" ]] &&
102
102
  git show-ref --verify --quiet "refs/heads/$base_branch"; then
103
- log_args+=("$base_branch")
103
+ log_args+=("refs/heads/$base_branch")
104
104
  fi
105
- scope="commits not in '$base_ref' (up to $count)"
106
- empty_hint="No commits on '$current_branch' that are not in '$base_ref'. Use 'commits --all' to show all recent commits."
105
+ local base_name
106
+ base_name=$(gb_ref_short "$base_ref")
107
+ scope="commits not in '$base_name' (up to $count)"
108
+ empty_hint="No commits on '$current_branch' that are not in '$base_name'. Use 'commits --all' to show all recent commits."
107
109
  fi
108
110
  fi
109
111
 
@@ -184,7 +186,7 @@ Showing $scope" \
184
186
  echo
185
187
  echo "Commits to revert (newest first):"
186
188
  for hash in "${to_revert[@]}"; do
187
- echo " - $(git log -1 --format='%h %s' "$hash")"
189
+ echo " - $(git log -1 --format='%h %s' "$hash" | gb_sanitize)"
188
190
  done
189
191
  echo
190
192
 
@@ -103,7 +103,7 @@ EOF
103
103
  --hotfix) branch_type="hotfix/"; shift ;;
104
104
  --release) branch_type="release/"; shift ;;
105
105
  -p|--push) auto_push="yes"; shift ;;
106
- -y|--yes) export GITBASH_ASSUME_YES=1; shift ;;
106
+ -y|--yes) GITBASH_ASSUME_YES=1; shift ;;
107
107
  --no-push) auto_push="no"; shift ;;
108
108
  --)
109
109
  shift
@@ -244,10 +244,10 @@ release/ - Release preparation branches"
244
244
  if base_branch=$(gb_base_branch); then
245
245
  print_info "Fetching latest '$base_branch' from '$remote'..."
246
246
  if git fetch --quiet "$remote" "+refs/heads/$base_branch:refs/remotes/$remote/$base_branch" 2>/dev/null; then
247
- start_point="$remote/$base_branch"
247
+ start_point="refs/remotes/$remote/$base_branch"
248
248
  elif git show-ref --verify --quiet "refs/heads/$base_branch"; then
249
249
  print_warning "Could not fetch '$base_branch'. Creating the branch from local '$base_branch'."
250
- start_point="$base_branch"
250
+ start_point="refs/heads/$base_branch"
251
251
  fi
252
252
  fi
253
253
  if [[ -z "$start_point" ]]; then
@@ -257,7 +257,7 @@ release/ - Release preparation branches"
257
257
  print_info "Creating branch..."
258
258
  if [[ -n "$start_point" ]]; then
259
259
  git switch --quiet --no-track -c "$branch_name" "$start_point" || { print_error "Failed to create branch."; return 1; }
260
- print_success "Created and switched to '$branch_name' (from $start_point)"
260
+ print_success "Created and switched to '$branch_name' (from $(gb_ref_short "$start_point"))"
261
261
  else
262
262
  git switch --quiet -c "$branch_name" || { print_error "Failed to create branch."; return 1; }
263
263
  print_success "Created and switched to '$branch_name'"
package/commands/pr.sh CHANGED
@@ -78,7 +78,7 @@ EOF
78
78
  shift
79
79
  ;;
80
80
  -y|--yes)
81
- export GITBASH_ASSUME_YES=1
81
+ GITBASH_ASSUME_YES=1
82
82
  shift
83
83
  ;;
84
84
  *)
@@ -102,7 +102,7 @@ EOF
102
102
  return 1
103
103
  fi
104
104
  if ! base=$(gb_web_url "$remote_url"); then
105
- print_error "Don't know how to open '$remote_url' in a browser."
105
+ print_error "Don't know how to open '$(gb_redact_url "$remote_url")' in a browser."
106
106
  return 1
107
107
  fi
108
108
 
package/commands/repo.sh CHANGED
@@ -61,7 +61,7 @@ EOF_HELP
61
61
  return 1
62
62
  fi
63
63
  if ! url=$(gb_web_url "$remote_url"); then
64
- print_error "Don't know how to open '$remote_url' in a browser."
64
+ print_error "Don't know how to open '$(gb_redact_url "$remote_url")' in a browser."
65
65
  return 1
66
66
  fi
67
67
 
@@ -68,7 +68,7 @@ EOF_HELP
68
68
  shift
69
69
  ;;
70
70
  -y|--yes)
71
- export GITBASH_ASSUME_YES=1
71
+ GITBASH_ASSUME_YES=1
72
72
  shift
73
73
  ;;
74
74
  *)
@@ -100,7 +100,7 @@ EOF_HELP
100
100
  return 1
101
101
  fi
102
102
 
103
- local remote target
103
+ local remote target target_ref
104
104
  remote=$(gb_remote)
105
105
  if ! git remote get-url "$remote" >/dev/null 2>&1; then
106
106
  print_error "No remote '$remote' configured."
@@ -111,10 +111,13 @@ EOF_HELP
111
111
  print_error "Failed to fetch '$remote'."
112
112
  return 1
113
113
  fi
114
+ # Full ref names for git (a tag called <remote>/<branch> must not win), short ones for messages
114
115
  if git show-ref --verify --quiet "refs/remotes/$remote/$branch"; then
115
116
  target="$remote/$branch"
117
+ target_ref="refs/remotes/$remote/$branch"
116
118
  else
117
119
  target="$branch"
120
+ target_ref="refs/heads/$branch"
118
121
  print_warning "'$branch' is not on '$remote': keeping its commits, only local changes are discarded."
119
122
  fi
120
123
 
@@ -130,14 +133,14 @@ EOF_HELP
130
133
 
131
134
  # What would be lost
132
135
  local lost_commits changes untracked
133
- lost_commits=$(git log --oneline "$target..refs/heads/$branch")
136
+ lost_commits=$(git log --oneline "$target_ref..refs/heads/$branch")
134
137
  changes=$(git status --porcelain --untracked-files=no)
135
138
  untracked=$(git clean -n "${clean_args[@]}")
136
139
 
137
140
  local head_commit
138
141
  head_commit=$(git rev-parse "refs/heads/$branch")
139
142
  if [[ -z "$lost_commits" && -z "$changes" && -z "$untracked" &&
140
- "$(git rev-parse HEAD)" == "$(git rev-parse "$target")" ]]; then
143
+ "$(git rev-parse HEAD)" == "$(git rev-parse "$target_ref")" ]]; then
141
144
  _reset_repo_abort_operations
142
145
  print_success "'$branch' is already like a fresh clone of '$target'."
143
146
  return 0
@@ -147,17 +150,17 @@ EOF_HELP
147
150
  if [[ -n "$lost_commits" ]]; then
148
151
  echo
149
152
  print_warning "$(printf '%s\n' "$lost_commits" | wc -l | tr -d ' ') unpushed commit(s) will be removed from '$branch':"
150
- printf '%s\n' "$lost_commits" | sed 's/^/ /'
153
+ printf '%s\n' "$lost_commits" | gb_sanitize | sed 's/^/ /'
151
154
  fi
152
155
  if [[ -n "$changes" ]]; then
153
156
  echo
154
157
  echo "Local changes to discard:"
155
- printf '%s\n' "$changes" | sed 's/^/ /'
158
+ printf '%s\n' "$changes" | gb_sanitize | sed 's/^/ /'
156
159
  fi
157
160
  if [[ -n "$untracked" ]]; then
158
161
  echo
159
162
  echo "Untracked and ignored files to delete:"
160
- printf '%s\n' "$untracked" | sed 's/^Would remove / /'
163
+ printf '%s\n' "$untracked" | gb_sanitize | sed 's/^Would remove / /'
161
164
  fi
162
165
  if [[ ${#keep[@]} -gt 1 ]]; then
163
166
  echo
@@ -180,7 +183,7 @@ EOF_HELP
180
183
  print_error "Failed to check out '$branch'."
181
184
  return 1
182
185
  fi
183
- if ! git reset --quiet --hard "$target"; then
186
+ if ! git reset --quiet --hard "$target_ref"; then
184
187
  print_error "Failed to reset '$branch' to '$target'."
185
188
  return 1
186
189
  fi
@@ -189,7 +192,9 @@ EOF_HELP
189
192
  return 1
190
193
  fi
191
194
  if [[ -f .gitmodules ]]; then
192
- git submodule update --init --recursive --force --quiet ||
195
+ # Only submodules that were initialized: --init would clone every URL in
196
+ # the repository's .gitmodules, which a fresh clone doesn't do either
197
+ git submodule update --recursive --force --quiet ||
193
198
  print_warning "Failed to update submodules."
194
199
  fi
195
200
 
package/commands/stale.sh CHANGED
@@ -172,7 +172,7 @@ EOF
172
172
  stale_list+="$row"
173
173
  fi
174
174
  done < <(git for-each-ref --sort=committerdate \
175
- --format="%(refname:short)%1f%(committerdate:unix)%1f%(committerdate:relative)%1f%(authoremail)%1f%(authorname)" \
175
+ --format="%(refname:lstrip=2)%1f%(committerdate:unix)%1f%(committerdate:relative)%1f%(authoremail)%1f%(authorname)" \
176
176
  "refs/remotes/$remote" 2>/dev/null)
177
177
 
178
178
  if [[ "$json_mode" == true ]]; then
@@ -268,7 +268,7 @@ TOGGLE_EOF
268
268
 
269
269
  echo
270
270
  echo "Selected branches to delete from '$remote':"
271
- printf ' - %s\n' "${branches_to_delete[@]}"
271
+ printf ' - %s\n' "${branches_to_delete[@]}" | gb_sanitize
272
272
  echo
273
273
 
274
274
  if ! gb_confirm --strict "Delete these ${#branches_to_delete[@]} branch(es) from '$remote'? This cannot be undone from here." n; then
@@ -177,7 +177,7 @@ EOF
177
177
  print_warning "$path has a merge conflict - resolve it in your editor, then stage it."
178
178
  ;;
179
179
  ?" ")
180
- echo "Unstaging: $path"
180
+ echo "Unstaging: $path" | gb_sanitize
181
181
  if [[ -n "$orig" ]]; then
182
182
  _status_unstage "$path" "$orig" || print_error "Failed to unstage $path"
183
183
  else
@@ -185,7 +185,7 @@ EOF
185
185
  fi
186
186
  ;;
187
187
  *)
188
- echo "Staging: $path"
188
+ echo "Staging: $path" | gb_sanitize
189
189
  git add -A -- "$path" || print_error "Failed to stage $path"
190
190
  ;;
191
191
  esac
@@ -210,7 +210,7 @@ _status_discard() {
210
210
 
211
211
  if [[ ${#untracked[@]} -gt 0 ]]; then
212
212
  echo "Untracked files/directories to delete:"
213
- printf ' - %s\n' "${untracked[@]}"
213
+ printf ' - %s\n' "${untracked[@]}" | gb_sanitize
214
214
  if gb_confirm --strict "Delete these ${#untracked[@]} item(s)? This cannot be undone." n; then
215
215
  for path in "${untracked[@]}"; do
216
216
  if rm -rf -- "$path"; then
@@ -224,7 +224,7 @@ _status_discard() {
224
224
 
225
225
  if [[ ${#added[@]} -gt 0 ]]; then
226
226
  echo "Newly added files to unstage:"
227
- printf ' - %s\n' "${added[@]}"
227
+ printf ' - %s\n' "${added[@]}" | gb_sanitize
228
228
  if gb_confirm --strict "Unstage these ${#added[@]} file(s)?" n; then
229
229
  local delete_too=false
230
230
  gb_confirm --strict "Also delete them from disk?" n && delete_too=true
@@ -248,7 +248,7 @@ _status_discard() {
248
248
  return 0
249
249
  fi
250
250
  echo "Files to restore to the last commit:"
251
- printf ' - %s\n' "${tracked[@]}"
251
+ printf ' - %s\n' "${tracked[@]}" | gb_sanitize
252
252
  if gb_confirm --strict "Discard all changes in these ${#tracked[@]} file(s)? This cannot be undone." n; then
253
253
  for path in "${tracked[@]}"; do
254
254
  if git restore --source=HEAD --staged --worktree -- "$path" 2>/dev/null ||
@@ -11,10 +11,10 @@ _switch_list_branches() {
11
11
  current=$(gb_current_branch) || current=""
12
12
  if base=$(gb_base_branch 2>/dev/null); then
13
13
  base_ref=$(gb_base_ref "$base")
14
- merged=$(git for-each-ref --merged "$base_ref" --format='%(refname:short)' refs/heads 2>/dev/null)
14
+ merged=$(git for-each-ref --merged "$base_ref" --format='%(refname:lstrip=2)' refs/heads 2>/dev/null)
15
15
  fi
16
16
 
17
- local_names=$(git for-each-ref --format='%(refname:short)' refs/heads)
17
+ local_names=$(git for-each-ref --format='%(refname:lstrip=2)' refs/heads)
18
18
  while IFS= read -r name; do
19
19
  [[ -z "$name" ]] && continue
20
20
  if [[ "$name" != "$current" && "$name" != "$base" ]] && printf '%s\n' "$merged" | grep -qxF -- "$name"; then
@@ -36,7 +36,7 @@ _switch_list_branches() {
36
36
  printed_spacer=true
37
37
  fi
38
38
  printf 'remote: %s\tremote\t%s\n' "$name" "$name"
39
- done < <(git for-each-ref --format='%(refname:short)' refs/remotes)
39
+ done < <(git for-each-ref --format='%(refname:lstrip=2)' refs/remotes)
40
40
  }
41
41
 
42
42
  # Delete the local branch of a list line (called from fzf via 'switch --delete-branch')
@@ -70,9 +70,11 @@ _switch_delete_branch() {
70
70
  return 0
71
71
  fi
72
72
 
73
- count=$(git rev-list --count "refs/heads/$name" --not --remotes "$(gb_base_ref "$(gb_base_branch 2>/dev/null || echo HEAD)")" 2>/dev/null || echo "?")
73
+ local base_ref=HEAD base
74
+ base=$(gb_base_branch 2>/dev/null) && base_ref=$(gb_base_ref "$base")
75
+ count=$(git rev-list --count "refs/heads/$name" --not --remotes "$base_ref" 2>/dev/null || echo "?")
74
76
  print_warning "'$name' has $count commit(s) that are not merged or pushed:"
75
- git log --oneline -n 10 "refs/heads/$name" --not --remotes 2>/dev/null | sed 's/^/ /'
77
+ git log --oneline -n 10 "refs/heads/$name" --not --remotes 2>/dev/null | gb_sanitize | sed 's/^/ /'
76
78
  if gb_confirm --strict "Force-delete '$name' and lose these commits?" n; then
77
79
  if git branch -D "$name" >/dev/null 2>&1; then
78
80
  print_success "Force-deleted $name"
@@ -246,7 +248,7 @@ EOF
246
248
  git switch "$local_branch" || { print_error "Failed to switch branch."; return 1; }
247
249
  else
248
250
  echo "Creating local branch '$local_branch' tracking '$branch_name'"
249
- git switch -c "$local_branch" --track "$branch_name" || { print_error "Failed to switch branch."; return 1; }
251
+ git switch -c "$local_branch" --track "refs/remotes/$branch_name" || { print_error "Failed to switch branch."; return 1; }
250
252
  fi
251
253
  fi
252
254
  print_success "Switched to branch: $(gb_current_branch)"
@@ -53,7 +53,7 @@ EOF
53
53
  while [[ $# -gt 0 ]]; do
54
54
  case "$1" in
55
55
  -h|--help) unstash --help; return 0 ;;
56
- -y|--yes) export GITBASH_ASSUME_YES=1 ;;
56
+ -y|--yes) GITBASH_ASSUME_YES=1 ;;
57
57
  *)
58
58
  print_error "Unknown argument: $1"
59
59
  echo "Usage: unstash [-y|--yes]" >&2
@@ -57,7 +57,7 @@ EOF
57
57
  shift
58
58
  ;;
59
59
  -y|--yes)
60
- export GITBASH_ASSUME_YES=1
60
+ GITBASH_ASSUME_YES=1
61
61
  shift
62
62
  ;;
63
63
  *)
@@ -86,7 +86,7 @@ EOF
86
86
  # -----------------------------
87
87
  if [[ "$current_branch" == "$base_branch" ]]; then
88
88
  print_info "Pulling latest '$base_branch' from '$remote'..."
89
- if git pull --ff-only "$remote" "$base_branch"; then
89
+ if git pull --ff-only "$remote" "refs/heads/$base_branch"; then
90
90
  print_success "'$base_branch' is up to date."
91
91
  return 0
92
92
  fi
@@ -143,7 +143,7 @@ EOF
143
143
  fi
144
144
 
145
145
  print_info "Merging '$remote/$base_branch' into '$current_branch'..."
146
- if git merge --no-edit "$remote/$base_branch"; then
146
+ if git merge --no-edit "refs/remotes/$remote/$base_branch"; then
147
147
  print_success "'$current_branch' is up to date with '$base_branch'."
148
148
  else
149
149
  local conflicts
@@ -124,7 +124,7 @@ shellcheck -x bin/gitbash commands/*.sh
124
124
  ./bin/gitbash stale --all
125
125
  ```
126
126
 
127
- Tests live in `test/*.bats`. Each test gets its own temporary repository with a local bare remote (`test/helpers/setup.bash`). fzf is replaced by a stub that records its arguments and answers from a plan (`test/helpers/bin/fzf`); `test/fzf_real.bats` drives the real fzf in a pseudo-terminal. Add a test for every bug fix.
127
+ Tests live in `test/*.bats`. Each test gets its own temporary repository with a local bare remote (`test/helpers/setup.bash`). fzf is replaced by a stub that records its arguments and answers from a plan (`test/helpers/bin/fzf`); `test/fzf_real.bats` drives the real fzf in a pseudo-terminal. Add a test for every bug fix. Write `[[ ... ]]` checks that are not the last line of a test as `[[ ... ]] || false`: bats running on bash 3.2 ignores a failing `[[ ]]` otherwise.
128
128
 
129
129
  CI runs shellcheck and the tests on Ubuntu (bash 5), macOS (bash 3.2 and 5) and Windows (Git Bash). Scripts must keep LF line endings (`.gitattributes` enforces it).
130
130
 
package/docs/SECURITY.md CHANGED
@@ -18,7 +18,9 @@ Versions before 2.0 executed repository `.gitbashrc` files as shell code, so ope
18
18
  ## Trust Model
19
19
 
20
20
  - Configuration files (`~/.gitbashrc`, a repository's `.gitbashrc` and `.gitbashrc-user`) are **parsed, never executed**. Only plain `GITBASH_*="value"` lines from a fixed list of settings are read; values containing `$`, backticks, backslashes or quotes are rejected.
21
- - A repository's committed `.gitbashrc` is treated as untrusted: it cannot set `GITBASH_MERGE_COMMAND` (the only setting that names a program to run). That setting is only read from `~/.gitbashrc` and `.gitbashrc-user`.
21
+ - A repository's committed `.gitbashrc` is treated as untrusted: it cannot set `GITBASH_MERGE_COMMAND` (the only setting that names a program to run). That setting is only read from `~/.gitbashrc` and an uncommitted `.gitbashrc-user`. A `.gitbashrc-user` that is tracked by git came with the repository and is treated like `.gitbashrc` (before 3.1.0 it was trusted, so a repository could choose the merge tool that `update` runs on conflicts).
22
+ - A repository's `.gitbashrc` can add to `GITBASH_PROTECTED_BRANCHES`, but not remove branches that your own settings (or the default) protect.
23
+ - A repository also cannot turn off update checks: `GITBASH_NO_UPDATE_CHECKS` is ignored in the committed `.gitbashrc` (and in a committed `.gitbashrc-user`).
22
24
  - Settings that reach git as arguments are validated: `GITBASH_BASE_BRANCH` (and a base branch taken from the remote's `HEAD`) must be a valid branch name that does not start with `-`, and `GITBASH_REMOTE` must not start with `-`. Before 3.0.0, a committed `.gitbashrc` could set `GITBASH_BASE_BRANCH="--output=<file>"` and make `switch` or `cleanup` overwrite that file.
23
25
  - `gitbash --init` prints wrapper functions that call the installed `gitbash` binary; command code is not sourced into your shell.
24
26
  - `pr` and `repo` never pass credentials from the remote URL to the browser.
@@ -28,7 +30,8 @@ Versions before 2.0 executed repository `.gitbashrc` files as shell code, so ope
28
30
  ## Releases
29
31
 
30
32
  - Releases are staged only by the Release workflow in GitHub Actions, with npm trusted publishing (OIDC, no long-lived npm token) and [provenance](https://docs.npmjs.com/generating-provenance-statements). A version goes live only after a maintainer approves it with 2FA ([staged publishing](https://docs.npmjs.com/staged-publishing/)); neither the workflow nor a leaked GitHub token can publish on its own. Check an installed copy with `npm audit signatures`.
31
- - The npm package is published before the GitHub release, which `gitbash --update` and the install script use.
33
+ - The install script (`install.sh`) downloads that same npm package and installs it only if it matches the sha512 checksum npm records for the version. It keeps a copy of itself in the installation, and `gitbash --update` runs that copy, so a git tag cannot change what gets installed or run. (Before 3.1.0, `--update` downloaded `install.sh` from the release's git tag and ran it: whoever could create a GitHub release could run code on machines that updated a script install.)
34
+ - `gitbash --update`, the update check and the install script take the latest version from npm's `latest` tag, which only moves when a maintainer approves a staged version. GitHub releases are not used for updates.
32
35
  - Third-party actions are pinned to commit SHAs and kept up to date by Dependabot; each workflow job gets only the permissions it needs.
33
36
 
34
37
  ---
package/install.sh ADDED
@@ -0,0 +1,171 @@
1
+ #!/bin/sh
2
+ # gitbash installer - installs gitbash without npm or node.
3
+ #
4
+ # Downloads the package published on npm (released only from GitHub Actions,
5
+ # with provenance, after approval with 2FA) and checks it against the sha512
6
+ # integrity npm records for that version before installing anything.
7
+ #
8
+ # curl -fsSL https://raw.githubusercontent.com/jaggli/gitbash/main/install.sh | sh
9
+ # wget -qO- https://raw.githubusercontent.com/jaggli/gitbash/main/install.sh | sh
10
+ #
11
+ # Environment:
12
+ # GITBASH_VERSION version to install, e.g. 3.0.0 or v3.0.0 (default: latest)
13
+ # GITBASH_INSTALL_DIR where the files go (default: ${XDG_DATA_HOME:-~/.local/share}/gitbash)
14
+ # GITBASH_BIN_DIR where the gitbash symlink goes (default: ~/.local/bin)
15
+ #
16
+ # Works in Git Bash on Windows, where a small launcher script replaces the symlink.
17
+ #
18
+ # Run it again, or 'gitbash --update' (which runs the installed copy of this
19
+ # script), to upgrade.
20
+ set -eu
21
+
22
+ REGISTRY="https://registry.npmjs.org/gitbash"
23
+ INSTALL_DIR="${GITBASH_INSTALL_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/gitbash}"
24
+ BIN_DIR="${GITBASH_BIN_DIR:-$HOME/.local/bin}"
25
+
26
+ say() { printf '%s\n' "$*"; }
27
+ die() { printf 'gitbash install: %s\n' "$*" >&2; exit 1; }
28
+
29
+ if command -v curl >/dev/null 2>&1; then
30
+ fetch() { curl -fsSL "$1"; }
31
+ fetch_to() { curl -fsSL -o "$2" "$1"; }
32
+ elif command -v wget >/dev/null 2>&1; then
33
+ fetch() { wget -qO- "$1"; }
34
+ fetch_to() { wget -qO "$2" "$1"; }
35
+ else
36
+ die "curl or wget is required"
37
+ fi
38
+ command -v tar >/dev/null 2>&1 || die "tar is required"
39
+ command -v bash >/dev/null 2>&1 || die "bash is required to run gitbash"
40
+ command -v git >/dev/null 2>&1 || say "Warning: git is not installed; gitbash needs git >= 2.23."
41
+
42
+ # SHA-512 of a file, in hex
43
+ sha512_hex() {
44
+ if command -v sha512sum >/dev/null 2>&1; then
45
+ sha512sum "$1" | cut -d ' ' -f 1
46
+ elif command -v shasum >/dev/null 2>&1; then
47
+ shasum -a 512 "$1" | cut -d ' ' -f 1
48
+ elif command -v openssl >/dev/null 2>&1; then
49
+ openssl dgst -sha512 "$1" | sed 's/.*= *//'
50
+ else
51
+ return 1
52
+ fi
53
+ }
54
+
55
+ # Base64 (npm's integrity format) to hex
56
+ base64_to_hex() {
57
+ { printf '%s' "$1" | base64 -d 2>/dev/null ||
58
+ printf '%s' "$1" | base64 -D 2>/dev/null ||
59
+ printf '%s' "$1" | openssl base64 -d -A 2>/dev/null; } | od -An -v -tx1 | tr -d ' \n'
60
+ }
61
+
62
+ # A string field of npm's (single-line) version document
63
+ json_field() {
64
+ # shellcheck disable=SC2020 # each of , { } becomes a newline
65
+ printf '%s' "$1" | tr ',{}' '\n\n\n' | sed -n "s/^\"$2\":\"\([^\"]*\)\"\$/\1/p" | head -n 1
66
+ }
67
+
68
+ # Returns 0 for a plain version number like 3.0.0 (digits and exactly two dots)
69
+ valid_version() {
70
+ case "$1" in
71
+ *[!0-9.]*|.*|*.|*..*|*.*.*.*) return 1 ;;
72
+ *.*.*) return 0 ;;
73
+ esac
74
+ return 1
75
+ }
76
+
77
+ # Resolve the version, then read its tarball URL and checksum from npm
78
+ version="${GITBASH_VERSION:-latest}"
79
+ version="${version#v}"
80
+ if [ "$version" != latest ] && ! valid_version "$version"; then
81
+ die "invalid GITBASH_VERSION '$version' (use e.g. 3.0.0)"
82
+ fi
83
+ if [ "$version" = latest ]; then
84
+ # Not the version document's "version": its npm scripts have one too
85
+ tags="$(fetch "https://registry.npmjs.org/-/package/gitbash/dist-tags")" ||
86
+ die "could not look up the latest version on npm (set GITBASH_VERSION to pick one)"
87
+ version="$(json_field "$tags" latest)"
88
+ valid_version "$version" ||
89
+ die "could not look up the latest version on npm (set GITBASH_VERSION to pick one)"
90
+ fi
91
+ meta="$(fetch "$REGISTRY/$version")" || die "could not find gitbash $version on npm"
92
+ tarball="$(json_field "$meta" tarball)"
93
+ integrity="$(json_field "$meta" integrity)"
94
+ case "$tarball" in
95
+ "$REGISTRY/-/gitbash-$version.tgz") ;;
96
+ *) die "unexpected package location '$tarball'" ;;
97
+ esac
98
+ case "$integrity" in
99
+ sha512-?*) ;;
100
+ *) die "npm has no sha512 checksum for gitbash $version" ;;
101
+ esac
102
+
103
+ # Refuse to replace a directory that is not a gitbash install
104
+ if [ -e "$INSTALL_DIR" ] && [ ! -f "$INSTALL_DIR/bin/gitbash" ]; then
105
+ die "$INSTALL_DIR exists and is not a gitbash install; set GITBASH_INSTALL_DIR to another location"
106
+ fi
107
+
108
+ tmp="$(mktemp -d 2>/dev/null || mktemp -d -t gitbash)"
109
+ trap 'rm -rf "$tmp"' EXIT INT TERM
110
+
111
+ say "Downloading gitbash $version..."
112
+ fetch_to "$tarball" "$tmp/gitbash.tgz" || die "download failed"
113
+
114
+ # Nothing is unpacked before the checksum matches
115
+ expected="$(base64_to_hex "${integrity#sha512-}")"
116
+ actual="$(sha512_hex "$tmp/gitbash.tgz")" || die "sha512sum, shasum or openssl is required to verify the download"
117
+ if [ -z "$expected" ] || [ "$actual" != "$expected" ]; then
118
+ die "checksum mismatch for gitbash $version: the download was not installed"
119
+ fi
120
+ say "Verified sha512 checksum."
121
+
122
+ mkdir "$tmp/src"
123
+ tar -xzf "$tmp/gitbash.tgz" -C "$tmp/src"
124
+ src="$tmp/src/package"
125
+ [ -f "$src/bin/gitbash" ] || die "unexpected package layout"
126
+
127
+ # Copy only what gitbash needs at runtime
128
+ mkdir "$tmp/install"
129
+ cp -R "$src/bin" "$src/commands" "$src/package.json" "$tmp/install/"
130
+ [ -f "$src/LICENSE" ] && cp "$src/LICENSE" "$tmp/install/"
131
+ # This version's verified installer: 'gitbash --update' runs it from here
132
+ [ -f "$src/install.sh" ] && cp "$src/install.sh" "$tmp/install/"
133
+ chmod +x "$tmp/install/bin/gitbash"
134
+ # Tells 'gitbash --update' how gitbash was installed
135
+ printf 'method=script\nbin_dir=%s\n' "$BIN_DIR" > "$tmp/install/.gitbash-install"
136
+
137
+ mkdir -p "$(dirname "$INSTALL_DIR")" "$BIN_DIR"
138
+ rm -rf "$INSTALL_DIR"
139
+ mv "$tmp/install" "$INSTALL_DIR"
140
+ rm -f "$BIN_DIR/gitbash"
141
+ if ln -s "$INSTALL_DIR/bin/gitbash" "$BIN_DIR/gitbash" 2>/dev/null && [ -L "$BIN_DIR/gitbash" ]; then
142
+ linked="Linked $BIN_DIR/gitbash"
143
+ else
144
+ # No symlinks (Git Bash on Windows copies the file instead): a launcher script
145
+ rm -f "$BIN_DIR/gitbash"
146
+ target="$(printf '%s' "$INSTALL_DIR/bin/gitbash" | sed "s/'/'\\\\''/g")"
147
+ printf '#!/bin/sh\nexec bash '"'"'%s'"'"' "$@"\n' "$target" > "$BIN_DIR/gitbash"
148
+ chmod +x "$BIN_DIR/gitbash"
149
+ linked="Created launcher $BIN_DIR/gitbash"
150
+ fi
151
+
152
+ say "Installed gitbash $version to $INSTALL_DIR"
153
+ say "$linked"
154
+
155
+ case ":$PATH:" in
156
+ *":$BIN_DIR:"*)
157
+ found="$(command -v gitbash 2>/dev/null || true)"
158
+ if [ -n "$found" ] && [ "$found" != "$BIN_DIR/gitbash" ]; then
159
+ say ""
160
+ say "Note: 'gitbash' currently resolves to $found, which comes before $BIN_DIR in PATH."
161
+ fi
162
+ ;;
163
+ *)
164
+ say ""
165
+ say "$BIN_DIR is not in your PATH. Add this to your .zshrc or .bashrc:"
166
+ say " export PATH=\"$BIN_DIR:\$PATH\""
167
+ ;;
168
+ esac
169
+
170
+ say ""
171
+ say "Next: run 'gitbash --config', and see the README for shell integration."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitbash",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Opinionated git utilities for bash with fzf-powered menus",
5
5
  "author": "jaggli",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "bin/",
31
31
  "commands/",
32
32
  "docs/",
33
+ "install.sh",
33
34
  "README.md",
34
35
  "LICENSE"
35
36
  ],