Skip to content

Commit ce6de9c

Browse files
codexByron
authored andcommitted
feat(gix): check repository dirtiness natively
Use native tree/index/worktree status for `Repo.is_dirty`, preserving its flags, pathspecs, untracked selection and configured submodule checks. Do not write native status refreshes back to the index. Add CLI parity and no-subprocess checks for status, ignores, untracked files and dirty submodules. The staged version passes Ruff and Python syntax checks. Its SHA-1/SHA-256 native smoke and focused pytest selection pass: 34 passed in 18.88s.
1 parent a63ce33 commit ce6de9c

3 files changed

Lines changed: 93 additions & 0 deletions

File tree

‎git/_backend.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,52 @@ def _worktree_root(command: Any, repo: Any) -> str:
256256
return os.fspath(root)
257257

258258

259+
def is_dirty(command: Any, index: bool, working_tree: bool, untracked: bool, submodules: bool, path: Any) -> Any:
260+
if gix is None:
261+
return NotImplemented
262+
method = "Repo.is_dirty"
263+
command._require_version()
264+
try:
265+
repo = _repository(command, {})
266+
_worktree_root(command, repo)
267+
if not hasattr(repo, "status"):
268+
raise _Unsupported("status feature disabled")
269+
patterns = [os.fspath(path)] if path else []
270+
if any("\0" in pattern for pattern in patterns):
271+
raise _Unsupported("invalid pathspec")
272+
status = (
273+
repo.status()
274+
.index(_index(repo, {"env": command.environment()}))
275+
.untracked_files("files" if untracked else "none")
276+
.tree_index_track_renames(None)
277+
.index_worktree_rewrites(None)
278+
.index_worktree_submodules("configured" if submodules else "all", check_dirty=submodules)
279+
)
280+
dirty = False
281+
with status.into_iter(patterns) as entries:
282+
for item in entries:
283+
if item.summary() is None:
284+
continue
285+
if item.kind == "TreeIndex":
286+
if index and (submodules or item.details["entry_mode"] != 0o160000):
287+
dirty = True
288+
break
289+
elif item.details["kind"] == "DirectoryContents":
290+
if untracked and item.details["entry"]["status"] == "Untracked":
291+
dirty = True
292+
break
293+
elif working_tree:
294+
dirty = True
295+
break
296+
except _Unsupported as exc:
297+
return _fallback(method, str(exc))
298+
except gix.Error as exc:
299+
_logger.debug("is_dirty native read: %s", exc)
300+
return _fallback(method, "native read diagnostics")
301+
record(method, "native")
302+
return dirty
303+
304+
259305
def untracked_files(command: Any, args: Tuple[Any, ...], options: Dict[str, Any]) -> Any:
260306
if gix is None:
261307
return NotImplemented

‎git/repo/base.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,10 @@ def is_dirty(
10961096
# always considered to be clean.
10971097
return False
10981098

1099+
native = _backend.is_dirty(self.git, index, working_tree, untracked_files, submodules, path)
1100+
if native is not NotImplemented:
1101+
return native
1102+
10991103
# Start from the one which is fastest to evaluate.
11001104
default_args = ["--raw", "--no-ext-diff", "--no-textconv"]
11011105
if not submodules:

‎test/test_gix_backend.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,49 @@ def test_native_raw_diff_matches_cli(repo):
300300
assert native == before.diff(after, **options)
301301

302302

303+
def test_native_status_and_ignore_match_cli_without_writing_index(repo):
304+
root = Path(repo.working_dir)
305+
(root / ".gitignore").write_text("*.log\nignored/\n")
306+
(root / "tracked.log").write_text("tracked even though ignored\n")
307+
repo.index.add([".gitignore", "tracked.log"])
308+
repo.index.commit("initial", skip_hooks=True)
309+
(root / "ignored").mkdir()
310+
(root / "ignored/file").write_text("ignored")
311+
(root / "untracked\nfile").write_text("untracked")
312+
(root / "tracked.log").write_text("changed")
313+
Repo.init(root / "nested").close()
314+
paths = ["ignored", "ignored/file", "new.log", "tracked.log", "untracked\nfile"]
315+
index_before = Path(repo.index.path).read_bytes()
316+
options = [{}, {"working_tree": False}, {"index": False, "untracked_files": True}, {"path": "ignored"}]
317+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
318+
untracked = repo.untracked_files
319+
ignored = repo.ignored(*paths)
320+
dirty = [repo.is_dirty(**option) for option in options]
321+
assert Path(repo.index.path).read_bytes() == index_before
322+
with patch.object(_backend, "gix", None):
323+
assert untracked == repo.untracked_files
324+
assert ignored == repo.ignored(*paths)
325+
assert dirty == [repo.is_dirty(**option) for option in options]
326+
327+
328+
def test_native_status_tracks_submodule_dirtiness(repo):
329+
root = Path(repo.working_dir)
330+
with Repo.init(root.parent / "source", object_format=repo.git.rev_parse("--show-object-format")) as source:
331+
Path(source.working_dir, "file").write_text("initial")
332+
source.index.add(["file"])
333+
source.index.commit("initial", skip_hooks=True)
334+
module = repo.create_submodule("module", "module", source.working_dir)
335+
repo.index.commit("add submodule", skip_hooks=True)
336+
with module.module() as child:
337+
Path(child.working_dir, "file").write_text("modified")
338+
options = [{}, {"submodules": False}, {"working_tree": False}, {"index": False}]
339+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
340+
dirty = [repo.is_dirty(**option) for option in options]
341+
assert dirty == [True, False, False, True]
342+
with patch.object(_backend, "gix", None):
343+
assert dirty == [repo.is_dirty(**option) for option in options]
344+
345+
303346
def test_native_commit_statistics_match_cli(repo):
304347
root = Path(repo.working_dir)
305348
for name, content in (("file", b"one\ntwo\n"), ("binary", b"a\0b"), ("old", b"gone\n"), ("link", b"text")):

0 commit comments

Comments
 (0)