From 5d3130ee3d6a9404489726019d1ee3e7ee6f39e5 Mon Sep 17 00:00:00 2001 From: Andrey Zolotarev Date: Sat, 31 Oct 2026 23:55:48 +0000 Subject: [PATCH 1/2] gc: detect squash-merged branches --- CHANGELOG.md | 3 +++ lib/dpcli/__init__.py | 2 +- lib/dpcli/commands/branches.py | 20 +++++++++++++++++++- tests/test_gc.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8110fc9..225aca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 35 +- `gc` sees branches squash-merged into the main branch (their diff is one commit there) and branches merged into those: after a squash MR it reported "no merged branches" and copies were removed by hand. + ## 34 - `contribute` makes the `improve/F-n-*` branch and the commit from uncommitted edits in `.dpcli`: agents may run dpcli but not git there, so a friction stopped on a permission. - `update` keeps `.dpcli` on branch `main` at the release (not a detached tag next to a lagging local `main`), prunes deleted hub branches, and drops the local `improve/F-n-*` branch of a merged PR (one left checked out blocked every later update). diff --git a/lib/dpcli/__init__.py b/lib/dpcli/__init__.py index a5cf4e8..a2d55ac 100644 --- a/lib/dpcli/__init__.py +++ b/lib/dpcli/__init__.py @@ -6,4 +6,4 @@ else in the current copy. Exit codes: 0 ok, 1 error/check failed, 2 arguments. """ -VERSION = 34 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec) +VERSION = 35 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec) diff --git a/lib/dpcli/commands/branches.py b/lib/dpcli/commands/branches.py index d1e1191..e04cef8 100644 --- a/lib/dpcli/commands/branches.py +++ b/lib/dpcli/commands/branches.py @@ -199,10 +199,28 @@ def remove_copy(path): return None, ign +def squash_merged(main, merged): + """Local branches squash-merged into main (their whole diff is one commit there), plus branches merged into those.""" + rest = [b for b in gitx.local_branches() if b != main and b not in merged] + out = set() + for b in rest: + base = gitx.gitr("merge-base", main, b).stdout.strip() + if not base: + continue + tmp = gitx.gitr("commit-tree", f"{b}^{{tree}}", "-p", base, "-m", "gc").stdout.strip() + if tmp and gitx.gitr("cherry", main, tmp).stdout.startswith("-"): + out.add(b) + out |= {b for b in rest if b not in out and any( + gitx.gitr("merge-base", "--is-ancestor", b, s).returncode == 0 for s in out)} + return [b for b in rest if b in out] + + def cmd_gc(a): main = config.main_branch() main_tip = gitx.gitr("rev-parse", main).stdout.strip() merged = [b for b in gitx.gitr("branch", "--merged", main, "--format=%(refname:short)").stdout.split() if b != main] + squashed = squash_merged(main, merged) + merged += squashed here = str(gitx.toplevel()) wt = {b: p for p, b in gitx.worktrees()} n = 0 @@ -236,7 +254,7 @@ def cmd_gc(a): if why_not: print(f"skip {label}: {why_not}") continue - r = gitx.gitr("branch", "-d", b) + r = gitx.gitr("branch", "-D" if b in squashed else "-d", b) print((f"removed {label}" + (f" (with ignored: {', '.join(ign[:4])})" if path and ign else "")) if r.returncode == 0 else f"copy removed, branch {b} kept: {gitx.gerr(r)}") n += 1 diff --git a/tests/test_gc.py b/tests/test_gc.py index 4fbc44c..68336f1 100644 --- a/tests/test_gc.py +++ b/tests/test_gc.py @@ -25,6 +25,34 @@ def test_symlinked_copy_skipped(self): self.assertRegex(out, r"skip ta .*symlinks from other copies") self.assertIn("tb @", out) + def test_squash_merged(self): + """A branch squash-merged into main (and a branch merged into it) is removable; an unmerged one is not.""" + p = Project() + self.addCleanup(p.cleanup) + r = p.root + mod, task = p.copies / "mod", p.copies / "task" + git("worktree", "add", "-q", "-b", "fm", str(mod), "main", cwd=r) + git("worktree", "add", "-q", "-b", "fm-t", str(task), "fm", cwd=r) + (task / "t.txt").write_text("t") + p.commit_all("t", cwd=task) + git("merge", "-q", "--no-ff", "-m", "m t", "fm-t", cwd=mod) + (mod / "j.txt").write_text("j") + p.commit_all("j", cwd=mod) + git("branch", "other", "main", cwd=r) + (r / "o.txt").write_text("o") + p.commit_all("o") # main moves on before the squash + git("merge", "-q", "--squash", "fm", cwd=r) + git("commit", "-q", "-m", "squash fm", cwd=r) + git("checkout", "-q", "other", cwd=r) + (r / "x.txt").write_text("x") + p.commit_all("x") + git("checkout", "-q", "main", cwd=r) + out = p.ok("gc", "--remove") + self.assertIn("removed fm @", out) + self.assertIn("removed fm-t @", out) + self.assertNotIn("other", out) + self.assertNotIn("fm", git("branch", "--format=%(refname:short)", cwd=r).split()) + if __name__ == "__main__": unittest.main() From 76c44ee13a37ed5566e1f42dbdee61b4ef31de6e Mon Sep 17 00:00:00 2001 From: Andrey Zolotarev Date: Sun, 1 Nov 2026 00:34:01 +0000 Subject: [PATCH 2/2] gc: skip empty-diff branches; tests for work after squash and empty diff Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- lib/dpcli/commands/branches.py | 6 ++++-- tests/test_gc.py | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 225aca8..91123c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## 35 -- `gc` sees branches squash-merged into the main branch (their diff is one commit there) and branches merged into those: after a squash MR it reported "no merged branches" and copies were removed by hand. +- `gc` also removes branches squash-merged into the main branch, and branches merged into them. ## 34 - `contribute` makes the `improve/F-n-*` branch and the commit from uncommitted edits in `.dpcli`: agents may run dpcli but not git there, so a friction stopped on a permission. diff --git a/lib/dpcli/commands/branches.py b/lib/dpcli/commands/branches.py index e04cef8..b3f08b9 100644 --- a/lib/dpcli/commands/branches.py +++ b/lib/dpcli/commands/branches.py @@ -200,12 +200,14 @@ def remove_copy(path): def squash_merged(main, merged): - """Local branches squash-merged into main (their whole diff is one commit there), plus branches merged into those.""" + """Local branches squash-merged into main (their whole diff is one commit there, by patch-id), plus branches + merged into those. A branch with an empty net diff is never matched. Costs a few git calls per unmerged + branch and leaves one dangling commit object each (collected by git gc).""" rest = [b for b in gitx.local_branches() if b != main and b not in merged] out = set() for b in rest: base = gitx.gitr("merge-base", main, b).stdout.strip() - if not base: + if not base or gitx.gitr("rev-parse", f"{b}^{{tree}}").stdout == gitx.gitr("rev-parse", f"{base}^{{tree}}").stdout: continue tmp = gitx.gitr("commit-tree", f"{b}^{{tree}}", "-p", base, "-m", "gc").stdout.strip() if tmp and gitx.gitr("cherry", main, tmp).stdout.startswith("-"): diff --git a/tests/test_gc.py b/tests/test_gc.py index 68336f1..2ba0d19 100644 --- a/tests/test_gc.py +++ b/tests/test_gc.py @@ -53,6 +53,30 @@ def test_squash_merged(self): self.assertNotIn("other", out) self.assertNotIn("fm", git("branch", "--format=%(refname:short)", cwd=r).split()) + def test_squash_then_more_work_and_empty_diff_kept(self): + """Kept: a branch with a commit after its squash, and a branch whose net diff is empty.""" + p = Project() + self.addCleanup(p.cleanup) + r = p.root + git("checkout", "-q", "-b", "more", cwd=r) + (r / "a.txt").write_text("a") + p.commit_all("a") + git("checkout", "-q", "main", cwd=r) + git("merge", "-q", "--squash", "more", cwd=r) + git("commit", "-q", "-m", "squash more", cwd=r) + git("checkout", "-q", "more", cwd=r) + (r / "b.txt").write_text("b") + p.commit_all("b") # work after the squash + git("checkout", "-q", "-b", "empty", "main", cwd=r) + (r / "e.txt").write_text("e") + p.commit_all("e") + (r / "e.txt").unlink() + p.commit_all("revert e") # tree == merge-base tree + git("checkout", "-q", "main", cwd=r) + out = p.ok("gc", "--remove") + self.assertIn("no merged branches", out) + self.assertTrue({"more", "empty"} <= set(git("branch", "--format=%(refname:short)", cwd=r).split())) + if __name__ == "__main__": unittest.main()