One feature, many repos: the same branch everywhere, one agent that sees it all.
Project description
polytree
One feature, many repos.
Your frontend and backend live in separate repositories, and every feature touches both. Git doesn't know they're related: creating a worktree in one doesn't create one in the other, and your coding agent only sees the repo you launched it in — so you keep telling it "the frontend is over there, modify that too".
polytree fixes both halves:
$ polytree new checkout-redesign
Creating 'checkout-redesign' worktrees in 2 repos (backend: git)…
my-api -> /home/you/polytree/checkout-redesign/my-api
my-web -> /home/you/polytree/checkout-redesign/my-web
→ claude in:
host : /home/you/polytree/checkout-redesign/my-api
+dir : /home/you/polytree/checkout-redesign/my-web
One command: a worktree on the same branch in every repo, plus one agent session that sees all of them.
Already have the worktrees? polytree link finds the siblings by shared branch name — you never type a path, and you never have to remember what you called them.
Why not just a monorepo?
If your repos always ship in lockstep, a monorepo is probably the right answer and you don't need this. polytree is for teams that have deliberately separate repos and still need a feature to span them.
Install
Runs on macOS and Linux. Requires Python 3.11+ and git 2.36+ (for
worktree list -z). polytree has no third-party dependencies — it is pure
standard library, one self-contained script.
With pipx (recommended) — isolated, on your PATH,
and clean to upgrade or remove:
pipx install polytree
With pip:
pip install polytree
Either way you get the polytree command on your PATH — nothing to move by
hand.
Hacking on polytree itself? Symlink it instead of copying, so the command always runs what's in your checkout:
git clone https://github.com/briankalid/polytree && cd polytree
ln -s "$PWD/polytree" ~/.local/bin/polytree
Land in the worktree (git backend)
On the git backend, polytree new/link run the agent inside the worktree. A program can't change its parent shell's directory, so instead of dropping you back where you launched when the agent exits, polytree lands you in a shell already in the worktree — ready to commit and push:
$ polytree new feature # agent runs… you quit it…
→ shell in ~/polytree/feature/my-api (exit to return)
$ git push -u origin feature # you're in the worktree
$ exit # leave the set in place, back to where you launched
# …or, once the feature is merged, tear the whole set down from here first:
$ polytree rm # removes this branch's worktrees, then exit
This is on by default (git backend only — the orca backend gives the agent its own terminal). It keeps your dev loop unbroken: if the agent dies, you're still in the worktree, so polytree link picks right back up. And relaunching from inside that shell runs the agent and returns you to the same shell — shells never stack, however often you re-launch.
The one cost is a nested shell (one extra exit). To turn it off, subshell = false in the config, or --no-subshell for one run.
Prefer a true cd? For no nested shell — your current shell moves into the worktree — set subshell = false and add this to your ~/.zshrc/~/.bashrc. polytree writes the worktree path to POLYTREE_CD_FILE; the function reads it and cds there after the agent exits:
polytree() {
local f; f="$(mktemp)"
POLYTREE_CD_FILE="$f" command polytree "$@"
local d; d="$(cat "$f" 2>/dev/null)"; rm -f "$f"
[ -n "$d" ] && [ -d "$d" ] && cd "$d"
}
Publishing to PyPI (maintainers)
The version is read straight from the polytree script (VERSION = "…"), so
there is nothing else to bump. Build and upload with:
python -m build # writes dist/polytree-<version>-py3-none-any.whl + .tar.gz
python -m twine upload dist/*
Configure
~/.config/polytree/config.toml:
backend = "auto" # auto | git | orca (auto = orca if installed, else git)
agent = "claude" # any key under [agents], or a built-in (claude, codex)
root = "~/polytree" # git backend: worktrees live at <root>/<feature>/<repo>
base = "origin/main" # optional global default base ref (both backends)
[[repos]] # the first repo (or host = true) hosts the agent
path = "~/code/my-api"
base = "origin/dev" # optional; overrides the global default
host = true
[[repos]]
path = "~/code/my-web"
Each repo's directory name must be unique — it's the folder name under <root>/<branch>/. Set name = "..." explicitly if two repos share a basename.
macOS note. The branch name is itself a directory component (
<root>/<branch>/). On a case-insensitive filesystem — the default on macOS (APFS) — two branches that differ only in case, likeFix-typoandfix-typo, resolve to the same folder and collide. On Linux, whose filesystems are case-sensitive, they're distinct. If you're on a stock Mac, keep feature branches distinct by more than case.
Which ref do the branches start from?
Per repo, in order: --base on the command line → that repo's base → the global base → the repo's origin/HEAD → its local default branch. If none of those resolve, polytree tells you instead of guessing. The chosen ref is checked in every repo before anything is created.
If you leave base unset the two backends differ: the git backend uses origin/HEAD, while the orca backend defers to the base ref you configured for that repo in Orca. Set base explicitly if you need them to agree.
Upstream
Branches are created with no upstream, on purpose. Branching off a remote ref like origin/dev would otherwise make your feature branch track dev (git's default branch.autoSetupMerge): git push then fails and suggests git push origin HEAD:dev — which pushes unreviewed work straight onto the shared branch. Publish the normal way instead:
git push -u origin <branch>
Commands
| Command | What it does |
|---|---|
polytree new <name> |
Create a worktree on branch <name> in every repo, then launch the agent. If any repo fails, everything is rolled back — you never get half a set |
polytree list |
Your feature sets: every branch with a worktree in 2+ repos, and whether each is clean or dirty |
polytree link [branch] |
Existing worktrees: find the siblings and launch the agent. No branch = the one you're standing in |
polytree rm [branch] |
Remove the worktree set. No branch = the one you're standing in. Checks every repo first and removes nothing if any worktree is dirty, locked, or has populated submodules. --force overrides all three and deletes the branch even if unmerged. The main checkout is never removed |
polytree paths [branch] |
Print the sibling worktree paths. No side effects |
polytree ls |
Show the resolved config (backend, agent, repos) |
new and link take --host <repo> to choose which repo runs the agent (default: the first in your config — see the hooks caveat below). new takes --no-launch to create the worktrees without starting anything, and --base <ref> to branch off something other than the configured base — the hotfix case:
polytree new hotfix-payments --base origin/master # config still says origin/dev
--base applies the same ref to every repo, so it wants a name they share (master, main). If a repo doesn't have it, polytree says which one and creates nothing.
Reviewing a branch someone else pushed? It already exists — locally or on origin — so new refuses rather than shadow it with a new branch off the base. Use --existing to check it out into a worktree set instead:
polytree new their-feature --existing
Pass the plain branch name, not origin/their-feature: polytree fetches first, and a branch that only exists on the remote is checked out into a local one for you.
Or point at the pull request and let polytree find the branch (needs gh). The PR's branch has to be on origin — a PR from a fork is not supported yet, and polytree says so rather than inventing an empty branch:
polytree new --pr 378 # the PR in the host repo
polytree new --pr my-web#378 # the PR in a specific repo
Qualify the repo. PR numbers are per-repo, so #378 is a different pull request in each one — polytree names the repo it resolved against every time, and never guesses beyond the host default.
What this does not do is attach the PR to Orca's card: that badge is metadata in Orca's own store, which only its UI writes, and git has no such concept. You get the branch, the worktrees and the agent — the work — just not the label. (--issue <n> does set a link, and GitHub numbers issues and PRs together, so --issue 378 will point at the PR — labelled as an issue.)
Both also take --agent <name> to override the configured agent for one run, and --prompt "..." to hand the agent its task on startup. On the orca backend, new --issue <n> links the worktrees to a GitHub issue.
All the flags
new [name] — create the set and launch the agent:
| Flag | What it does |
|---|---|
--host REPO |
Repo that hosts the agent (default: first in config) |
--base REF |
Branch off this ref instead of the configured base, e.g. --base origin/master for a hotfix. Same ref in every repo |
--existing |
Check out a branch that already exists (locally or on origin) instead of creating one |
--pr [REPO#]N |
Take the branch from a GitHub PR — implies --existing; needs gh. Qualify as repo#n, or plain n for the host repo |
--issue N |
Link the worktrees to a GitHub issue (orca backend only) |
--agent NAME |
Use this agent for the run, overriding the config |
--prompt TEXT |
Hand the agent an initial prompt on startup |
--no-launch |
Create the worktrees, don't start the agent |
--subshell / --no-subshell |
git backend: land in a shell in the worktree when the agent exits. On by default (Land in the worktree) |
link [branch] — launch the agent on an existing set (no branch = the one you're standing in):
| Flag | What it does |
|---|---|
--host REPO |
Repo that hosts the agent (default: first in config) |
--agent NAME |
Use this agent for the run, overriding the config |
--prompt TEXT |
Hand the agent an initial prompt on startup |
--subshell / --no-subshell |
git backend: land in a shell in the worktree when the agent exits (on by default) |
rm [branch] — remove the set (no branch = the one you're standing in):
| Flag | What it does |
|---|---|
--force |
Discard uncommitted changes, remove locked worktrees, and delete the branch even if unmerged |
-y, --yes |
Skip the confirmation prompt (for scripts) |
paths [branch], list, and ls take no flags of their own. Every command accepts -v/--verbose (echo each underlying git/orca command to stderr — handy when Orca misbehaves) and -h/--help; polytree --version prints the version.
Two agents on one set. Because link only attaches — it never creates — you can run it more than once. Point Claude at the set in one terminal and Codex in another, both seeing the same worktrees:
polytree new feature --no-launch # create the set once
polytree link feature --agent claude # terminal 1
polytree link feature --agent codex # terminal 2
They share the same files, so split the work (say, one per repo) to avoid stepping on each other.
new, new --existing, or link?
| The branch… | The worktrees… | Use |
|---|---|---|
| doesn't exist | — | polytree new <branch> |
| exists (someone's PR) | don't exist yet | polytree new <branch> --existing |
| exists | exist | polytree link <branch> |
link never creates anything; it only attaches the agent to worktrees that are already there.
Coming back to a set? Use
polytree link— not the agent directly. The extra repos are attached with launch-time flags (--add-dir,--mcp-config, theCLAUDE.mdenv var). Once you quit that session, they're gone. Running plainclaude(orcodex, or whatever agent) again starts a session that sees only the repo you're standing in — the siblings are not re-attached.polytree linkrebuilds the exact same launch, so the agent sees the whole set again.
What your agent actually picks up
This is the part nobody documents in one place. When a second repo is attached as an extra directory, most of its configuration is silently ignored. polytree works around what it can and is honest about the rest.
Verified empirically against Claude Code 2.1.209:
| From the attached repo | Loaded by default? | polytree's fix |
|---|---|---|
.claude/skills/ |
✅ Yes | — (works out of the box) |
CLAUDE.md |
❌ No — not at startup, not even lazily when reading its files | Sets CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 ✅ |
.mcp.json (MCP servers) |
❌ No | Adds --mcp-config <dir>/.mcp.json ✅ |
settings.json, hooks, subagents |
❌ No (per docs; not independently tested) | None possible — make that repo the host |
AGENTS.md |
❌ Claude Code reads CLAUDE.md, never AGENTS.md |
Add @AGENTS.md to that repo's CLAUDE.md, or symlink |
Codex reads AGENTS.md hierarchically across roots and needs no env var.
The practical takeaway: hooks and settings only ever come from the host repo. The host is the first repo in your config — deliberately not the directory you happen to be standing in, so this stays predictable. If that repo has no worktree for the branch, link refuses rather than silently promoting another repo (and changing which hooks load); pass --host <repo> to choose deliberately.
Backends
auto(the default) — Orca if its CLI is installed, otherwise git.polytree lsandpolytree newboth print which backend is in use.git— plaingit worktree. Nothing else required. Set this explicitly if you have Orca installed but don't want polytree to use it.orca— creates worktrees through Orca and launches the agent in an Orca-managed terminal, so the whole set shows up in the app.
Two things the Orca CLI does that polytree corrects, so both backends behave the same: it slugifies the name it is given into the branch (feature/x becomes feature-x), and it cannot check out an existing branch — it always creates a new one off the base. polytree renames the branch back to what you asked for, and for --existing puts the worktree on the real branch. Orca picks both up; only its directory name stays slugified.
If the slug lands on a branch that already exists, Orca reuses that branch — renaming it would take someone's work, so polytree refuses and tells you which branch is in the way. Asking for feature/x while a feature-x branch exists is the case to know about.
Discovery is always plain git, so polytree link, paths and rm work on worktrees created by either backend. (With the orca backend the launch goes through Orca, so the host worktree does need to be one Orca knows about.)
Finding a repo in Orca
You don't normally configure this: polytree locates each repo inside Orca by its filesystem path. And if a repo isn't registered in Orca yet, polytree new doesn't just fail — it offers to register it for you (orca repo add) and retries, so a fresh machine works after a single y.
You only pin a repo explicitly in the rarer case Orca can't match an already-registered repo by path — with an orca selector on that repo:
[[repos]]
path = "~/code/my-api"
orca = "id:8ecafcbb-8411-4a96-8049-41d08cd67544" # optional — only if the path won't resolve
The id is the repo's Orca setup id (a UUID). List every setup with its path and copy the one you need:
orca project setups --json | jq -r '.result.setups[] | "\(.path) -> id:\(.id)"'
Any agent
The requirement: the agent must take one or more extra directories on its command line. If it accepts multiple roots, polytree can drive it; if it can't, it can't be linked — that's the one thing no wrapper fixes.
Built in (no config needed):
| Agent | agent = |
How the sibling repos attach |
|---|---|---|
| Claude Code | claude |
--add-dir per repo, --mcp-config for each .mcp.json, plus CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 so their CLAUDE.md loads |
| Codex | codex |
--add-dir per repo (it reads AGENTS.md across the roots on its own) |
Pick a built-in with agent = in the config, or per run with --agent.
Adding your own agent
Any other agent works from config alone — no code changes. You describe it once under [agents.<name>], then select it with agent = "<name>" (config) or --agent <name> (one run).
Step 1 — find its "extra directory" flag. polytree launches the agent inside the host repo and hands it the other repos as extra roots, so the agent needs a command-line flag that adds a directory to its workspace. Run the agent's --help and look for one — it's usually called --add-dir, --root, --dir, --include, or --workspace. It must be repeatable (you can pass it more than once), because polytree uses it once per sibling repo. If the agent can't take extra directories at all, it can't be driven — that's the one hard limit.
Step 2 — describe it in the config. The minimum is one line, attach. For an imaginary agent acme whose flag is --root:
[agents.acme]
attach = "--root {dir}" # required: the flag that adds ONE extra directory
All the keys you can set:
| Key | Required? | What it does |
|---|---|---|
attach |
yes | The flag that adds one extra directory. {dir} is replaced with each sibling repo's absolute path |
cmd |
no | How to start the agent. Defaults to the table name (here, acme). Put base flags here, e.g. "acme --model fast" |
env |
no | Environment variables set for the run, as a table: { ACME_TELEMETRY = "0" } |
attach_if_exists |
no | Extra flags added only when a file exists in a sibling, as { "filename" = "flag {dir}" } |
How attach expands. polytree substitutes {dir} and repeats the flag once per attached repo. Launching acme on a set with two siblings runs:
acme --root /home/you/polytree/feature/web --root /home/you/polytree/feature/mobile
The host repo is the working directory — it is not attached; only the siblings are. And attach_if_exists is checked per file, per sibling: { ".mcp.json" = "--mcp-config {dir}/.mcp.json" } adds --mcp-config <repo>/.mcp.json for each sibling that actually has an .mcp.json, and nothing for those that don't.
A fuller example, using every key:
[agents.acme]
cmd = "acme --model fast" # optional: base flags for every launch
attach = "--root {dir}" # required
env = { ACME_TELEMETRY = "0" } # optional
attach_if_exists = { ".mcp.json" = "--mcp-config {dir}/.mcp.json" } # optional
Notes on the workflow
polytree handles the mechanics, not the discipline. What still helps:
- Contract first — agree on the API/schema before implementing either side.
- Two PRs, cross-linked — separate repos mean separate PRs; reference each in the other and merge in dependency order.
- The branch name is the link. Same name in every repo is what makes discovery work.
Tests
python3 -m unittest discover -s tests
They cover the failure modes rather than the happy path: partial-failure rollback, Ctrl-C mid-create, re-runs, dirty/locked/submodule refusals, detached/prunable/newline worktrees, and config validation.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file polytree-0.1.8.tar.gz.
File metadata
- Download URL: polytree-0.1.8.tar.gz
- Upload date:
- Size: 47.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f2eb48203f456840eb7813d3604c01243be3f82b3fb54de80d1d553d5e0c06e3
|
|
| MD5 |
1bc6fef6d1566fae7798ab43d90bcaaa
|
|
| BLAKE2b-256 |
400d5ca2c6627331b51aca0dbd8829aa9667bce3d6fedd0df0a3aa9a684680f6
|
File details
Details for the file polytree-0.1.8-py3-none-any.whl.
File metadata
- Download URL: polytree-0.1.8-py3-none-any.whl
- Upload date:
- Size: 26.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f958762e1812e215e233adcbe21e41bb2649b3c49c6be0fd6fa71e8df52ca6f3
|
|
| MD5 |
b6f88835508b9cbea57e416c361ca3b5
|
|
| BLAKE2b-256 |
2b979ae99405162e3a3da36473e328fcd70ae8267c5fe0a59bdf7d41fbf77eed
|