indexzero / indexzero/skiffs

feat(prune): `--apply` — safe, worktree-aware branch/worktree removal

Open
#3 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
5
Forks
0
PR merge metrics
No merged PRs in 30d

Description

Summary

skiffs prune today classifies local branches read-only. This adds an opt-in executor,
skiffs prune --apply, that removes integrated/merged branches and the worktrees holding
them — behind a durability guard that never destroys unique work. Read-only stays the default
(a plan / apply split).

Design

Durability guard — block removal if any of:

  1. Working tree not clean — untracked / modified / staged files.
  2. Un-PR'd commit — a commit in default..branch that is not an ancestor of the default
    branch, not patch-equivalent to one already in default (rebase-&-merge, detected via
    git cherry), and not covered by a MERGED PR (squash-&-merge). A CLOSED-unmerged
    PR does not make a branch safe.
  3. Precious ignored content — gitignored-but-present files that a plain git worktree remove would silently destroy. Opted in via --anti-gitignore <path> (file of verbatim
    .gitignore keys) or --no-ignore (blanket: every ignored+present file is precious).

Removal routing (per --git-common-dir):

  • default → native git worktree remove + git branch -D
  • worktrunk-managed repo (.config/wt.toml present or any worktrunk.* key in
    .git/config) → delegate to wt remove (preserves its pre-remove hooks)
  • worktrunk-managed but wt not on PATHskip + report, never native-fallback
    (fail-closed)

Interaction:

  • read-only by default (bare skiffs prune = the report / "plan")
  • --apply → interactive [y/N/a/q] (Enter=skip, a=all, q=quit); -y/--yes = all
  • --apply refuses when the scan spans more than one --git-common-dir (single-repo
    safety boundary; the primary + all its worktrees still count as one repo)
  • the per-branch verdict is additive to the existing PruneResult JSON —
    safeToDelete/needsReview unchanged

Tasks

Dependency-ordered. T1 unblocks the parallel block T2–T5. Each task ≤ ~5 files, test-first.

  • T1 — Guard verdict types + pure Guard()

    • Acceptance: report/guard.go defines Verdict{Branch, Blocks []BlockReason, Route},
      BlockReason (DirtyWorktree|UnPushedCommits|PreciousIgnored), Route
      (NativeGit|WorktrunkDelegate|SkipWtUnavailable), BranchState, GuardConfig.
      Guard(BranchState, GuardConfig) Verdict; Removable == len(Blocks)==0. Additive —
      PruneResult slices untouched.
    • Verify: go test ./report/ — table tests: each block reason alone, combined, clean case.
    • Files: report/guard.go, report/guard_test.go.
  • T2 — gh: MERGED-only signal

    • Acceptance: internal/gh adds MergedPRBranches(ctx, dir, limit) (map[string]bool, error)
      filtering state == MERGED only. Existing MergedClosedPRBranches unchanged.
    • Verify: go test ./internal/gh/ — fixture asserts CLOSED excluded, MERGED included.
    • Files: internal/gh/gh.go, internal/gh/gh_test.go.
  • T3 — git: durability collectors

    • Acceptance: WorktreeDirty(dir) (bool, FileStatus) (reuse GetState);
      UnDurableCommits(ctx, dir, defaultRef, branch string, mergedPR bool) ([]string, error)
      combining merge-base --is-ancestor, git cherry defaultRef branch (+ = candidate,
      - = upstream), and the MERGED-PR cover; ignores empty commits and in-range merges.
    • Verify: go test ./internal/git/t.TempDir() repos for merge-commit, rebase-&-merge,
      squash (+MERGED), and CLOSED-unmerged; assert removable vs held per the matrix.
    • Files: internal/git/prune.go, internal/git/prune_test.go.
  • T4 — internal/ignore: .anti-gitignore

    • Acceptance: new package. Parse(path) ([]string, error) (non-empty, non-# verbatim
      keys); PreciousPresent(ctx, dir string, keys []string, blanket bool) ([]string, error) =
      ignored+present files (git ls-files --others --ignored --exclude-standard) matched by a
      listed key (verbatim, via doublestar), or all when blanket. Reports per-key match count.
    • Verify: go test ./internal/ignore/ — temp repo with a gitignored dir + noise; scoped key
      matches only its dir, blanket matches all, a typo'd key → 0 (surfaced, not silent).
    • Files: internal/ignore/ignore.go, internal/ignore/ignore_test.go.
  • T5 — internal/worktrunk: detect + route

    • Acceptance: new package. IsManaged(ctx, dir) bool (.config/wt.toml present or
      git config --get-regexp '^worktrunk\.' non-empty). Available() bool
      (exec.LookPath("wt")). Route(managed, available bool) report.Route:
      managed+avail→WorktrunkDelegate; managed+!avail→SkipWtUnavailable; else→NativeGit.
    • Verify: go test ./internal/worktrunk/ — temp repos with/without each signal; assert
      fail-closed route when wt unavailable.
    • Files: internal/worktrunk/worktrunk.go, internal/worktrunk/worktrunk_test.go.
  • T6 — Executor interface (real + fake)

    • Acceptance: Executor interface { RemoveWorktree(dir) error; DeleteBranch(name) error; WtRemove(branch) error }. Real impl shells git worktree remove / git branch -D /
      wt remove --foreground. fakeExecutor records calls without running them.
    • Verify: go test ./internal/git/ — integration (temp repo) for real RemoveWorktree +
      DeleteBranch; fake used by later routing tests.
    • Files: internal/git/executor.go, internal/git/executor_test.go.
  • T7 — cmd/prune: per-branch Verdict in the read-only report

    • Acceptance: runPrune collects state (T2–T5), builds a BranchState per candidate, runs
      Guard, renders held reasons in the table + adds verdict to JSON additively. No
      --apply yet → still fully read-only.
    • Verify: go test ./cmd/ — safety-regression: bare prune performs zero mutations
      (refs+worktrees unchanged); JSON still contains safeToDelete/needsReview plus verdict.
    • Files: cmd/prune.go, cmd/prune_test.go.
  • T8 — cmd/prune: --apply (single-repo gate + [y/N/a/q] + dispatch)

    • Acceptance: flags --apply (bool), --no-ignore (bool), --anti-gitignore <path>,
      -y/--yes. Single-repo gate: if --apply and discovered branches span >1
      --git-common-dir, error before any prompt. Interactive loop matches [y/N/a/q]
      (Enter=skip, y, a=all, q=quit); -y = all. Only Removable verdicts offered; held
      listed with reason. Each confirmed removal dispatched via RouteExecutor.
    • Verify: go test ./cmd/ — (a) multi-common-dir --apply refused; (b) interaction parity
      with scripted stdin; (c) SkipWtUnavailable never calls native executor (fake asserts);
      (d) temp-repo end-to-end: one loose branch + one worktree removed.
    • Files: cmd/prune.go, cmd/prune_test.go.
  • T9 — Docs + help text

    • Acceptance: README documents the plan/apply model, the durability guard, and all flags;
      cobra long-help for prune updated. gofmt -l . clean, go vet ./... clean.
    • Verify: go run . prune --help shows the new flags; go test ./... green.
    • Files: README.md, cmd/prune.go.

Parallelization

T1 ─┬─ T2 ─┐
    ├─ T3 ─┼─ T6 ─┐
    ├─ T4 ─┤      ├─ T8 ─ T9
    └─ T5 ─┘  T7 ─┘
                (T7 needs T1–T5; T8 needs T6+T7)

Out of scope

  • Touching NeedsReview, CLOSED-unmerged branches, or the primary/current worktree.
  • Glob re-interpretation in .anti-gitignore — keys match verbatim against .gitignore.
  • Making the bare skiffs prune command destructive (read-only stays the default).
  • Non-GitHub remotes: branches whose squash-safety can't be proven via a MERGED PR are held,
    never auto-removed.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the dependency-ordered task you select, beginning with report/guard.go and report/guard_test.go for the pure guard types and table tests. Read the referenced package files and run the task's listed Go test command first. Done means the task's acceptance criteria pass without changing the read-only default; the complete feature also requires cmd/prune.go integration, end-to-end tests, and README/help updates.

Written by the indexing model from the issue text.

Assessment

Tech stack
git, github, go
Domain
cli, developer-experience, devtools
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.