Skip to main content

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, like Fix-typo and fix-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, the CLAUDE.md env var). Once you quit that session, they're gone. Running plain claude (or codex, or whatever agent) again starts a session that sees only the repo you're standing in — the siblings are not re-attached. polytree link rebuilds 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 ls and polytree new both print which backend is in use.
  • git — plain git 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

polytree-0.1.8.tar.gz (47.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

polytree-0.1.8-py3-none-any.whl (26.1 kB view details)

Uploaded Python 3

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

Hashes for polytree-0.1.8.tar.gz
Algorithm Hash digest
SHA256 f2eb48203f456840eb7813d3604c01243be3f82b3fb54de80d1d553d5e0c06e3
MD5 1bc6fef6d1566fae7798ab43d90bcaaa
BLAKE2b-256 400d5ca2c6627331b51aca0dbd8829aa9667bce3d6fedd0df0a3aa9a684680f6

See more details on using hashes here.

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

Hashes for polytree-0.1.8-py3-none-any.whl
Algorithm Hash digest
SHA256 f958762e1812e215e233adcbe21e41bb2649b3c49c6be0fd6fa71e8df52ca6f3
MD5 b6f88835508b9cbea57e416c361ca3b5
BLAKE2b-256 2b979ae99405162e3a3da36473e328fcd70ae8267c5fe0a59bdf7d41fbf77eed

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page