Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 20 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
50 changes: 45 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> <out>
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)"
Expand Down
174 changes: 174 additions & 0 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading