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

## 36
- `gc --remove` removes copies with a submodule (git refused without `--force`) and copies whose status shows changes but content equals HEAD (a hook restored the files); a change, a stash or a commit on no remote inside a submodule still blocks removal, and so does a failed submodule check.

## 35
- `gc` also removes branches squash-merged into the main branch, and branches merged into them.

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 = 35 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
VERSION = 36 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
14 changes: 11 additions & 3 deletions lib/dpcli/commands/branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,16 +184,24 @@ def junk_ok(f):
def remove_copy(path):
"""Remove a copy: refuse (-> reason) on changes in tracked files or untracked work files;
git-ignored and generated files (gc.ignore_dirs, gc.ignore_globs) do not block it. -> (reason|None, ignored dirs)."""
# status noise with content equal to HEAD (a hook restored the files) is not work; any change inside a submodule is,
# and so is a submodule commit on no remote: its repository is removed with the copy
d = gitx.dirty(path)
if d:
if d and gitx.gitr("diff", "--quiet", "HEAD", cwd=path).returncode:
return f"dirty copy ({len(d)} files)", []
r = gitx.gitr("submodule", "foreach", "--quiet", "--recursive",
"git status --porcelain && git stash list && git rev-list --branches HEAD --not --remotes", cwd=path)
if r.returncode:
return f"cannot check submodules: {gitx.gerr(r)}", []
if r.stdout.split():
return "changes or unpushed commits in submodules", []
junk = [f for xy, f in gitx.status_entries(path) if xy == "??"]
other = [f for f in junk if not junk_ok(f)]
if other:
return f"untracked files ({len(other)}: {', '.join(other[:3])})", []
ign = ignored_dirs(path)
# only generated files, not work: git refuses on untracked, hence --force
r = gitx.gitr("worktree", "remove", *(["--force"] if junk else []), str(path))
# checked above; --force: git refuses on untracked generated files and on any copy with a submodule
r = gitx.gitr("worktree", "remove", "--force", str(path))
if r.returncode:
return gitx.gerr(r), []
return None, ign
Expand Down
52 changes: 52 additions & 0 deletions tests/test_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,58 @@ def test_remove_copy_refuses_work(self):
self.assertIn("dirty copy", why)
self.assertTrue(path.exists())

def test_remove_copy_status_noise_without_changes(self):
# a pre-commit hook restored the file: status says MM, content equals HEAD
self.cfg()
path = self.make_copy()
(path / "a.txt").write_text("b")
sh("add", "a.txt", cwd=path)
(path / "a.txt").write_text("a")
why, _ = branches.remove_copy(path)
self.assertIsNone(why)
self.assertFalse(path.exists())

def test_remove_copy_with_submodule(self):
sub = self.main.parent / "sub"
sub.mkdir()
sh("init", "-q", "-b", "main", cwd=sub)
(sub / "s.txt").write_text("s")
sh("add", ".", cwd=sub)
sh("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "s", cwd=sub)
sh("-c", "protocol.file.allow=always", "submodule", "add", "-q", str(sub), "lib", cwd=self.main)
sh("commit", "-q", "-m", "sub", cwd=self.main)
self.cfg()
path = self.make_copy()
sh("-c", "protocol.file.allow=always", "submodule", "update", "-q", "--init", cwd=path)
(path / "lib" / "work.txt").write_text("работа")
why, _ = branches.remove_copy(path)
self.assertIn("submodules", why)
sh("add", "work.txt", cwd=path / "lib")
sh("-c", "user.email=t@t", "-c", "user.name=t", "stash", "-q", cwd=path / "lib")
why, _ = branches.remove_copy(path)
self.assertIn("submodules", why)
sh("stash", "pop", "-q", cwd=path / "lib")
# a commit only in the copy's submodule repository would be lost with it
sh("add", "work.txt", cwd=path / "lib")
sh("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "w", cwd=path / "lib")
sh("add", "lib", cwd=path)
sh("commit", "-q", "-m", "pointer", cwd=path)
why, _ = branches.remove_copy(path)
self.assertIn("submodules", why)
sh("reset", "-q", "--hard", "HEAD~1", cwd=path)
sh("submodule", "update", "-q", cwd=path)
# the check itself fails: block, do not assume clean
dotgit = path / "lib" / ".git"
good = dotgit.read_text()
dotgit.write_text("gitdir: /nonexistent\n")
why, _ = branches.remove_copy(path)
self.assertIn("cannot check submodules", why)
dotgit.write_text(good)
sh("branch", "-q", "-D", "main", cwd=path / "lib")
why, _ = branches.remove_copy(path)
self.assertIsNone(why)
self.assertFalse(path.exists())


if __name__ == "__main__":
unittest.main()
Loading