Finds local git branches that are done, meaning the remote branch is gone
and its merge/pull request is merged or closed, then renames them to
archive/<name>.
The point is to clear git branch down to work that's actually live without
deleting anything. Renaming is non-destructive: every commit stays reachable
through the renamed branch.
GitLab and GitHub are both supported. The forge is picked from the remote's URL, so no configuration is needed.
make install # uv sync + install the command globally (editable)Or without the global install:
uv sync
uv run archive-local-branches --helpmake install uses uv tool install --editable ., which puts the command on
PATH pinned to this working copy. Edits to the source take effect with no
reinstall. (uv add --editable . is a different thing: it adds a dependency
to a project, so pointing it at . asks this project to depend on itself.)
Requires git, plus the CLI for whichever forge you point it at:
| Forge | CLI | Authenticate with |
|---|---|---|
| GitLab | glab |
glab auth login --web |
| GitHub | gh |
gh auth login |
You only need the one your remote uses. There are no Python runtime dependencies. The tool shells out to these CLIs, so it reuses your existing credentials instead of asking for a token, and self-hosted instances work because each CLI already knows how to reach them.
cd ~/some/repo
archive-local-branches # dry run, report only
archive-local-branches --apply # actually rename
archive-local-branches ~/other/repo # scan a different repoDry run is the default. Nothing changes until --apply.
Repository: /Users/you/code/platform/payments-api
Remote: origin → GitLab acme/platform/data/payments-api (gitlab.com)
Skipped 12 branch(es):
5 no upstream configured, never tracked
3 remote branch still exists
2 already archived
2 protected branch
(--show-skipped for detail)
Would archive 27 branch(es):
PROJ-412_metrics-rollup → archive/PROJ-412_metrics-rollup
!89 merged
chore/add-catchup-job → archive/chore/add-catchup-job
!81 merged
...
Would clear the dangling upstream on 38 archived branch(es):
archive/PROJ-118_user-import ✂ origin/PROJ-118_user-import
archive/chore/cron-it ✂ origin/chore/cron-it
...
Summary
branches scanned 77
would archive 27
would clear upstream 38
skipped 12
Dry run. Nothing changed. Re-run with --apply to make changes.
Every branch it would change is listed with the MR that justifies it, and every
branch it left alone is accounted for in the skip breakdown. After --apply the
summary reports archived, upstream cleared, and failed counts instead.
| Flag | Effect |
|---|---|
--apply |
Perform the renames (default is dry run) |
--dry-run |
Report only. Already the default; the flag exists so scripts can say so explicitly |
--remote NAME |
Remote to treat as authoritative (default: origin if it's a supported forge, else the first that is) |
--prefix STR |
Prefix to prepend (default archive/) |
--no-fetch |
Skip git fetch --prune |
--include-no-request |
Also archive gone branches with no MR/PR at all |
--keep-upstream |
Keep the dangling upstream config instead of clearing it. Implies --no-repair |
--no-repair |
Don't clear dangling upstreams on already-archived branches |
--protect BRANCH |
Never archive this branch (repeatable) |
--show-skipped |
List every skipped branch instead of a summary |
--jobs N |
Concurrent forge lookups (default 8) |
-v / -vv |
Info / debug logging (stderr) |
All four must hold:
- An upstream is configured on the selected remote. This is what "recorded
as being there" means: git still has
branch.<name>.mergepointing at the remote. A branch that was never pushed is left alone. - The upstream is gone.
git branch -vvshows[gone], meaning the remote branch was deleted. This is only trustworthy after a prune, which is why the tool runsgit fetch --pruneby default. - The forge agrees the work is resolved. At least one MR/PR exists for the
branch, and every one of them is merged or closed. A single still-open one
blocks the branch, drafts included. A branch with no MR/PR is skipped
unless
--include-no-request. A gone branch with none is more often a hand-deleted branch than finished work. - The rename is safe. Not already prefixed, not protected
(
main/master/develop/trunkplus--protect), andarchive/<name>doesn't already exist.
Skipped branches are reported with their reason, so "why didn't it pick up X" is always answerable from the output.
Archiving also clears the branch's upstream. Once the remote branch is deleted,
that upstream is a permanently broken pointer: it clutters git branch -vv with
[gone] markers and makes git push on the branch behave confusingly.
Branches that were archived earlier, by hand or by a version of this tool predating that default, are repaired in the same pass: their dangling upstream is cleared without renaming anything. That's reported in its own section so it never hides inside the archive list.
Two cases are deliberately left alone:
- An archived branch with no upstream. Already clean.
- An archived branch whose upstream still exists on the remote. That's unusual enough that silently rewriting its config would be the wrong call, so it's reported as a skip instead.
--keep-upstream turns all of this off (and implies --no-repair, since the
two ask for opposite things). --no-repair keeps the clearing for newly
archived branches but leaves previously archived ones untouched.
The forge comes from the remote's URL: a gitlab* host uses glab, a github*
host uses gh. Substring matching means self-hosted instances
(gitlab.example.com, github.enterprise.example.com) resolve correctly.
With no --remote, origin wins if it's a supported forge, otherwise the first
remote that is. A repo with a GitLab origin and a GitHub mirror therefore uses
GitLab by default, and --remote github picks the other one.
The two forges differ in one way worth knowing. GitLab has a merged state;
GitHub does not, so a PR is open or closed and merged_at is what separates
merged work from abandoned work. Both are "resolved" for archiving purposes, and
the report tells you which:
feat/PROJ-977-migration-runner → archive/feat/PROJ-977-migration-runner
!112 merged
fix/retry-on-timeout → archive/fix/retry-on-timeout
#418 merged
- The forge is queried by the upstream branch name, not the local one. They differ when a branch was pushed under a different name, and the remote name is what the MR/PR recorded.
- GitHub lookups filter on
head=<owner>:<branch>, which scopes them to branches pushed to the repository itself. A PR opened from a fork isn't matched, which is correct here: such a branch's upstream points at the fork's remote, not this one. - Prefixing preserves the full path:
fix/PROJ-123-retrybecomesarchive/fix/PROJ-123-retry. - The current branch is archivable; git renames it in place and you stay on it.
Both mutations are reversible, and neither can lose a commit:
git branch -m archive/<name> <name> # undo a rename
git branch -u origin/<name> <name> # restore a cleared upstreamarchive_local_branches/
model.py dataclasses: LocalBranch, ChangeRequest, BranchPlan
service/git.py GitService: subprocess git, read + rename
service/forge.py Forge protocol, remote-URL parsing, forge dispatch
service/cli_forge.py CliForge: shared `<cli> api` subprocess + JSON handling
service/gitlab.py GitLabForge: `glab api` merge request lookups
service/github.py GitHubForge: `gh api` pull request lookups
operation/archive.py ArchiveOperation: plan() then apply()
cli.py argparse entry point and reporting
plan() is read-only and apply() is the only thing that mutates, which is
what makes the dry run trustworthy.
Forge is deliberately one method wide (requests_for_branch), and the planner
never sees a forge type. Both implementations sit on CliForge, because both
vendor CLIs accept the same <cli> api --hostname <host> <path> shape, so a
forge only has to supply its query and its payload mapping. A third forge would
be one more small class plus one line in forge_for_remote.
| Code | Meaning |
|---|---|
| 0 | Success, including a dry run and "nothing to do" |
| 1 | Setup problem: not a repo, no supported remote, auth failure, fetch failed |
| 2 | Applied, but one or more branches failed |
make check # fmt-check + lint-check + typecheck + test
make fix # ruff format + ruff check --fix
make testTests build real git repositories with real bare remotes rather than mocking
subprocess, so whether git reports [gone] after a prune is genuinely
exercised. Forge lookups use a fake, since the decision logic is what those
tests are about.
What a fake cannot prove is whether GitHub and GitLab still behave the way the
forge implementations assume. That is checked by hand, against a live forge,
using docs/forge-verification.md. Run it when you
touch service/*forge*.py or add a forge, not otherwise.