I found a useful Git one liner buried in leaked CIA developer docs

2026-02-2014:03708255spencer.wtf

How to delete all merged git branches locally with a single command. This one-liner has been in my zshrc since 2017 — I found it buried in the CIA's Vault7 l...

In 2017, WikiLeaks published Vault7 - a large cache of CIA hacking tools and internal documents. Buried among the exploits and surveillance tools was something far more mundane: a page of internal developer documentation with git tips and tricks.

Most of it is fairly standard stuff, amending commits, stashing changes, using bisect. But one tip has lived in my ~/.zshrc ever since.

The Problem

Over time, a local git repo accumulates stale branches. Every feature branch, hotfix, and experiment you’ve ever merged sits there doing nothing. git branch starts to look like a graveyard.

You can list merged branches with:

git branch --merged

But deleting them one by one is tedious. The CIA’s dev team has a cleaner solution:

The original command

git branch --merged | grep -v "\*\|master" | xargs -n 1 git branch -d

How it works:

  • git branch --merged — lists all local branches that have already been merged into the current branch
  • grep -v "\*\|master" — filters out the current branch (*) and master so you don’t delete either
  • xargs -n 1 git branch -d — deletes each remaining branch one at a time, safely (lowercase -d won’t touch unmerged branches)

Since most projects now use main instead of master, you can update the command and exclude any other branches you frequently use:

git branch --merged origin/main | grep -vE "^\s*(\*|main|develop)" | xargs -n 1 git branch -d

Run this from main after a deployment and your branch list goes from 40 entries back down to a handful.

I keep this as a git alias so I don’t have to remember the syntax:

alias ciaclean='git branch --merged origin/main | grep -vE "^\s*(\*|main|develop)" | xargs -n 1 git branch -d'

Then in your repo just run:

ciaclean

Small thing, but one of those commands that quietly saves a few minutes every week and keeps me organised.

You can follow me here for my latest thoughts and projects


Read the original article

Comments

  • By fphilipe 2026-02-2016:372 reply

    Here's my take on the one-liner that I use via a `git tidy` alias[1]. A few points:

    * It ensures the default branch is not deleted (main, master)

    * It does not touch the current branch

    * It does not touch the branch in a different worktree[2]

    * It also works with non-merge repos by deleting the local branches that are gone on the remote

        git branch --merged "$(git config init.defaultBranch)" \
        | grep -Fv "$(git config init.defaultBranch)" \
        | grep -vF '*' \
        | grep -vF '+' \
        | xargs git branch -d \
        && git fetch \
        && git remote prune origin \
        && git branch -v \
        | grep -F '[gone]' \
        | grep -vF '*' \
        | grep -vF '+' \
        | awk '{print $1}' \
        | xargs git branch -D
    
    
    [1]: https://github.com/fphilipe/dotfiles/blob/ba9187d7c895e44c35...

    [2]: https://git-scm.com/docs/git-worktree

    • By rubinlinux 2026-02-2019:043 reply

      The use of init.defaultBranch here is really problematic, because different repositories may use a different name for their default, and this is a global (your home directory scope) setting you have to pre-set.

      I have an alias I use called git default which works like this:

        default = !git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'
      
      then it becomes

        ..."$(git default)"...
      
      This figures out the actual default from the origin.

      • By jeffrallen 2026-02-2019:523 reply

        This is a great solution to a stupid problem.

        I work at a company that was born and grew during the master->main transition. As a result, we have a 50/50 split of main and master.

        No matter what you think about the reason for the transition, any reasonable person must admit that this was a stupid, user hostile, and needlessly complexifying change.

        I am a trainer at my company. I literally teach git. And: I have no words.

        Every time I decide to NOT explain to a new engineer why it's that way and say, "just learn that some are master, newer ones are main, there's no way to be sure" a little piece of me dies inside.

        • By lazyasciiart 2026-02-211:531 reply

          No, actually, zero people 'must admit' that it was a stupid, user hostile and needlessly complexifying change.

          I would say any reasonable person would have to agree that a company which didn't bother to set a standard for new repos once there are multiple common options is stupid, user hostile and needlessly complexifying. And if the company does have a standard for new repos, but for some reason you don't explain that to new engineers....

          • By 1718627440 2026-02-219:362 reply

            There was only a single standard before, so there was no reason why a company should make any company specific standard. The need for companies to make a standard only exists, since the master to main change, because now there are two standards.

            • By Timwi 2026-02-2110:281 reply

              I would argue there is still only one standard and it's main.

              • By 1718627440 2026-02-2110:45

                Except git init still creates branches named master without configuration (although with a warning), which will only change in 3.0 for obvious reasons, and tons of people (including me) still use master, and old projects don't just vanish.

            • By lazyasciiart 2026-02-2120:47

              Yes, and?

        • By nicoburns 2026-02-210:121 reply

          Why does your company not migrate to one standard? Github has this functionality built in, and it's also easy enough to do with just git.

          I'm personally a huge fan of the master-> main changejus5t because main is shorter to type. Might be a small win, but I checkout projects' main branches a lot.

          • By Griffinsauce 2026-02-218:041 reply

            It's extremely obvious that "main" is more ergonomic. It's sad that we're so resistant to change (regardless of how valid you think the initial trigger was)

            • By DangitBobby 2026-02-2114:58

              The miniscule (and still arguable, not obvious) "ergonomics" improvement was not and will never be worth the pain.

        • By UqWBcuFx6NV4r 2026-02-214:47

          I love the arguments where you tell me what I “must admit”. I simply don’t, therefore your entire point is meaningless. I’m sorry that something so inconsequential irked you so much. Maybe you need to read a book about git if this stumped you bad enough to want to voice your upset about it years later?

          I’d consider yourself lucky that everything else is going so well that this is what’s occupying you.

      • By suralind 2026-02-2311:41

        It's so funny. After seeing your comment I thought I must have this only to realize I already do: https://github.com/artuross/dotfiles/blob/main/home/dot_conf...

      • By fphilipe 2026-02-2019:52

        I have a global setting for that. Whenever I work in a repo that deviates from that I override it locally. I have a few other aliases that rely on the default branch, such as “switch to the default branch”. So I usually notice it quite quickly when the value is off in a particular repo.

    • By tonymet 2026-02-2019:39

      This is a good one you should contribute it to git extras.

  • By WickyNilliams 2026-02-2015:472 reply

    I have a cleanup command that integrates with fzf. It pre selects every merged branch, so I can just hit return to delete them all. But it gives me the opportunity to deselect to preserve any branches if I want. It also prunes any remote branches

        # remove merged branches (local and remote)
        cleanup = "!git branch -vv | grep ': gone]' | awk '{print $1}' | fzf --multi --sync --bind start:select-all | xargs git branch -D; git remote prune origin;"
    
    https://github.com/WickyNilliams/dotfiles/blob/c4154dd9b6980...

    I've got a few aliases that integrate with fzf like an interactive cherry pick (choose branch, choose 1 or more commits), or a branch selector with a preview panel showing commits to the side. Super useful

    The article also mentions that master has changed to main mostly, but some places use develop and other names as their primary branch. For that reason I always use a git config variable to reference such branches. In my global git config it's main. Then I override where necessary in any repo's local config eg here's an update command that updates primary and rebases the current branch on top:

        # switch to primary branch, pull, switch back, rebase
        update = !"git switch ${1:-$(git config user.primaryBranch)}; git pull; git switch -; git rebase -;"
    
    https://github.com/WickyNilliams/dotfiles/blob/c4154dd9b6980...

    • By lloeki 2026-02-2016:141 reply

      > For that reason I always use a git config variable to reference such branches. In my global git config it's main

          $(git config user.primaryBranch)
      
      What about using git's own `init.defaultBranch`?

      I mean, while useless in terms of `git init` because the repo's already init'd, this works:

          git config --local init.defaultBranch main
      
      And if you have `init.defaultBranch` set up already globally for `git init` then it all just works

      • By WickyNilliams 2026-02-2016:281 reply

        Hmm that might be nice actually. I like not conflating those two things, but as you say if the repo is already init'd then there's no chance it'll be used for the wrong purpose.

        In any case the main thrust was just to avoid embeddings assumptions about branch names in your scripts :)

        • By lloeki 2026-02-219:491 reply

          > I like not conflating those two things

          Fair enough!

          It simply occurred to me that if your `user.defaultBranch` is set to e.g `trunk` then presumably when you `git init` you also want it to create a `trunk` branch, ergo `init.defaultBranch` would be set to the same value, ergo... irrespective of naming, could they actually be the same thing?

          I can see a case for keeping them apart too though.

          • By WickyNilliams 2026-02-2111:59

            That's a good point, I think I will update my scripts to use this instead :)

    • By MathiasPius 2026-02-2016:154 reply

      You can pull another branch without switching first:

        git switch my-test-branch
        ...
        git pull origin main:main
        git rebase main

      • By hiccuphippo 2026-02-2017:161 reply

        You can also rebase directly on the remote branch

            git fetch
            git rebase origin/main

        • By 1718627440 2026-02-2213:49

          > git rebase origin/main

          When is that command actually useful? When you want to rebase it is likely because your local and the upstream branch have diverged, so this would just result in weird conflicts, because origin/main is no longer an ancestor to main. Wouldn't you want to use something like:

              git rebase $(git merge-base main origin/main) main --onto=origin/main
          
          or

              git rebase origin/main@{1} main --onto=origin/main
          
          ?

      • By WickyNilliams 2026-02-2016:30

        Nice. That'll make things a bit smoother. Changing branches often trips me up when I would later `git switch -`.

      • By mroche 2026-02-2017:09

        Likewise with the other way around, just switch pull with push.

      • By huntervang 2026-02-2016:46

        I have always done `git pull origin main -r`

  • By jakub_g 2026-02-2014:487 reply

    The main issue with `git branch --merged` is that if the repo enforces squash merges, it obviously won't work, because SHA of squash-merged commit in main != SHA of the original branch HEAD.

    What tools are the best to do the equivalent but for squash-merged branches detections?

    Note: this problem is harder than it seems to do safely, because e.g. I can have a branch `foo` locally that was squash-merged on remote, but before it happened, I might have added a few more commits locally and forgot to push. So naively deleting `foo` locally may make me lose data.

    • By samhclark 2026-02-2015:52

      Depends on your workflow, I guess. I don't need to handle that case you noted and we delete the branch on remote after it's merged. So, it's good enough for me to delete my local branch if the upstream branch is gone. This is the alias I use for that, which I picked up from HN.

          # ~/.gitconfig
          [alias]
              gone = ! "git fetch -p && git for-each-ref --format '%(refname:short) %(upstream:track)' | awk '$2 == \"[gone]\" {print $1}' | xargs -r git branch -D"
      
      Then you just `git gone` every once in a while, when you're between features.

    • By masklinn 2026-02-2015:271 reply

      Not just squash merges, rebase-merges also don't work.

      > What tools are the best to do the equivalent but for squash-merged branches detections?

      Hooking on remote branch deletion is what most people do, under the assumption that you tend to clean out the branches of your PRs after a while. But of course if you don't do that it doesn't work.

      • By Anamon 2026-02-2223:171 reply

        > Not just squash merges, rebase-merges also don't work.

        Are you sure? I almost exclusively rebase-merge, and I use ‘git branch --merged‘ all the time. It works perfectly fine for me.

        Also conceptually it seems to make sense to me: you rebase your commits onto the tip of the target branch, so you can trivially follow the link from the tip of your source branch to the tip of your target branch, which as I understand it is what the command checks for.

        • By masklinn 2026-02-235:19

          Sounds like you update the source after rebasing? Because if you rebase then push on the target git sees no more relation between the two than if you squash.

    • By otsaloma 2026-02-2018:57

      I recently revised my script to rely on (1) no commits in the last 30 days and (2) branch not found on origin. This is obviously not perfect, but it's good enough for me and just in case, my script prompts to confirm before deleting each branch, although most of the time I just blindly hit yes.

      To avoid losing any work, I have a habit of never keeping branches local-only for long. Additionally this relies on https://docs.github.com/en/repositories/configuring-branches...

    • By laksdjf 2026-02-2017:141 reply

      I have the same issue. Changes get pushed to gerrit and rebased on the server. This is what I have, though not perfected yet.

        prunable = "!f() { \
        : git log ; \
        target=\"$1\"; \
        [ -z \"$target\" ] && target=$(git for-each-ref --format=\"%(refname:short)\" --count=1 refs/remotes/m/); \
        if [ -z \"$target\" ]; then echo \"No remote branches found in refs/remotes/m/\"; return 1; fi; \
        echo \"# git branch --merged shows merged if same commit ID only\" ;\
        echo \"# if rebased, git cherry can show branch HEAD is merged\"  ;\
        echo \"# git log grep will check latest commit subject only.  if amended, this status won't be accurate\" ;\
        echo \"# Comparing against $target...\"; \
        echo \"# git branch --merged:\"; \
        git branch --merged $target  ;\
        echo \" ,- git cherry\" ; \
        echo \" |  ,- git log grep latest message\"; \
        for branch in $(git for-each-ref --format='%(refname:short)' refs/heads/); do \
         if git cherry \"$target\" \"$branch\" | tail -n 1 | grep -q \"^-\"; then \
          cr=""; \
         else \
          cr=""; \
         fi ; \
         c=$(git rev-parse --short $branch) ; \
         subject=$(git log -1 --format=%s \"$branch\" | sed 's/[][(){}.^$\*+?|\\/]/\\\\&/g') ; \
         if git log --grep=\"^$subject$\" --oneline \"$target\" | grep -q .; then \
          printf \"$cr  $c %-20s $subject\\n\"  $branch; \
         else \
          printf \"$cr  \\033[0;33m$c \\033[0;32m%-20s\\033[0m $subject\\n\"  $branch; \
         fi; \
        done; \
        }; f"
      
      (some emojis missing in above. see gist) https://gist.github.com/lawm/8087252b4372759b2fe3b4052bf7e45...

      It prints the results of 3 methods:

      1. git branch --merged

      2. git cherry

      3. grep upstream git log for a commit with the same commit subject

      Has some caveats, like if upstream's commit was amended or the actual code change is different, it can have a false positive, or if there are multiple commits on your local branch, only the top commit is checked

      • By arccy 2026-02-2018:14

        if you're using gerrit then you have the Change-Id trailer you can match against?

    • By WorldMaker 2026-02-2015:49

      This is my PowerShell variant for squash merge repos:

          function Rename-GitBranches {
              git branch --list "my-branch-prefix/*" | Out-GridView -Title "Branches to Zoo?" -OutputMode Multiple | % { git branch -m $_.Trim() "zoo/$($_.Trim())" }
          }
      
      `Out-GridView` gives a very simple dialog box to (multi) select branch names I want to mark finished.

      I'm a branch hoarder in a squash merge repo and just prepend a `zoo/` prefix. `zoo/` generally sorts to the bottom of branch lists and I can collapse it as a folder in many UIs. I have found this useful in several ways:

      1) It makes `git rebase --interactive` much easier when working with stacked branches by taking advantage of `--update-refs`. Merges do all that work for you by finding their common base/ancestor. Squash merging you have to remember which commits already merged to drop from your branch. With `--update-refs` if I find it trying to update a `zoo/` branch I know I can drop/delete every commit up to that update-ref line and also delete the update-ref.

      2) I sometimes do want to find code in intermediate commits that never made it into the squashed version. Maybe I tried an experiment in a commit in a branch, then deleted that experiment in switching directions in a later commit. Squashing removes all evidence of that deleted experiment, but I can still find it if I remember the `zoo/` branch name.

      All this extra work for things that merge commits gives you for free/simpler just makes me dislike squash merging repos more.

    • By de46le 2026-02-2017:31

      Mine's this-ish (nushell, but easily bashified or pwshd) for finding all merged, including squashed:

          let t = "origin/dev"; git for-each-ref refs/heads/ --format="%(refname:short)" | lines | where {|b| $b !~ 'dev' and (git merge-tree --write-tree $t $b | lines | first) == (git rev-parse $"($t)^{tree}") }
      
      Does a 3-way in-mem merge against (in my case) dev. If there's code in the branch that isn't in the target it won't show up.

      Pipe right to deletion if brave, or to a choice-thingy if prudent :)

    • By rtpg 2026-02-2023:37

      What if you first attempted rebases of your branches? Then you would detect empty branches

HackerNews