Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ TARGET := $(BINDIR)/$(BINARY)

build: $(TARGET)

$(TARGET): main.go go.mod
$(TARGET): $(wildcard *.go) go.mod
@mkdir -p $(BINDIR)
go build -o $(TARGET) .

Expand Down
88 changes: 82 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ git_pruner version # also --version, -v
| -------------- | ------------------------------------------------------------- |
| `↑`/`k`, `↓`/`j` | Move cursor |
| `g` / `G` | Jump to top / bottom |
| `space` | Toggle selection (the current branch cannot be selected) |
| `space` | Toggle selection (locked branches cannot be selected — see [Protected branches](#protected-branches)) |
| `a` / `n` | Select all listed branches / clear selection |
| `c` | Checkout the branch under the cursor (`git switch`) |
| `m` | Select listed branches that are merged and older than N days (prompts for N) |
| `tab` | Switch between local branches and [remote-only branches](#remote-only-branches) |
| `c` | Checkout the branch under the cursor (`git switch`); on a remote-only row, create a local branch that tracks it |
| `/` | Filter branches by name; `enter` keeps it, `esc` clears it |
| `r` | Toggle "also delete remote" for the row (needs an upstream) |
| `v` | View the branch's diff (through [delta](https://github.com/dandavison/delta) when installed) |
Expand All @@ -56,6 +58,7 @@ git_pruner version # also --version, -v
| `s` | Cycle sort field: committerdate -> name -> ahead/behind |
| `o` | Reverse sort direction |
| `f` | Toggle delete mode: safe `-d` <-> force `-D` |
| `u` | Undo: recreate the local branches the last delete removed |
| `d` / `enter` | Go to the confirmation screen |
| `?` | Help screen (build metadata, keybindings, column guide) |
| `q` / `ctrl+c` | Quit |
Expand All @@ -68,7 +71,19 @@ delete (`-d`) because it isn't fully merged, a follow-up prompt offers to force
just those branches — `y` discards their unmerged commits, `n`/`esc` keeps them.

Deletions run in the background with a live progress screen (a spinner plus a per-branch
checklist), so the UI stays responsive while remote pushes complete.
checklist), so the UI stays responsive while remote pushes complete. `ctrl+c` there asks for a
second press: quitting mid-run can leave a remote branch behind a deleted local one.

On the results screen, `u` recreates the deleted local branches at their old commits, with
their upstream config. `enter` returns to the list, and `q` quits. When you quit, git_pruner
prints a restore command for every branch it deleted this session, so the way back stays in your
scrollback:

```
git_pruner: to restore a deleted branch, run:
git branch feature/foo 3e2210a…
git push origin 3e2210a…:refs/heads/feature/foo
```

In the diff view: `↑`/`↓` scroll, `space`/`ctrl+d` page down, `ctrl+u`/`pgup` page up,
`g`/`G` jump to top/bottom, and `q`/`esc`/`v` return to the list.
Expand All @@ -85,11 +100,13 @@ precedence, so `y`, `R` and `n` still work while a list is scrolled.
> [x] R * feature/foo ↑2↓1 ✓ 3 days ago a1b2c3d Fix the thing
```

- `>` cursor, `[x]` selected, `R` remote deletion armed, `*` current branch
- `>` cursor, `[x]` selected, `R` remote deletion armed
- `*` current branch, `+` checked out in another worktree, `P` protected — all three are locked
- ahead/behind shown as `↑N↓M` (`=` when in sync, `gone` in red when the upstream was deleted)
- a green `✓` after the track column means the upstream is merged into the remote default
branch — i.e. the remote is safe to delete
- relative commit date, short hash, and commit subject
- relative commit date, short hash, and commit subject. On a narrow terminal the relative date,
then the hash, then the date drop out, so a row never wraps

## Viewing a branch's changes

Expand Down Expand Up @@ -157,10 +174,66 @@ claiming the work is unrecoverable.
Deletions then run concurrently in the background on a live progress screen, and a results
screen reports per-branch success or failure.

## Selecting merged, old branches

Press `m`, type an age in days (it starts at 90, or at `pruner.staleDays`), and press `enter`.
git_pruner selects every listed branch that is merged into the default branch and whose last
commit is older than that. A local branch counts as merged when its tip is in the default
branch, or when its upstream is merged and it has no commits of its own on top. Like `a`, it only
acts on the listed rows, so a `/` filter narrows it.

## Remote-only branches

Press `tab` to list the remote branches that no local branch tracks — work other people pushed,
or branches you deleted locally but not on the remote. They are read on first use, so startup
does not pay for them. A `✓` means the branch is merged into the remote default; the
confirmation screen counts the commits of any branch that is not.

Deleting one is a push, so only `R` does it; `y` never touches a remote. The exit summary
prints the `git push` command that puts a deleted remote branch back.

## Protected branches

The default branch is always protected. Add your own with name globs:

```sh
git config --add pruner.protect 'release/*'
git config --add pruner.protect develop
```

A protected branch shows `P` and cannot be selected by any key. Remote rows are matched by the
branch part of their name, so `release/*` covers `origin/release/1.0` too.

## Script mode

The same selection rules work without the screen, for cron jobs or shell aliases:

```sh
git_pruner --prune-gone # list what would be deleted
git_pruner --fetch --prune-gone --yes # fetch, then delete
git_pruner --merged-older-than 90 --yes # delete merged branches older than 90 days
```

Nothing is deleted without `--yes` (`--dry-run` forces a listing even with it). Script mode never
deletes remote branches, and never deletes a gone branch that holds commits missing from the
default branch. It prints restore commands for what it deleted, and exits 1 if any delete failed.

## Network calls

`git fetch` and `git push --delete` run with `GIT_TERMINAL_PROMPT=0` and without a terminal, and
stop after 60 seconds. A remote that asks for a password or does not answer fails with a message,
instead of freezing the screen. Use a credential helper or an ssh agent for remotes that need a
login.

## Settings

The sort field and order are saved in `~/Library/Application Support/git_pruner/settings`
(macOS) or `~/.config/git_pruner/settings` (Linux), and restored on the next start.

## Development

```sh
make build # build straight to $BINDIR (default ~/shared/bin), skipping install.sh
make build # build straight to $BINDIR (default ~/shared/bin; override with BINDIR=...)
make test # go test ./...
make vet # go vet ./...
make clean # remove the binary from $BINDIR
Expand All @@ -181,6 +254,9 @@ would look mismatched if the width or theme drifted between them.
[`docs/improvements.md`](docs/improvements.md) records the codebase analysis, the reasoning behind
the current safety behavior, and the roadmap of remaining work.

The code is split by layer: `git.go` (every git call), `model.go` (state and key handling),
`view.go` (rendering), `cli.go` (script mode), `settings.go`, and `main.go`.

The test suite drives a real `git` binary against throwaway repositories created per test, so it
needs `git` on `PATH` and a committer identity (`user.name` / `user.email`); the tests set one
inside each temporary repo.
Expand Down
2 changes: 1 addition & 1 deletion assets/screenshot.tape
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Source "assets/common.tape"
Type "git_pruner"
Enter
Sleep 3s
# `g` first: on launch the cursor sits on whatever branch git listed first.
# `g` is kept for older builds, where the cursor did not start on the first row.
Type "g"
Sleep 300ms
Type "jjj"
Expand Down
113 changes: 113 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package main

import (
"flag"
"fmt"
"io"
"strings"
"sync"
"time"
)

// runCLI is script mode: the TUI's selection rules, without the TUI. It only
// deletes with --yes, so a cron line or a typo cannot delete by accident. It
// never deletes remote branches: that stays a deliberate key in the TUI.
// Returns the exit code.
func runCLI(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("git_pruner", flag.ContinueOnError)
fs.SetOutput(stderr)
gone := fs.Bool("prune-gone", false, "select gone branches that hold no commits missing from the default branch")
olderThan := fs.Int("merged-older-than", -1, "select merged branches whose last commit is older than `DAYS`")
fetch := fs.Bool("fetch", false, "run git fetch --all --prune first")
yes := fs.Bool("yes", false, "delete the selected branches (without it, only list them)")
dryRun := fs.Bool("dry-run", false, "only list what would be deleted, even with --yes")
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: git_pruner [--fetch] (--prune-gone | --merged-older-than DAYS)... [--yes | --dry-run]")
fmt.Fprintln(stderr, " git_pruner start the interactive screen")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 || (!*gone && *olderThan < 0) {
fs.Usage()
return 2
}

if *fetch {
if _, err := runGit("fetch", "--all", "--prune"); err != nil {
fmt.Fprintln(stderr, "git_pruner: fetch:", err)
return 1
}
}
m, err := initialModel()
if err != nil {
fmt.Fprintln(stderr, "git_pruner:", err)
return 1
}
if *gone {
fmt.Fprintln(stderr, m.selectGone())
}
if *olderThan >= 0 {
fmt.Fprintln(stderr, m.selectMergedOlder(*olderThan, time.Now()))
}
m.measureSelectedRisk()
sel := m.selectedBranches()
if len(sel) == 0 {
fmt.Fprintln(stdout, "nothing to delete")
return 0
}

if *dryRun || !*yes {
for _, b := range sel {
line := "would delete " + b.name + " (" + b.deleteFlag(false) + ")"
if w := m.riskWarning(b); w != "" {
line += " " + w
}
fmt.Fprintln(stdout, line)
}
if !*dryRun {
fmt.Fprintln(stderr, "rerun with --yes to delete")
}
return 0
}

// runGit caps the concurrency, so one goroutine per branch is safe.
results := make([]deleteResult, len(sel))
var wg sync.WaitGroup
for i, b := range sel {
wg.Go(func() { results[i] = deleteBranch(b, b.deleteFlag(false), false) })
}
wg.Wait()

code := 0
for _, r := range results {
if r.localOK {
fmt.Fprintf(stdout, "deleted %s (was %s)\n", r.br.name, r.br.hash)
} else {
fmt.Fprintf(stdout, "failed %s: %s\n", r.br.name, r.localErr)
code = 1
}
}
fmt.Fprint(stderr, restoreSummary(results))
return code
}

// restoreSummary lists a command that brings back each branch deleted this
// session. The TUI prints it on quit and script mode after a run, so the way
// back stays in the terminal's scrollback after the screen is gone.
func restoreSummary(results []deleteResult) string {
var lines []string
for _, r := range results {
if r.restorable() {
lines = append(lines, fmt.Sprintf(" git branch %s %s", r.br.name, r.br.sha))
}
if r.remoteOK && r.br.sha != "" {
lines = append(lines, fmt.Sprintf(" git push %s %s:refs/heads/%s", r.br.remoteName(), r.br.sha, r.br.remoteBranch()))
}
}
if len(lines) == 0 {
return ""
}
return "git_pruner: to restore a deleted branch, run:\n" + strings.Join(lines, "\n") + "\n"
}
Loading
Loading