Skip to content

Commit c0fdf75

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 db73ee2 commit c0fdf75

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
@@ -260,6 +260,52 @@ def _worktree_root(command: Any, repo: Any) -> str:
260260
return os.fspath(root)
261261

262262

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

303303

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