diff --git a/AGENTS.md b/AGENTS.md index 4e6e039..a0797e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,17 @@ What the suite covers: through `json.load` - **install.sh** — places the binary; seeds `~/.config/git-trees/AGENTS.md` from the template under a redirected `HOME`; does not overwrite an existing config - file + file; honours `TREES_DEST`, with a positional argument still winning over it +- **install.sh — no-repo bootstrap** — the `curl | bash` path, with + `TREES_BASE_URL` pointed at a `file://` fixture so the real download branch + runs without touching the network: piped on stdin from a directory with no + `git-trees` in it (piped bash has neither `BASH_SOURCE` nor `$1`, and `set -u` + makes a bare reference to either fatal), the `wget` fallback on a `PATH` built + without `curl`, a clear error when neither downloader exists, a **zero-byte + body** rejected (the transfer succeeds, so only the non-empty check catches + it), a missing script failing loudly and installing nothing, a missing + template warning while the binary still installs, no-clobber on rerun, and the + temp download directory cleaned up by its trap - **rm** — dry run vs `--apply`, worktree removal by branch and by path (a slugged directory whose name is not a branch name, so the path arm is the one that runs), `-d` escalating to `-D` so an unmerged branch is still deleted diff --git a/README.md b/README.md index e5588cf..df3b20c 100644 --- a/README.md +++ b/README.md @@ -119,22 +119,25 @@ cd git-trees && ./install.sh # → ~/.local/bin ./install.sh /usr/local/bin # or anywhere else ``` -**Convenience — curl.** Fetches the script and the agents template (skips the -template if that path is already occupied, including a broken symlink): +**Convenience — one-line curl.** Same installer, downloaded and run in place. +It fetches the script *and* the agents template (skipping the template if that +path is already occupied, including a broken symlink): ```bash -mkdir -p ~/.local/bin ~/.config/git-trees -tmp=$(mktemp) && curl -fsSL -o "$tmp" \ - https://raw.githubusercontent.com/brightdigit/git-trees/main/git-trees \ - && mv "$tmp" ~/.local/bin/git-trees -chmod +x ~/.local/bin/git-trees -if [ ! -e ~/.config/git-trees/AGENTS.md ] && [ ! -L ~/.config/git-trees/AGENTS.md ]; then - tmp=$(mktemp) && curl -fsSL -o "$tmp" \ - https://raw.githubusercontent.com/brightdigit/git-trees/main/AGENTS.md.template \ - && mv "$tmp" ~/.config/git-trees/AGENTS.md -fi +curl -fsSL https://raw.githubusercontent.com/brightdigit/git-trees/main/install.sh | bash ``` +A piped script receives no positional arguments, so set `TREES_DEST` to install +somewhere other than `~/.local/bin`: + +```bash +TREES_DEST=/usr/local/bin curl -fsSL \ + https://raw.githubusercontent.com/brightdigit/git-trees/main/install.sh | bash +``` + +The installer uses `curl` or `wget`, whichever it finds, and verifies each +download is complete and non-empty before installing anything. + `main` is the stable release. A re-install from these URLs picks up the current stable script and template. @@ -145,12 +148,13 @@ case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc ;; esac ``` -`install.sh` warns if it isn't; the curl path cannot. Anything on `PATH` named -`git-trees` becomes `git trees`. +`install.sh` warns if it isn't — either way you run it. Anything on `PATH` +named `git-trees` becomes `git trees`. ## Configuration -All three variables are optional. Add to `~/.zshrc` (or `~/.bashrc`): +Every variable in [**Environment**](#environment) is optional. Add to +`~/.zshrc` (or `~/.bashrc`): ```zsh export TREES_ORG=your-org @@ -322,6 +326,7 @@ git worktree prune | `TREES_AGENTS_TEMPLATE` | `~/.config/git-trees/AGENTS.md` | Seeded at the container root by `init` (and `root --agents`) | | `TREES_NO_PUSH` | *(unset)* | Any non-empty value: `add`/`track` never create a branch on `origin` | | `TREES_RM_CMD` | *(unset)* | Custom command for worktree directory removal (defaults to `git worktree remove`). Bypasses git's uncommitted-work check — see [`git trees rm`](#git-trees-rm-branchpath---apply) | +| `TREES_DEST` | `~/.local/bin` | Install destination for `install.sh`; the only way to choose one when piping the installer | ## Shell wrapper (optional) diff --git a/install.sh b/install.sh index 98363a4..740896e 100755 --- a/install.sh +++ b/install.sh @@ -3,22 +3,62 @@ # # ./install.sh install to ~/.local/bin # ./install.sh /usr/local/bin install elsewhere +# +# Also works with no repo around it, piped straight from the raw URL: +# +# curl -fsSL .../install.sh | bash +# TREES_DEST=/usr/local/bin curl -fsSL .../install.sh | bash +# +# A piped script gets no positional arguments, so TREES_DEST is the only way to +# choose a destination on that path. set -uo pipefail -SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Pinned to main: main is the stable release for this project. +BASE_URL="${TREES_BASE_URL:-https://raw.githubusercontent.com/brightdigit/git-trees/main}" + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" || SRC="" CFG="$HOME/.config/git-trees" -DEST="${1:-$HOME/.local/bin}" +# Positional wins for the clone path; TREES_DEST is the piped path's only lever. +DEST="${1:-${TREES_DEST:-$HOME/.local/bin}}" -[ -f "$SRC/git-trees" ] || { echo "install.sh: git-trees not found in $SRC" >&2; exit 1; } +# Fetch one file to a path. Verifies the transfer rather than trusting that a +# file appeared: a truncated or 404 body installed onto PATH is the worst +# outcome here, and `curl -o` leaves an empty file behind on failure. +fetch() { # fetch + if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "$2" "$1" || return 1 + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" || return 1 + else + echo "install.sh: need curl or wget to download $1" >&2 + return 1 + fi + [ -s "$2" ] +} + +# No repo around the script — piped into bash, or copied off somewhere alone. +if [ -z "$SRC" ] || [ ! -f "$SRC/git-trees" ]; then + SRC=$(mktemp -d "${TMPDIR:-/tmp}/git-trees-install.XXXXXX") || exit 1 + trap 'rm -rf "$SRC"' EXIT + + echo "downloading git-trees from $BASE_URL" >&2 + fetch "$BASE_URL/git-trees" "$SRC/git-trees" || { + echo "install.sh: failed to download git-trees" >&2; exit 1; } + # The template is optional at install time; git-trees warns without it. + fetch "$BASE_URL/AGENTS.md.template" "$SRC/AGENTS.md.template" || { + echo "warning: failed to download AGENTS.md.template — skipping" >&2 + rm -f "$SRC/AGENTS.md.template" + } +fi mkdir -p "$DEST" || exit 1 install -m 0755 "$SRC/git-trees" "$DEST/git-trees" || exit 1 echo "installed $DEST/git-trees" -# AGENTS.md template — README's curl path installs this too. -if [ -f "$SRC/AGENTS.md.template" ] && [ ! -f "$CFG/AGENTS.md" ]; then +# AGENTS.md template — the curl path installs this too. +if [ -f "$SRC/AGENTS.md.template" ] && [ ! -e "$CFG/AGENTS.md" ] && [ ! -L "$CFG/AGENTS.md" ]; then mkdir -p "$CFG" cp "$SRC/AGENTS.md.template" "$CFG/AGENTS.md" echo "installed $CFG/AGENTS.md (template; used by init or root --agents to seed the container root)" diff --git a/tests/smoke.sh b/tests/smoke.sh index 63d4f59..2361b6e 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -487,6 +487,180 @@ assert_eq "install.sh rerun exits 0" "$rc" "0" assert_eq "install.sh does not overwrite an existing template" \ "$(cat "$IHOME/.config/git-trees/AGENTS.md")" "CUSTOM" +# TREES_DEST is the piped path's only way to choose a destination, but it must +# work from a clone too, and the positional must still win over it. +EDEST="$TMP/install-envdest" +out=$(HOME="$IHOME" TREES_DEST="$EDEST" bash "$REPO/install.sh" 2>&1) +rc=$? +assert_eq "install.sh TREES_DEST exits 0" "$rc" "0" +assert_ok "install.sh honours TREES_DEST" test -x "$EDEST/git-trees" +PDEST="$TMP/install-posdest" +HOME="$IHOME" TREES_DEST="$EDEST" bash "$REPO/install.sh" "$PDEST" >/dev/null 2>&1 +assert_ok "install.sh positional beats TREES_DEST" test -x "$PDEST/git-trees" + +# --- install.sh — no-repo bootstrap (the `curl | bash` path) ----------------- +# +# The script is copied somewhere with no git-trees beside it, so it takes the +# download branch. TREES_BASE_URL points at a file:// fixture: the real fetch +# code runs, no network is touched, and the test cannot silently no-op offline. + +section "install.sh — no-repo bootstrap" +SERVE="$TMP/serve" +mkdir -p "$SERVE" +cp "$REPO/git-trees" "$SERVE/git-trees" +cp "$REPO/AGENTS.md.template" "$SERVE/AGENTS.md.template" +BOOT="$TMP/boot" +mkdir -p "$BOOT" +cp "$REPO/install.sh" "$BOOT/install.sh" + +BHOME="$TMP/boot-home" +BDEST="$TMP/boot-bin" +mkdir -p "$BHOME" +out=$(HOME="$BHOME" TREES_DEST="$BDEST" TREES_BASE_URL="file://$SERVE" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap exits 0" "$rc" "0" +assert_contains "bootstrap announces the download" "$out" "downloading git-trees" +assert_ok "bootstrap placed the binary" test -x "$BDEST/git-trees" +assert_eq "bootstrap binary matches the source" \ + "$(cat "$BDEST/git-trees")" "$(cat "$REPO/git-trees")" +assert_ok "bootstrap binary runs" bash "$BDEST/git-trees" help +assert_ok "bootstrap seeded the agents template" \ + test -f "$BHOME/.config/git-trees/AGENTS.md" +assert_eq "bootstrap template matches AGENTS.md.template" \ + "$(cat "$BHOME/.config/git-trees/AGENTS.md")" "$(cat "$REPO/AGENTS.md.template")" + +# A rerun must not clobber a template the user has edited. +echo BOOTCUSTOM > "$BHOME/.config/git-trees/AGENTS.md" +HOME="$BHOME" TREES_DEST="$BDEST" TREES_BASE_URL="file://$SERVE" \ + bash "$BOOT/install.sh" >/dev/null 2>&1 +rc=$? +assert_eq "bootstrap rerun exits 0" "$rc" "0" +assert_eq "bootstrap rerun does not overwrite the template" \ + "$(cat "$BHOME/.config/git-trees/AGENTS.md")" "BOOTCUSTOM" + +# The temp download directory is trapped away on exit. A private TMPDIR makes +# that observable: mktemp -d lands inside it, so anything left is a leak. +SCRATCH="$TMP/boot-tmpdir" +mkdir -p "$SCRATCH" +HOME="$BHOME" TMPDIR="$SCRATCH" TREES_DEST="$BDEST" TREES_BASE_URL="file://$SERVE" \ + bash "$BOOT/install.sh" >/dev/null 2>&1 +assert_eq "bootstrap cleans up its temp dir" \ + "$(find "$SCRATCH" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" "0" + +# A missing git-trees at the base URL must fail loudly, not install nothing +# quietly. `curl -fsSL` fails on HTTP errors; file:// fails on a missing path. +EMPTY="$TMP/serve-empty" +mkdir -p "$EMPTY" +FHOME="$TMP/boot-fail-home" +FDEST="$TMP/boot-fail-bin" +mkdir -p "$FHOME" +out=$(HOME="$FHOME" TREES_DEST="$FDEST" TREES_BASE_URL="file://$EMPTY" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap fails when git-trees is missing" "$rc" "1" +assert_contains "bootstrap says why it failed" "$out" "failed to download git-trees" +assert_fail "bootstrap installed nothing on failure" test -e "$FDEST/git-trees" + +# The actual `curl ... | bash` shape: piped on stdin, from a directory with no +# git-trees in it. Piped bash has no BASH_SOURCE and no $1, and `set -u` makes a +# bare reference to either fatal — a copied-file test cannot catch that. +PHOME="$TMP/boot-piped-home" +PPDEST="$TMP/boot-piped-bin" +mkdir -p "$PHOME" "$TMP/boot-piped-cwd" +out=$(in_dir "$TMP/boot-piped-cwd" env HOME="$PHOME" TREES_DEST="$PPDEST" \ + TREES_BASE_URL="file://$SERVE" bash < "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "piped bootstrap exits 0" "$rc" "0" +assert_not_contains "piped bootstrap has no unbound-variable error" \ + "$out" "unbound variable" +assert_ok "piped bootstrap placed the binary" test -x "$PPDEST/git-trees" +assert_ok "piped bootstrap seeded the template" \ + test -f "$PHOME/.config/git-trees/AGENTS.md" + +# The wget fallback and the neither-downloader error, on a PATH built to contain +# exactly what each case needs. macOS ships /usr/bin/curl, so proving the +# fallback runs at all means excluding the real curl from PATH. +STUBBIN="$TMP/stub-bin" +mkdir -p "$STUBBIN" +for c in bash mkdir install cp mktemp rm cat dirname pwd sed find chmod wc tr; do + cbin=$(command -v "$c") && ln -sf "$cbin" "$STUBBIN/$c" +done + +WHOME="$TMP/boot-wget-home" +WDEST="$TMP/boot-wget-bin" +mkdir -p "$WHOME" +out=$(env -i HOME="$WHOME" PATH="$STUBBIN" TREES_DEST="$WDEST" \ + TREES_BASE_URL="file://$SERVE" "$STUBBIN/bash" "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap fails with neither curl nor wget" "$rc" "1" +assert_contains "bootstrap names the missing tools" "$out" "need curl or wget" +assert_fail "bootstrap installed nothing without a downloader" \ + test -e "$WDEST/git-trees" + +# Minimal wget standing in for the real thing: only the -qO form install.sh +# uses, over file://. Exits nonzero on a missing source, as wget does. +cat > "$STUBBIN/wget" <<'WGET_STUB' +#!/usr/bin/env bash +out=""; url="" +while [ $# -gt 0 ]; do + case "$1" in + -qO) out="$2"; shift 2 ;; + -q) shift ;; + *) url="$1"; shift ;; + esac +done +src="${url#file://}" +[ -f "$src" ] || exit 8 +cat "$src" > "$out" +WGET_STUB +chmod +x "$STUBBIN/wget" + +out=$(env -i HOME="$WHOME" PATH="$STUBBIN" TREES_DEST="$WDEST" \ + TREES_BASE_URL="file://$SERVE" "$STUBBIN/bash" "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap via wget exits 0" "$rc" "0" +assert_ok "bootstrap via wget placed the binary" test -x "$WDEST/git-trees" +assert_eq "bootstrap via wget binary matches the source" \ + "$(cat "$WDEST/git-trees")" "$(cat "$REPO/git-trees")" +assert_ok "bootstrap via wget seeded the template" \ + test -f "$WHOME/.config/git-trees/AGENTS.md" + +# A zero-byte body is the truncated-download case: curl succeeds (the transfer +# completed), so only the non-empty check catches it. Installing an empty +# git-trees onto PATH is the worst outcome here, hence its own fixture. +TRUNC="$TMP/serve-truncated" +mkdir -p "$TRUNC" +: > "$TRUNC/git-trees" +cp "$REPO/AGENTS.md.template" "$TRUNC/AGENTS.md.template" +THOME="$TMP/boot-trunc-home" +TDEST="$TMP/boot-trunc-bin" +mkdir -p "$THOME" +out=$(HOME="$THOME" TREES_DEST="$TDEST" TREES_BASE_URL="file://$TRUNC" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap rejects a zero-byte download" "$rc" "1" +assert_contains "bootstrap says why the empty download failed" \ + "$out" "failed to download git-trees" +assert_fail "bootstrap installed nothing from a zero-byte download" \ + test -e "$TDEST/git-trees" + +# A present script with a missing template warns and still installs the binary. +ONLY="$TMP/serve-binary-only" +mkdir -p "$ONLY" +cp "$REPO/git-trees" "$ONLY/git-trees" +NHOME="$TMP/boot-notmpl-home" +NDEST="$TMP/boot-notmpl-bin" +mkdir -p "$NHOME" +out=$(HOME="$NHOME" TREES_DEST="$NDEST" TREES_BASE_URL="file://$ONLY" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap without a template exits 0" "$rc" "0" +assert_ok "bootstrap without a template still installs" test -x "$NDEST/git-trees" +assert_contains "bootstrap warns about the missing template" "$out" "AGENTS.md.template" +assert_fail "bootstrap wrote no config template" \ + test -e "$NHOME/.config/git-trees/AGENTS.md" + # --- rm ---------------------------------------------------------------------- section "rm"