diff --git a/CHANGELOG.md b/CHANGELOG.md index 91123c1..c80c062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/lib/dpcli/__init__.py b/lib/dpcli/__init__.py index a2d55ac..2118742 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 = 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) diff --git a/lib/dpcli/commands/branches.py b/lib/dpcli/commands/branches.py index b3f08b9..1baea28 100644 --- a/lib/dpcli/commands/branches.py +++ b/lib/dpcli/commands/branches.py @@ -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 diff --git a/tests/test_fixes.py b/tests/test_fixes.py index fef64e8..486b566 100644 --- a/tests/test_fixes.py +++ b/tests/test_fixes.py @@ -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()