copse
One folder per task, one git worktree per repo. Run it from any directory with repos 1-2 levels deep.
$ copse new 301
◇ Repos api, libs/auth-service-with-long-name
◇ Branch 301
◇ Start new branches from remote
╭─ Plan ───────────────────────────────────────────────╮
│ api 301 ← origin/main │
│ libs/auth-service-with-long-name 301 ← main │
╰──────────────────────────────────────────────────────╯
◆ Create 2 worktrees? Yes
✔ api 301 tasks/301/api
✔ libs/auth-service-with-long-name 301 tasks/301/auth-service-with-long-name
What it does
- Finds repos on its own. It scans 1-2 levels below the current directory for folders with a
.gitdirectory, skipping dot-folders,node_modulesand existing worktrees. - Sets branch and base per repo with
-r repo[:branch[:base]]. Branch defaults to the task name. - Starts new branches from
origin/<base>(the default) or from your local<base>with--from local, and fetches first unless you pass--no-fetch. - Reuses a local branch if it exists, and tracks a remote-only branch instead of cutting a new one.
- Shows every worktree's branch, dirty state and ahead/behind counts with
copse ls. - Removes worktrees with
copse rm, and deletes a branch only if copse created it for that task. - Speaks JSON and exit codes for scripts. Without
-rand without a TTY it exits 2 instead of prompting, and git never asks for credentials.
Quick start
uv tool install copse # or, from a clone: uv tool install -e .
cd ~/code # a folder that contains your repos
copse repos # see what it found
copse new 200 -r api -r web # tasks/200/api and tasks/200/web
copse ls 200 # branch, clean or dirty, ahead/behind
copse rm 200 --delete-branch # clean up when the task is done
Run copse new 200 on a terminal with no -r to pick repos interactively.
Layout
~/code/
├── api/ # repos you already have
├── web/
├── libs/
│ └── auth-service/
└── tasks/
└── 200/
├── api/ # worktree on branch 200
└── web/
Worktrees go in ./tasks/<task>/. Change that with --root or the COPSE_ROOT environment variable. A nested repo is named by its last path part (auth-service), or libs-auth-service if another repo has the same folder name.
Commands
| Command | What it does |
|---|---|
copse repos |
List detected repos with their current branch. |
copse new TASK |
Create one worktree per repo under the task folder. |
copse ls [TASK] |
Show worktrees per task: branch, dirty, ahead/behind. |
copse rm TASK |
Remove a task's worktrees, all or only the -r repos. |
copse new options:
| Flag | Default | Meaning |
|---|---|---|
-r, --repo SPEC |
interactive pick | Repo to include. Repeat for more. |
-b, --branch |
task name | Branch for every repo without its own. |
--base |
<remote>/HEAD, else current branch |
Base for new branches. |
--from remote|local |
remote |
Cut new branches from <remote>/<base> or from local <base>. |
--remote |
origin |
Remote to fetch and track. |
--no-fetch |
off | Skip git fetch. |
--dry-run |
off | Plan only, create nothing. |
--root |
./tasks ($COPSE_ROOT) |
Where task folders live. |
--json |
off | Machine-readable output. |
A SPEC is repo[:branch[:base]]. repo is the id from copse repos or just the folder name if that is unique.
copse new 200 -r api # branch 200, default base
copse new 200 -r api:feat/200:develop # own branch and base
copse new 200 --from local -r web:x:origin/main # local start, but this repo uses origin/main
How branches are picked
For each repo, the first rule that matches wins.
- A local branch with that name exists. It is reused.
<remote>/<branch>exists. A local branch is created that tracks it.- Otherwise a new branch is cut from the base. A base written as
origin/xalways uses that remote ref, even with--from local. Otherwise--from remoteusesorigin/<base>when it exists and--from localuses<base>. A sha or tag also works.
With no base given, copse uses what <remote>/HEAD points at, or the repo's current branch if there is none. Rerunning new marks worktrees that already exist as exists.
For agents and scripts
Add --json to repos, new, ls or rm. new and rm print:
{
"task": "200",
"path": "/home/me/code/tasks/200",
"results": [
{"repo": "api", "path": ".../tasks/200/api", "branch": "200", "base": "main",
"status": "created", "error": null}
]
}
status is created, exists, planned (dry run), removed or failed. Dry runs also include start, the ref the new branch would be cut from.
| Code | Meaning |
|---|---|
| 0 | Everything worked. |
| 1 | At least one repo failed. The others still ran. |
| 2 | Usage error, no -r without a TTY, or a cancelled prompt. Nothing was created. |
Output is colored only on a TTY. Pipes, --json and NO_COLOR get plain text, and errors go to stderr as error: ....
copse new 200 -r api -r web:feat/200:develop --json | jq '.results[] | {repo,status,error}'
Removing tasks safely
copse rm 200 --delete-branch removes the worktrees, then deletes each branch only if copse created it for that task. Branches copse created carry a branch.<name>.copseTask git config entry. Branches you attached are kept and reported as branch_kept in JSON. Deletion uses git branch -d, so a branch with unmerged commits survives unless you add --force, which also forces worktree removal and uses -D.
Why copse
It was called twt for about a day, and I didn't like it. A copse is a small group of trees, which is what a task is here: a handful of worktrees standing together in one folder, one per repo.
Development
uv sync
uv run pytest
License
MIT, see LICENSE. NOTICE credits the projects some code was adapted from.
Metadata
Release files for copse 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| copse-0.1.0.tar.gz | 15.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| copse-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 27.2 kB
Release files / copse-0.1.0.tar.gz
| Download URL | copse-0.1.0.tar.gz |
|---|---|
| Size | 15.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cf3db3735b17823d7af3f1eb766f2cf438875a390f4627f5eb775a0c83f6161f
|
|
BLAKE2b-256 checksum How to use checksums |
a80370201474239567849f325597a4870f6b77de3a6eeec447f12144509ed2e6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.
Transparency logRelease files / copse-0.1.0-py3-none-any.whl
| Download URL | copse-0.1.0-py3-none-any.whl |
|---|---|
| Size | 11.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
24edc9ac415f158cc853d8771495c9927c596a270c7666361f119ad990aa2e89
|
|
BLAKE2b-256 checksum How to use checksums |
5bf8b5d23d6853d3a044157c43e0127d4938ac52187e91195e4a5bdf52a6a7c5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.
Transparency log