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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 35
- `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.
- `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).
Expand Down
2 changes: 1 addition & 1 deletion lib/dpcli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
22 changes: 21 additions & 1 deletion lib/dpcli/commands/branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,30 @@ 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, 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 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("-"):
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
Expand Down Expand Up @@ -236,7 +256,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
Expand Down
52 changes: 52 additions & 0 deletions tests/test_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,58 @@ 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())

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()
Loading