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
5 changes: 5 additions & 0 deletions .claude/agent-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ update or remove the stale line rather than leaving both.

- Never merge pull requests unless the user explicitly asks to merge.
- Do not change `AGENTS.md.template` for project-specific agent conventions; that file is for the installing developer.
- Branch names with `/` are supported and slug to `-` in the directory name; do not reject slash branches.
- User-facing docs (README, CHANGELOG) must not mention features that never shipped; 1.0.0 is the first release, so there is no prior version to reference.
- CHANGELOG follows GitHub release-notes format (`## What's Changed` + PR URLs), listing shipped features only — not Keep a Changelog / Unreleased / pre-1.0 fix archaeology. PR URLs remain even after the git history wipe.
- Do not mention previous code, removed subcommands, or pre-v1.0 archaeology anywhere in the tree (docs, comments, tests). Forward-looking constraints and current git-behavior rationale are fine; unused merged-branch/`rev-list` guidance and a dedicated `clean` unknown-command test are not.
- Do not remove an `init` container on agent-seeding failure; return nonzero and leave the directory.
74 changes: 3 additions & 71 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,75 +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

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

if bash "$T" add feature/x 2>/tmp/slash-err; then
echo "FAIL: add should reject branch names containing /"; exit 1
fi
grep -q "must not contain '/'" /tmp/slash-err || {
echo "FAIL: missing slash-rejection message"; cat /tmp/slash-err; exit 1
}

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; }

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)'

git branch fresh origin/main
if bash "$T" clean 2>&1 | grep -A50 'merged into' | grep -q ' fresh$'; then
echo "FAIL: fresh branch listed as merged"; 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"

echo "smoke tests passed"
run: tests/smoke.sh ./git-trees
140 changes: 90 additions & 50 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,79 +20,119 @@ leaving both.
bare-repo + worktrees layout. Read `README.md` first for behavior; this file
covers constraints on changing it.

**This file is contributor guidance for `git-trees` itself** (`CLAUDE.md` is a
symlink to it). Do not confuse it with `AGENTS.md.template`, which is a
*product artifact*: `install.sh` copies it to `~/.config/git-trees/AGENTS.md`,
and `init` seeds it into the root of containers built with this tool, for an
entirely different audience. Nothing project-specific to this repo belongs in
the template.

## Constraints

**Single file.** `git-trees` must stay one self-contained script installable by
copying it onto `PATH`. Do not split into sourced libraries.

**Pure git.** No `gh`, `glab`, `jq`, or other external CLIs. `git`, coreutils,
and `awk` only. An earlier `pr` subcommand was removed specifically to
keep this property — do not reintroduce forge integration.
and `awk` only. Do not add forge integration.

**Cannot cd the shell.** A git subcommand is a separate process. Any feature
needing to change the user's directory must instead print a path on stdout and
be wrapped by the optional shell function in the README. `--print-path` on `add`
is the established pattern: path to stdout, all other output to stderr.

**Report before destroy.** `clean` defaults to reporting. Destructive work
happens only under `--apply`. Use `git branch -d`, never `-D`; when it fails,
print the `-D` command for the user rather than running it.

## Two bugs that were found by testing — don't regress them

1. **`git worktree add -b <new> <base>` inherits the base ref's upstream.** A
branch created from `origin/main` silently gets `origin/main` as its upstream
and will push there. The new-branch path must pass `--no-track`, then let
`cmd_track` set the correct upstream.

2. **`git branch --merged` flags branches with no commits of their own.** A
branch just cut from `main` is reachable-from-`main` and looks merged. The
merged pass must skip branches where
`git rev-list --count origin/<def>..<br>` is 0.

Both are counterintuitive and both were caught only by running against a real
repo. Any change touching `add` or `clean --merged` needs a fresh test.
**Worktrees do not nest.** Every worktree is a direct child of the container
root, so the directory name is the branch name with each `/` replaced by `-`
(`_slug`). The branch itself is never renamed — only the directory. The
consequence is that `feature/x` and `feature-x` compete for one directory;
`cmd_add` resolves this by refusing the second and naming the branch that owns
the directory (`_branch_at`). Do not "fix" that by inventing a suffixed variant:
a directory whose name the user cannot predict is worse than an error.

**Nothing destructive.** No subcommand removes a worktree or deletes a branch.
Anything that destroys user data must report by default and act only under an
explicit `--apply`, must use `git branch -d` and never `-D`, and must route
directory removal through a user-configurable command.

**`track` only ever sets `origin/<branch>`.** Same remote, same name. There is
no flag for an arbitrary upstream, and `origin` is hardcoded throughout —
deliberately, since the layout assumes one remote. A user wanting something else
runs `git branch --set-upstream-to` themselves; `track` is idempotent and returns
early once *any* upstream is set, so it will not fight them. If this ever grows a
`--upstream <ref>` flag, `cmd_add` must pass it through — `add` calls `cmd_track`
unconditionally, and would otherwise overwrite what the user asked for.

## Git pitfall: new worktrees inherit upstream

`git worktree add -b <new> <base>` inherits the base ref's upstream. A branch
created from `origin/main` silently gets `origin/main` as its upstream and will
push there. The new-branch path must pass `--no-track`, then let `cmd_track` set
the correct upstream. Live in `cmd_add`; any change there needs a fresh test.

## 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` — must fail (no `/` in branch names)
- `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
- `clean` — a freshly cut branch must not appear under "merged"
- `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`, and unknown command
- **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`

Two assertion shapes are easy to get wrong:

- The missing-option-value checks are **bounded** (`run_bounded`). An unbounded
hang would sit 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`. A looser
check (for example `!= origin/main`) also passes on an empty or otherwise wrong
upstream.

ShellCheck is not a safety net here — it can pass clean on an argument-parsing
hang or a wrong `_seed_agents` guard order. Linting is not coverage.

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

## Style

- `set -uo pipefail` at the top; deliberately not `-e`, since several checks
rely on nonzero exits
- Functions prefixed `cmd_` are subcommands; `_`-prefixed are internal helpers
- Every subcommand validates its own args and prints usage to stderr on failure
- Comments explain *why*, particularly for the two bugs above — the `--no-track`
and `rev-list --count` lines look removable without them
- Comments explain *why* — the `--no-track` line looks removable without one
6 changes: 4 additions & 2 deletions AGENTS.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ indexes, and HEADs.

1. Stay inside your assigned worktree. Do not `cd` into a sibling worktree or
above the repo root.
2. Do not `git checkout` a different branch. To work on another branch, request a
new worktree.
2. Do not `git checkout` a different branch. To work on another branch, create
its worktree with `git trees add <name>` from the repo root — that gives it
its own directory and leaves yours untouched. If you cannot run that, request
the worktree instead.
3. Never touch `trees-bare.git/`.
4. Push with `git push -u origin HEAD` — upstream tracking may not be set.
5. Other agents may be working in sibling worktrees concurrently. Do not `git gc`,
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Changelog

## v1.0.0

First release of `git-trees`: a `git` subcommand for managing a bare-repo +
worktrees layout. Pure git — no `gh`, no `jq`, no forge integration.

## What's Changed

* Seed `AGENTS.md` at the container root only, not in each worktree by @leogdion in https://github.com/brightdigit/git-trees/pull/16
* Document that `add` writes to the remote, and add `--no-push` by @leogdion in https://github.com/brightdigit/git-trees/pull/37
* Support slash branch names via slugged worktree directories (`feature/x` → `feature-x/`) by @leogdion in https://github.com/brightdigit/git-trees/pull/36
* Harden `add`: clear collision errors and fail when track/push fails by @leogdion in https://github.com/brightdigit/git-trees/pull/10
* Document env var setup; verify placeholder URLs resolved by @leogdion in https://github.com/brightdigit/git-trees/pull/15
* Docs: a quickstart that works, a concepts primer, and honest install docs by @leogdion in https://github.com/brightdigit/git-trees/pull/38
* CI: run smoke tests on Linux and macOS by @leogdion in https://github.com/brightdigit/git-trees/pull/11

**Full Changelog**: https://github.com/brightdigit/git-trees/commits/v1.0.0
Loading
Loading