Skip to content

v0.4.0: stale branch detection and pruning - #4

Open
sven42xyz wants to merge 10 commits into
mainfrom
feature_stale-branches
Open

v0.4.0: stale branch detection and pruning#4
sven42xyz wants to merge 10 commits into
mainfrom
feature_stale-branches

Conversation

@sven42xyz

@sven42xyz sven42xyz commented May 26, 2026

Copy link
Copy Markdown
Owner

Closes #3.

What

New stale subcommand that lists local branches likely safe to clean up across all scanned repos, with optional pruning.

Three detection categories:

Tag Detection
gone Upstream tracking ref no longer exists on the remote
merged Branch tip is reachable from the default branch (regular / FF merge)
squash Branch's cumulative diff matches a single default-branch commit (Squash & merge)

Surface

gitls stale [DIR]
gitls stale --prune [--yes] [--older-than 30d] [DIR]

Plus ~/.gitlsrc:

protected_branches=develop,staging

Safety

  • Current HEAD branch and the default branch are always excluded.
  • main and master are implicitly protected even when not the resolved default.
  • gone branches are re-verified against the default branch on prune. Local-only commits not reachable from default cause deletion to be refused, not silently deleted.
  • merged and squash branches are safe by construction.

Implementation highlights

  • Detection lives in phase-1 worker threads (libgit2-only, no fork).
  • Squash detection uses git_diff_patchid: walk the default branch (cap 500 commits), build a patch-id set, then compare each candidate branch's cumulative diff vs merge-base. Branches with unmerged work past the squash point keep a unique patch-id and correctly do not match.
  • Prune runs sequentially via git_branch_delete from the main thread after user confirmation.
  • Duration parser accepts s/h/d/w/m/y suffixes.

Tests

  • Unit: 22 passing (unchanged)
  • Integration: +16 new tests covering list (none/merged/gone/squash + negative), invalid combos, --prune (delete merged/squash, refuse gone+unmerged), --older-than (recent filtered, old retained, invalid rejected), protected_branches config, implicit main/master protection. 52 total.

Docs

  • README: dedicated "Stale branches" section, pruning safety rules, duration suffix table, protected_branches in config table.
  • gitlsrc.example: new protected_branches entry.
  • --help: subcommand + dedicated "Stale options" block.

Summary by CodeRabbit

  • New Features

    • Added stale subcommand to identify/manage local branches (gone, merged, squash-merged)
    • --prune to remove eligible branches, --yes/-y to skip prompts, --older-than age filter
    • protected_branches config (defaults include main/master)
  • Documentation

    • Updated usage/help, examples, pruning safety rules, and duration suffix docs
  • Tests

    • Expanded integration and unit tests covering detection, pruning, filters, and config handling

Review Change Stack

sven42xyz and others added 8 commits May 26, 2026 20:49
Detects two classes of stale local branches via libgit2 in phase 1:
  gone   — upstream tracking ref no longer exists
  merged — fully reachable from the repo's default branch (main/master)

Output is a per-repo grouped listing; --prune and age filter follow
in subsequent commits (see #3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
--prune deletes the listed stale branches via libgit2's git_branch_delete.
--yes skips the confirmation prompt.

Safety:
 * STR_MERGED branches are always safe to delete (already verified merged).
 * STR_GONE branches are re-checked against the default branch tip; if
   they carry local commits not reachable from default, deletion is
   refused with a clear "unmerged local commits" marker.

Per-branch outcome (deleted/refused/error) is rendered by display.c via
the new print_prune_results() function, followed by a summary line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Covers:
 * no stale branches → "No stale branches found"
 * merged branch listed
 * gone-upstream branch listed (uses bare+clone+push-delete)
 * invalid combinations rejected (stale + -s, --prune without stale)
 * --prune --yes deletes merged branch
 * --prune --yes refuses gone+unmerged (data-loss guard)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A new reason STR_SQUASHED is reported when a branch's cumulative diff
(merge-base → tip) matches the patch-id of any commit on the default
branch. This catches the common GitHub/GitLab "Squash & merge" flow
where the per-branch commits collapse into a single mainline commit
and git_graph_descendant_of can no longer recognise the branch as
merged.

For each repo we walk up to 500 commits of the default branch once,
compute their first-parent patch-ids, and compare each candidate
branch's single cumulative patch-id against that set. Branches with
unmerged work past the squash point keep their unique patch-id and
correctly do not match.

Squashed branches are treated as safe to delete in --prune.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A new ~/.gitlsrc key 'protected_branches=develop,staging,...' lists
local branch names that must never be flagged by 'stale' detection,
regardless of merge or upstream status. Parsing mirrors skip_dirs
(comma-separated, cap of 64).

Additionally main and master are always implicitly protected. The
existing default-branch and current-HEAD skips already covered the
common case, but a repo using master as its default could still have
a separate 'main' branch flagged after a non-FF merge into master.
Treating both names as reserved closes that gap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Restricts the listing (and therefore --prune) to branches whose tip
commit is older than the given duration. Useful for "list stale
branches that have been dormant for at least a month" workflows
where recently-merged branches may still be active locally.

Duration suffixes: s, h, d, w, m (≈30d), y (≈365d). Examples:
  gitls stale --older-than 30d
  gitls stale --prune --yes --older-than 6m

The filter applies before classification, so no patch-id work is
done for branches that are too young to be candidates anyway.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a dedicated "Stale branches" section to the README covering the
three detection categories (gone / merged / squash), the safety rules
applied by --prune (current HEAD, default branch, main/master and
protected_branches always excluded; gone+unmerged refused), and the
--older-than age filter with its supported duration suffixes.

Also wires protected_branches into the Config table and the example
gitlsrc, and brings the synopsis / examples in line with the new
subcommand surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR adds a stale subcommand that detects and optionally deletes local branches classified as gone, merged, or squash-merged, with configurable branch protection and age-based filtering. The implementation spans type definitions, configuration loading, core stale-branch classification, CLI wiring, display formatting, and comprehensive integration tests.

Changes

Stale Branch Detection and Pruning

Layer / File(s) Summary
Type definitions and public contracts
gitools.h
New StaleReason enum (gone/merged/squashed), StaleAction enum (deleted/refused/error), StaleBranch struct containing name and classification. Repo extended with stale pointer, stale_count, and default_branch buffer. Global externs for options (opt_stale, opt_prune, opt_yes, opt_older_than_secs, opt_protected_branches) and function prototypes (prune_stale_branches, print_stale_summary, print_prune_results).
Configuration and documentation
README.md, gitlsrc.example
README documents stale subcommand with usage examples, detection categories, safety rules, and --older-than filtering. Example config file and README config table updated with protected_branches setting.
Protected branches configuration loading
config.c
Extends load_config() to parse protected_branches as comma-separated list (max 64 entries), allocates dynamic string array, frees prior allocation, and updates global pointers with proper error handling.
Stale-branch classification and detection
repo.c
Adds fill_stale_branches() to classify local branches: computes patch-ids on recent default-branch commits to detect squash-merged branches; checks upstream tracking refs for gone branches; tests reachability for merged branches. Stores results as STR_GONE, STR_MERGED, or STR_SQUASHED per branch. Integrates into phase-1 local processing.
Branch pruning and safety verification
repo.c
Implements prune_stale_branches() to reopen repos, re-verify deletability (re-checking reachability for gone branches to reject branches with local-only commits), delete eligible branches, and report aggregate counts via print_prune_results().
Stale detection and prune result formatting
display.c
New print_stale_summary() iterates repos with stale branches, prints per-repo headers, lists branches with reason-based colors/labels and per-reason tallies. New print_prune_results() reports per-action outcomes (deleted/refused/error) with explanatory note for unmerged branches.
CLI option parsing and subcommand recognition
main.c
Adds global option variables for stale state. Recognizes stale subcommand, skips --older-than during subcommand detection. Parses --prune, --yes/-y, and --older-than <DUR> with duration validation via parse_duration(). Updates usage text to document stale and new options.
Validation and mutual-exclusivity rules
main.c
Enforces stale cannot combine with fetch/pull/-s, requires stale for --prune/--yes/--older-than flags, updates spinner label for stale operations.
Execution path and cleanup
main.c
Dedicated stale execution: prints "Scanned" header, calls print_stale_summary(), optionally prompts for confirmation when --prune is set (skipped if --yes), invokes prune_stale_branches() on approval, then proceeds to cleanup. Cleanup extended to free per-repo stale data and protected-branches array.
Integration tests
tests/integration.sh
Comprehensive test coverage: empty stale list, merged/gone detection, squash-merge positive/negative cases, config-driven and implicit protection, age filtering, error handling for invalid options, pruning workflows, and refusal to delete gone+unmerged branches with local commits. Adds mkmain() helper to normalize test repo default branches.
Unit test globals
tests/unit.c
Declares stale-related global options (opt_stale, opt_prune, opt_yes, opt_older_than_secs, opt_protected_branches*) to match expanded main.c declarations.
sequenceDiagram
  participant User
  participant CLI as main.c
  participant Detector as repo.c
  participant Pruner as repo.c
  participant Display as display.c
  participant Git as libgit2

  User->>CLI: gitls stale [--prune] [--older-than N]
  CLI->>Detector: process_repo_local() calls fill_stale_branches()
  Detector->>Git: resolve default branch
  Detector->>Git: compute patch-ids on default branch commits
  Detector->>Git: check upstream tracking refs for gone branches
  Detector->>Git: test reachability from default branch
  Detector->>Detector: classify branches: STR_GONE/STR_MERGED/STR_SQUASHED
  CLI->>Display: print_stale_summary()
  Display->>User: display stale branches and counts
  alt --prune flag set
    CLI->>User: prompt for confirmation (unless --yes)
    User-->>CLI: confirm
    CLI->>Pruner: prune_stale_branches()
    Pruner->>Git: reopen repos and re-verify safety
    Pruner->>Git: delete eligible branches
    Pruner->>Display: print_prune_results()
    Display->>User: deleted/refused/error counts
  end
  CLI->>CLI: cleanup & exit
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

A rabbit hops through old branches with glee, 🐰
Finding the gone ones and those merged with ease,
With squash-detection and age-filters too,
It prunes what's stale (with your safety to chew!),
Making repos clean—hop, hop, hooray! 🌿✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.68% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely describes the main feature added: a new stale subcommand for detecting and pruning stale branches, matching the primary change across all modified files.
Linked Issues check ✅ Passed All coding objectives from issue #3 are met: stale detection (gone/merged/squash), CLI with --prune/--yes/--older-than, protected_branches config, safety rails (never touch HEAD/default branch, refuse unmerged local commits), and --only filter.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing issue #3 requirements: stale detection, pruning, config parsing, CLI flags, and comprehensive tests. No out-of-scope modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature_stale-branches

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
tests/integration.sh (1)

487-503: ⚡ Quick win

Add one integration case for interactive stale --prune confirmation (without --yes).

Current prune coverage is good, but it only exercises the non-interactive path. Please add a prompt-path test (accept/decline) to lock down the safety rail behavior.

Suggested test shape
+printf "\nstale --prune: confirmation prompt\n"
+D="$WORK/prune-confirm"; mkmain "$D"
+git -C "$D" checkout -q -b f-confirm
+git -C "$D" commit -q --allow-empty -m "merged work"
+git -C "$D" checkout -q main
+git -C "$D" merge -q --no-ff f-confirm -m "merge"
+
+# decline prune
+out=$(printf 'n\n' | "$GITLS" --no-color stale --prune "$D" 2>&1)
+if git -C "$D" rev-parse --verify -q f-confirm >/dev/null; then
+    printf "  ok  prune declined keeps branch\n"; passed=$((passed + 1))
+else
+    printf "FAIL  prune declined keeps branch\n     got: %s\n" "$out"
+    failed=$((failed + 1))
+fi

Also applies to: 568-585, 624-649

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration.sh` around lines 487 - 503, Add an interactive prune test
next to the existing non-interactive case: after creating D via mkmain and
creating/merging branch f-merged, run the CLI without --yes and feed it a
simulated response using printf; e.g. capture out=$(printf 'y\n' | "$GITLS"
--no-color stale --prune "$D" 2>&1) and assert the branch is deleted (similar
checks used currently), then add a second case that feeds 'n\n' (out=$(printf
'n\n' | ...)) and assert the branch still exists; reuse the same test variables
($GITLS, mkmain, D, passed, failed) and mirror the existing success/failure
reporting format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config.c`:
- Around line 123-133: The parsing loop that splits protected_branches (using
strtok on copy with tok, idx, arr, count) stores tokens verbatim so entries like
"develop, staging" keep spaces; update the loop to trim leading and trailing
whitespace from each tok before strdup and storing in arr (either by adding an
in-place trim routine or calling a reusable helper like trim_whitespace(tok) to
strip spaces/tabs/newlines), then strdup the trimmed token and proceed with the
same error handling and cleanup logic.

In `@main.c`:
- Around line 149-162: The CLI allows invalid/ignored flags: disallow
non-positive durations and require --yes only with --prune; in the argument
parsing around parse_duration and variables opt_older_than_secs and opt_yes,
treat secs <= 0 as invalid (emit an error for "0d" or negative durations)
instead of accepting, and after parsing all args validate that if opt_yes is
true then opt_prune must also be true (otherwise print a clear error and return
non-zero). Update the error messages to mention the contract (e.g.,
"--older-than must be >0" and "--yes is only valid with --prune") so callers get
immediate feedback.

In `@README.md`:
- Around line 229-240: The fenced code block showing the stale-output example
(the block beginning with the line "gitls stale ~/projects") lacks a language
tag and triggers MD040; update that fence to include a language identifier
(e.g., add "text" so it becomes ```text) so the markdown linter recognizes it;
locate the example block by the unique snippet "gitls stale ~/projects" in
README.md and add the language tag to the opening triple-backtick only.

In `@repo.c`:
- Around line 539-549: Resolve the repository's actual default branch from HEAD
first (e.g., call git_repository_head(&default_ref, repo) and obtain the branch
name via git_branch_name or git_reference_shorthand), store that name into
r->default_branch using strncpy, and only if HEAD cannot be resolved fall back
to checking "main" then "master" via git_branch_lookup; also ensure any
previously allocated git_reference (default_ref) is freed before reassigning.
Use the same symbols from the diff (default_ref, git_branch_lookup,
r->default_branch) so the change replaces the hard-coded main/master resolution
with HEAD-based resolution plus the existing fallbacks.

---

Nitpick comments:
In `@tests/integration.sh`:
- Around line 487-503: Add an interactive prune test next to the existing
non-interactive case: after creating D via mkmain and creating/merging branch
f-merged, run the CLI without --yes and feed it a simulated response using
printf; e.g. capture out=$(printf 'y\n' | "$GITLS" --no-color stale --prune "$D"
2>&1) and assert the branch is deleted (similar checks used currently), then add
a second case that feeds 'n\n' (out=$(printf 'n\n' | ...)) and assert the branch
still exists; reuse the same test variables ($GITLS, mkmain, D, passed, failed)
and mirror the existing success/failure reporting format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93adbd10-f809-4301-bd4f-1d84b3391b80

📥 Commits

Reviewing files that changed from the base of the PR and between f080d6a and 6f966e3.

📒 Files selected for processing (9)
  • README.md
  • config.c
  • display.c
  • gitlsrc.example
  • gitools.h
  • main.c
  • repo.c
  • tests/integration.sh
  • tests/unit.c

Comment thread config.c
Comment thread main.c
Comment thread README.md Outdated
Comment thread repo.c Outdated
sven42xyz and others added 2 commits May 26, 2026 21:40
* config.c: trim leading/trailing whitespace in protected_branches
  tokens so "develop, release" matches the literal branch name
  instead of silently storing " release".

* main.c: reject --older-than 0/negative durations (they would
  silently disable the filter while looking active on the CLI), and
  require --prune when --yes is given (it has no effect otherwise
  and the help text documents it as prune-only).

* repo.c: resolve the repo's actual default branch from
  refs/remotes/origin/HEAD first, falling back to main/master only
  when the remote-published default is unavailable. Repos using
  develop, trunk, etc. as their default now get correct merged /
  squash detection against the right base.

* README.md: language tag on the stale-output fence (MD040).

* integration tests: new coverage for the validation gates, the
  origin/HEAD default-branch resolution, and whitespace-trimming
  in protected_branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Restricts the listing (and therefore --prune) to a subset of detection
reasons. Useful for "prune only the gone branches, leave merged ones
for review" workflows.

  gitls stale --only gone
  gitls stale --prune --yes --only gone,merged

Accepts a comma-separated list of: gone, merged, squash (also accepts
"squashed" as a synonym). Whitespace around tokens is trimmed.

The filter is applied at the append site so call sites stay clean and
the existing reason-precedence (a branch is classified once, with
GONE > MERGED > SQUASHED) is preserved. To match a branch that is
both gone and merged under either reason, pass --only gone,merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
main.c (1)

61-70: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard parse_duration() against signed overflow.

parse_duration() multiplies a user-controlled long n by unit constants without bounding the product. On 32-bit long, inputs like 70y can overflow, yielding undefined behavior and potentially causing opt_older_than_secs to become non-positive (disabling the --older-than filter in repo.c).

Suggested fix
 static long parse_duration(const char *s) {
     if (!s || !*s) return -1;
     char *end;
     errno = 0;
     long n = strtol(s, &end, 10);
     /* Reject zero and negative durations — they would silently disable the
      * filter while still appearing on the command line. */
     if (errno || n <= 0 || end == s || *end == '\0' || *(end + 1) != '\0') return -1;
-    switch (*end) {
-        case 's': return n;
-        case 'h': return n * 3600L;
-        case 'd': return n * 86400L;
-        case 'w': return n * 7L * 86400L;
-        case 'm': return n * 30L * 86400L;
-        case 'y': return n * 365L * 86400L;
-        default:  return -1;
-    }
+    long mult;
+    switch (*end) {
+        case 's': mult = 1L; break;
+        case 'h': mult = 3600L; break;
+        case 'd': mult = 86400L; break;
+        case 'w': mult = 7L * 86400L; break;
+        case 'm': mult = 30L * 86400L; break;
+        case 'y': mult = 365L * 86400L; break;
+        default:  return -1;
+    }
+    if (n > LONG_MAX / mult) return -1;
+    return n * mult;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.c` around lines 61 - 70, parse_duration() can overflow when multiplying
the parsed long n by unit constants (e.g. in cases 'y','m','w','d','h','s'),
leading to undefined behavior and incorrect opt_older_than_secs; add explicit
overflow checks before each multiplication (or compute using unsigned long long
or use bounds like LONG_MAX / multiplier) and return -1 on overflow. Locate
parse_duration and the switch on *end and for each branch validate that n <=
(LONG_MAX / multiplier) (or cast to wider type and compare against LONG_MAX)
before performing n * multiplier; if the check fails, return -1 so callers
(e.g., opt_older_than_secs handling) do not receive a wrapped/negative value.
Ensure errno or error path is set consistently when returning -1.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@main.c`:
- Around line 61-70: parse_duration() can overflow when multiplying the parsed
long n by unit constants (e.g. in cases 'y','m','w','d','h','s'), leading to
undefined behavior and incorrect opt_older_than_secs; add explicit overflow
checks before each multiplication (or compute using unsigned long long or use
bounds like LONG_MAX / multiplier) and return -1 on overflow. Locate
parse_duration and the switch on *end and for each branch validate that n <=
(LONG_MAX / multiplier) (or cast to wider type and compare against LONG_MAX)
before performing n * multiplier; if the check fails, return -1 so callers
(e.g., opt_older_than_secs handling) do not receive a wrapped/negative value.
Ensure errno or error path is set consistently when returning -1.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1a63abfb-4355-468c-b549-27cf988d114b

📥 Commits

Reviewing files that changed from the base of the PR and between 6f966e3 and 914bfd3.

📒 Files selected for processing (7)
  • README.md
  • config.c
  • gitools.h
  • main.c
  • repo.c
  • tests/integration.sh
  • tests/unit.c
✅ Files skipped from review due to trivial changes (1)
  • README.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0.6.0: stale branch detection and pruning

1 participant