Skip to content
Merged
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
177 changes: 3 additions & 174 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v4

- name: Syntax check
run: bash -n git-trees && bash -n install.sh
run: bash -n git-trees && bash -n install.sh && bash -n tests/smoke.sh

- name: Install ShellCheck
run: |
Expand All @@ -27,178 +27,7 @@ jobs:
fi

- name: ShellCheck
run: shellcheck -s bash git-trees install.sh
run: shellcheck -s bash git-trees install.sh tests/smoke.sh

- name: Smoke tests
run: |
set -e
git config --global user.email ci@example.com
git config --global user.name CI
git config --global init.defaultBranch main

mkdir -p /tmp/tt/origin && cd /tmp/tt/origin
git init -q -b main .
echo hi > a.txt && git add . && git commit -qm init
git branch feature-x
git branch slug/one # pre-existing slash branch, as a real repo has

cd /tmp/tt && git clone -q --bare /tmp/tt/origin proj/trees-bare.git
echo "gitdir: ./trees-bare.git" > proj/.git
cd proj
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
git fetch -q origin
git remote set-head origin --auto >/dev/null

T="$GITHUB_WORKSPACE/git-trees"
chmod +x "$T"

bash "$T" help

bash "$T" add feature-x
up=$(git -C feature-x rev-parse --abbrev-ref '@{upstream}')
[ "$up" = "origin/feature-x" ] || { echo "FAIL: upstream was $up"; exit 1; }

# A slash branch checks out into a slugged directory; the ref keeps
# its slash. Uses a name of its own so it cannot claim feature-x above.
bash "$T" add slug/one
[ -d slug-one ] || { echo "FAIL: slug/one did not create slug-one"; exit 1; }
b=$(git -C slug-one symbolic-ref --short HEAD)
[ "$b" = "slug/one" ] || { echo "FAIL: branch was $b, expected slug/one"; exit 1; }
up=$(git -C slug-one rev-parse --abbrev-ref '@{upstream}')
[ "$up" = "origin/slug/one" ] || { echo "FAIL: upstream was $up"; exit 1; }

# Two branches slugging to one directory: refuse, and name the owner.
if bash "$T" add slug-one 2>/tmp/slug-err; then
echo "FAIL: slug-one should collide with slug/one"; exit 1
fi
grep -q "slug/one" /tmp/slug-err || {
echo "FAIL: collision message did not name the owning branch"
cat /tmp/slug-err; exit 1
}

# New slash branch: every component slugged, and --no-track still
# applies so it does not inherit origin/main.
bash "$T" add deep/new/branch
[ -d deep-new-branch ] || { echo "FAIL: deep/new/branch did not slug"; exit 1; }
up=$(git -C deep-new-branch rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none)
[ "$up" = "origin/deep/new/branch" ] || {
echo "FAIL: new slash branch upstream was $up"; exit 1
}

mkdir leftover-dir
if bash "$T" add leftover-dir 2>/tmp/collide-err; then
echo "FAIL: add should reject an existing directory"; exit 1
fi
grep -q "directory already exists" /tmp/collide-err || {
echo "FAIL: missing collision message"; cat /tmp/collide-err; exit 1
}
rmdir leftover-dir

bash "$T" add brandnew
up=$(git -C brandnew rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none)
[ "$up" != "origin/main" ] || { echo "FAIL: new branch inherited origin/main"; exit 1; }

p=$(bash "$T" add another --print-path 2>/dev/null)
[ -d "$p" ] || { echo "FAIL: --print-path returned '$p'"; exit 1; }
# stdout must be only the path (no banners mixed in).
# Note: $(...) strips one trailing newline, so wc -l is 0 for a single line.
case $p in *$'\n'*) echo "FAIL: --print-path stdout was multi-line: '$p'"; exit 1 ;; esac

bash "$T" list
bash "$T" list --json | python3 -c 'import json,sys; json.load(sys.stdin)'

# `clean` is not a command (#17). Assert it is *unrecognized* — a bare
# nonzero exit would also pass if it were dispatched and merely failed.
if bash "$T" clean 2>/tmp/clean-err; then
echo "FAIL: clean should be an unknown command"; exit 1
fi
grep -q "unknown command 'clean'" /tmp/clean-err || {
echo "FAIL: clean failed, but not as an unknown command"
cat /tmp/clean-err; exit 1
}
# Whole word: the usage text legitimately contains "clean/dirty".
if bash "$T" help 2>&1 | grep -qw clean; then
echo "FAIL: help still advertises clean"; exit 1
fi

# --- #25: --no-push must not create the branch on origin ---
# Remote branch absent: nothing pushed, upstream unset, exit 0.
bash "$T" add nopush --no-push 2>/tmp/nopush-err
if git ls-remote --exit-code --heads origin nopush >/dev/null 2>&1; then
echo "FAIL: --no-push created origin/nopush"; exit 1
fi
if git -C nopush rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then
echo "FAIL: --no-push set an upstream"; exit 1
fi
grep -q 'git push -u origin HEAD' /tmp/nopush-err || {
echo "FAIL: --no-push did not print the push command"; cat /tmp/nopush-err; exit 1
}

# Remote branch present: --no-push must still set the upstream.
git push -q origin feature-x:nopush-existing
git fetch -q origin
bash "$T" add nopush-existing --no-push >/dev/null 2>&1
up=$(git -C nopush-existing rev-parse --abbrev-ref '@{upstream}')
[ "$up" = "origin/nopush-existing" ] || {
echo "FAIL: --no-push upstream was $up"; exit 1
}

# TREES_NO_PUSH does the same without the flag.
TREES_NO_PUSH=1 bash "$T" add envnopush >/dev/null 2>&1
if git ls-remote --exit-code --heads origin envnopush >/dev/null 2>&1; then
echo "FAIL: TREES_NO_PUSH created origin/envnopush"; exit 1
fi

# Outside a repo → nonzero exit
outside=$(mktemp -d)
if (cd "$outside" && bash "$T" list) >/dev/null 2>&1; then
echo "FAIL: expected nonzero exit outside a repo"; exit 1
fi
rmdir "$outside"

# --- #18: an option missing its value must fail, not spin forever ---
# Bounded, because a regression hangs the runner until the job timeout.
for opt in --host --dir; do
bash "$T" init "$opt" >/dev/null 2>&1 &
pid=$!
hung=1
for _ in 1 2 3 4 5 6; do
if ! kill -0 $pid 2>/dev/null; then hung=0; break; fi
sleep 0.5
done
if [ "$hung" -eq 1 ]; then
kill -9 $pid 2>/dev/null || true
echo "FAIL: 'init $opt' with no value hung"; exit 1
fi
if wait $pid; then
echo "FAIL: 'init $opt' with no value should exit nonzero"; exit 1
fi
done

# --- #31: invalid branch names report the branch, not a collision ---
for bad in . ..; do
if bash "$T" add "$bad" 2>/tmp/badname-err; then
echo "FAIL: add '$bad' should fail"; exit 1
fi
if grep -q "directory already exists" /tmp/badname-err; then
echo "FAIL: add '$bad' reported a directory collision"; cat /tmp/badname-err; exit 1
fi
done
if bash "$T" add 'has space' 2>/tmp/space-err; then
echo "FAIL: add 'has space' should fail"; exit 1
fi
# Must be rejected before git starts building the worktree.
if grep -q 'Preparing worktree' /tmp/space-err; then
echo "FAIL: 'has space' rejected only after worktree add ran"; cat /tmp/space-err; exit 1
fi

# --- #23: --json must escape backslashes, not just quotes ---
jf=$(mktemp -d)/'back\slash "quoted"'
git worktree add -b jsonesc "$jf" origin/main >/dev/null 2>&1
bash "$T" list --json | python3 -c 'import json,sys; json.load(sys.stdin)' || {
echo "FAIL: list --json invalid with a backslash in a path"; exit 1
}
git worktree remove --force "$jf"
git branch -D jsonesc >/dev/null

echo "smoke tests passed"
run: tests/smoke.sh ./git-trees
88 changes: 58 additions & 30 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,40 +84,68 @@ repo.

## Testing

No test framework. Verify by building a throwaway repo pair:
Run the suite:

```bash
rm -rf /tmp/tt && mkdir -p /tmp/tt/origin && cd /tmp/tt/origin
git init -q -b main . && git config user.email t@t && git config user.name t
echo hi > a.txt && git add . && git commit -qm init
git branch feature-x

cd /tmp/tt && git clone -q --bare /tmp/tt/origin proj/trees-bare.git
echo "gitdir: ./trees-bare.git" > proj/.git
cd proj
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
git config user.email t@t && git config user.name t
git fetch -q origin && git remote set-head origin --auto >/dev/null
tests/smoke.sh # tests ./git-trees
tests/smoke.sh /path/to/git-trees
```

Then exercise the paths. Things worth checking after any change:

- `add feature/x` — creates `feature-x/`; the branch keeps its slash and its
upstream is `origin/feature/x`
- `add feature-x` when `feature/x` already owns `feature-x/` — must fail, and the
error must name `feature/x`
- `add feature-x` — remote branch exists; upstream must be `origin/feature-x`
- `add brandnew` — no remote branch; upstream must be `origin/brandnew`, **not**
`origin/main`; failed track/push must exit nonzero
- `add` into an existing directory — clear collision error, nonzero exit
- `add x --print-path` — stdout must be *only* the path
- `list --json` — valid JSON, includes branches with no worktree
- `git trees` outside a repo — clean error, nonzero exit

Automated coverage lives in `.github/workflows/ci.yml` (ubuntu + macOS). Also run
`bash -n git-trees` for syntax and `shellcheck git-trees` if available.

`init` needs network and is not covered by the above.
It builds its own fixtures under `mktemp -d` from `file://` remotes — no
network, no `jq`, nothing written outside the temp directory — and runs every
check to completion rather than stopping at the first failure. CI runs exactly
this, on `ubuntu-latest` and `macos-latest`
(`.github/workflows/ci.yml`), plus `bash -n` and
`shellcheck -s bash git-trees install.sh tests/smoke.sh`.

`tests/smoke.sh` is a test harness, not part of the tool. The single-file
constraint above governs `git-trees`; it does not forbid a test script.

What the suite covers:

- **help/dispatch** — `help`, `--help`, unknown command, and that the removed
`clean` is *unrecognized* rather than merely unsuccessful
- **outside a repo** — `list` and `root` both exit nonzero
- **init** — happy path over `file://`; the gitdir pointer, bare store, seeded
`AGENTS.md`, and `origin/*` refs it must produce; the container root having no
work tree; refusal on an existing directory; **rollback when the post-clone
fetch fails**, and that a retry then works; `--host`/`--dir` with a missing
value exiting promptly rather than hanging
- **root** — printing the container, adopting a bare container by writing the
`.git` pointer under the store's own name, rejecting a plain directory
- **root --agents** — seeds when absent; never overwrites a regular file; treats
a **broken symlink** as occupied; no-ops without a template; keeps stdout to
the path alone
- **add** — `.`, `..` and `'has space'` reported as bad branch names rather than
directory collisions, and rejected before `Preparing worktree`; upstream
exactly `origin/feature-x` for an existing remote branch and exactly
`origin/brandnew` for a new one; directory collision; `--print-path` emitting
only a path; argument errors; nonzero exit when `track`/push fails
- **add with a slash in the branch** — the directory is slugged (`feature/x` →
`feature-x/`, `deep/new/branch` → `deep-new-branch/`) while the ref keeps its
slash and tracks `origin/feature/x`; a second branch slugging to a taken
directory is refused with the owning branch named
- **track** — idempotent on an already-tracked worktree; fails on a non-worktree
- **list** — text output, branches with no worktree shown as `(none)`,
`--json` parsing, and a worktree whose path contains `\` and `"` round-tripping
through `json.load`

Bug-shaped assertions carry a comment saying which bug they pin. Two are worth
knowing about:

- The missing-option-value checks are **bounded** (`run_bounded`). The
regression is an infinite loop, so a plain assertion would hang the runner
until the job timeout instead of failing. `timeout` is not installed on macOS,
hence the background-PID and `kill -0` dance.
- `add brandnew` asserts the upstream is **exactly** `origin/brandnew`. The
older `!= origin/main` form also passed on an empty or otherwise wrong
upstream, which is the same bug wearing a different hat.

ShellCheck is not a safety net here — it passes clean on code containing both
the argument-parsing hang and the `_seed_agents` precedence bug. Linting is not
coverage.

Not covered: `init` against a real network host.

## Style

Expand Down
Loading
Loading