From 23a7afde4fcce0964056909ec0f0c19cb4190b48 Mon Sep 17 00:00:00 2001 From: Andrey Zolotarev Date: Sun, 1 Nov 2026 02:59:20 +0000 Subject: [PATCH 1/3] =?UTF-8?q?gc=20--remove:=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=20=D1=81=D0=B0=D0=B1=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=20.dpcli=20=D0=BD=D0=B5=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D1=8F=D1=8E=D1=82=D1=81=D1=8F,=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8=20=C2=AB=D0=B3=D1=80=D1=8F=D0=B7=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=C2=BB=20=D0=BE=D1=82=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B0=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=B8=D0=B9=20=D0=BE=D1=82=20HEAD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +++ lib/dpcli/__init__.py | 2 +- lib/dpcli/commands/branches.py | 10 +++++++--- tests/test_fixes.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91123c1..d45740f 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 inside a submodule still blocks removal. + ## 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..f6e5f65 100644 --- a/lib/dpcli/commands/branches.py +++ b/lib/dpcli/commands/branches.py @@ -184,16 +184,20 @@ 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 d = gitx.dirty(path) - if d: + if d and gitx.gitr("diff", "--quiet", "HEAD", cwd=path).returncode: return f"dirty copy ({len(d)} files)", [] + sub = gitx.gitr("submodule", "foreach", "--quiet", "--recursive", "git status --porcelain", cwd=path).stdout.split("\n") + if any(sub): + return f"dirty copy ({sum(map(bool, sub))} files 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..4c16168 100644 --- a/tests/test_fixes.py +++ b/tests/test_fixes.py @@ -211,6 +211,37 @@ 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("dirty copy", why) + (path / "lib" / "work.txt").unlink() + why, _ = branches.remove_copy(path) + self.assertIsNone(why) + self.assertFalse(path.exists()) + if __name__ == "__main__": unittest.main() From 2c800b63845ade39e6baeed2a9c9fc60f1384979 Mon Sep 17 00:00:00 2001 From: Andrey Zolotarev Date: Sun, 1 Nov 2026 03:09:35 +0000 Subject: [PATCH 2/3] =?UTF-8?q?gc=20--remove:=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=20=D1=81=D0=B0=D0=B1=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=20.dpcli=20=D0=BD=D0=B5=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D1=8F=D1=8E=D1=82=D1=81=D1=8F,=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8=20=C2=AB=D0=B3=D1=80=D1=8F=D0=B7=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=C2=BB=20=D0=BE=D1=82=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B0=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=B8=D0=B9=20=D0=BE=D1=82=20HEAD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/dpcli/commands/branches.py | 10 ++++++---- tests/test_fixes.py | 13 +++++++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d45740f..7a16ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # 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 inside a submodule still blocks removal. +- `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 or a commit on no remote inside a submodule still blocks removal. ## 35 - `gc` also removes branches squash-merged into the main branch, and branches merged into them. diff --git a/lib/dpcli/commands/branches.py b/lib/dpcli/commands/branches.py index f6e5f65..442d249 100644 --- a/lib/dpcli/commands/branches.py +++ b/lib/dpcli/commands/branches.py @@ -184,13 +184,15 @@ 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 + # 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 and gitx.gitr("diff", "--quiet", "HEAD", cwd=path).returncode: return f"dirty copy ({len(d)} files)", [] - sub = gitx.gitr("submodule", "foreach", "--quiet", "--recursive", "git status --porcelain", cwd=path).stdout.split("\n") - if any(sub): - return f"dirty copy ({sum(map(bool, sub))} files in submodules)", [] + sub = gitx.gitr("submodule", "foreach", "--quiet", "--recursive", + "git status --porcelain; git rev-list --branches HEAD --not --remotes", cwd=path).stdout.split() + if sub: + 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: diff --git a/tests/test_fixes.py b/tests/test_fixes.py index 4c16168..7848010 100644 --- a/tests/test_fixes.py +++ b/tests/test_fixes.py @@ -236,8 +236,17 @@ def test_remove_copy_with_submodule(self): 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("dirty copy", why) - (path / "lib" / "work.txt").unlink() + self.assertIn("submodules", why) + # 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) + sh("branch", "-q", "-D", "main", cwd=path / "lib") why, _ = branches.remove_copy(path) self.assertIsNone(why) self.assertFalse(path.exists()) From adeb6cec28da04c3d8cdf41e7453ff3ff7fee499 Mon Sep 17 00:00:00 2001 From: Andrey Zolotarev Date: Sun, 1 Nov 2026 03:25:07 +0000 Subject: [PATCH 3/3] gc --remove: fail closed when the submodule check fails; block on submodule stashes --- CHANGELOG.md | 2 +- lib/dpcli/commands/branches.py | 8 +++++--- tests/test_fixes.py | 12 ++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a16ac6..c80c062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # 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 or a commit on no remote inside a submodule still blocks removal. +- `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/commands/branches.py b/lib/dpcli/commands/branches.py index 442d249..1baea28 100644 --- a/lib/dpcli/commands/branches.py +++ b/lib/dpcli/commands/branches.py @@ -189,9 +189,11 @@ def remove_copy(path): d = gitx.dirty(path) if d and gitx.gitr("diff", "--quiet", "HEAD", cwd=path).returncode: return f"dirty copy ({len(d)} files)", [] - sub = gitx.gitr("submodule", "foreach", "--quiet", "--recursive", - "git status --porcelain; git rev-list --branches HEAD --not --remotes", cwd=path).stdout.split() - if sub: + 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)] diff --git a/tests/test_fixes.py b/tests/test_fixes.py index 7848010..486b566 100644 --- a/tests/test_fixes.py +++ b/tests/test_fixes.py @@ -237,6 +237,11 @@ def test_remove_copy_with_submodule(self): (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") @@ -246,6 +251,13 @@ def test_remove_copy_with_submodule(self): 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)