Skip to content

About

Rename local git branches to archive/* once their merge or pull request is resolved and the remote branch is gone

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

archive-local-branches

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.

Install

make install          # uv sync + install the command globally (editable)

Or without the global install:

uv sync
uv run archive-local-branches --help

make 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.

Usage

cd ~/some/repo
archive-local-branches                 # dry run, report only
archive-local-branches --apply         # actually rename
archive-local-branches ~/other/repo    # scan a different repo

Dry 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.

Options

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)

How a branch qualifies

All four must hold:

  1. An upstream is configured on the selected remote. This is what "recorded as being there" means: git still has branch.<name>.merge pointing at the remote. A branch that was never pushed is left alone.
  2. The upstream is gone. git branch -vv shows [gone], meaning the remote branch was deleted. This is only trustworthy after a prune, which is why the tool runs git fetch --prune by default.
  3. 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.
  4. The rename is safe. Not already prefixed, not protected (main/master/develop/trunk plus --protect), and archive/<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.

Clearing dangling upstreams

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.

Choosing a forge

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

Notes

  • 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-retry becomes archive/fix/PROJ-123-retry.
  • The current branch is archivable; git renames it in place and you stay on it.

Undo

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 upstream

Architecture

archive_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.

Exit codes

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

Development

make check    # fmt-check + lint-check + typecheck + test
make fix      # ruff format + ruff check --fix
make test

Tests 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.

About

Rename local git branches to archive/* once their merge or pull request is resolved and the remote branch is gone

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages