From 17b7068d4f96d8101d205392a3a423a13e0f2dc8 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Wed, 19 Aug 2026 09:18:42 +0530 Subject: [PATCH 1/2] feat: include untracked files in unstaged snapshots --- README.md | 4 +++ diffgraph/git_snapshot.py | 63 ++++++++++++++++++++++++++++++++++++-- tests/test_git_snapshot.py | 50 ++++++++++++++++++++++++++++++ tests/test_structural.py | 21 +++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bbb74a2..4112a2c 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ IDs, and content SHA-256 values in structural evidence, while symbol/relationship evidence names the parser package, query revision, source blob identity, and exact call site where applicable. +Unstaged analysis includes non-ignored ordinary untracked files as exact +one-sided working-tree snapshots without modifying the index. Git ignore rules +and caller-relative pathspecs are preserved. + #### CLI and offline contract - Each canonical invocation resolves the requested Git snapshot once, builds diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py index f92c04d..631ebe0 100644 --- a/diffgraph/git_snapshot.py +++ b/diffgraph/git_snapshot.py @@ -3,7 +3,7 @@ This module models the two local snapshot pairs and immutable commit ranges: * staged: ``HEAD`` -> index -* unstaged: index -> working tree +* unstaged: index -> working tree, including ordinary untracked files * two-dot: the requested base commit -> the requested head commit * three-dot: the merge base of the requested commits -> the requested head @@ -108,8 +108,9 @@ def resolve_unstaged( ) -> SnapshotResolution: """Resolve tracked changes from the index to the working tree. - Git does not include ordinary untracked files in this diff. For each - post-change regular file, the object ID is computed with ``git + Ordinary untracked files are added as one-sided working-tree snapshots; + ignored files remain excluded. For each post-change regular file, the + object ID is computed with ``git hash-object --path`` so clean filters and attributes match ``git add`` semantics without modifying the index. """ @@ -273,10 +274,66 @@ def _resolve( if entry is not None: entries.append(entry) + if not staged: + entries.extend(_resolve_untracked(root, scoped_pathspecs, warnings)) + entries.sort(key=_entry_sort_key) return SnapshotResolution(tuple(entries), tuple(warnings)) +def _resolve_untracked( + root: str, + pathspecs: Sequence[str], + warnings: List[ResolutionWarning], +) -> List[SnapshotEntry]: + """Resolve non-ignored untracked paths without modifying the index.""" + + command = ["git", "ls-files", "--others", "--exclude-standard", "-z"] + if pathspecs: + command.append("--") + command.extend(pathspecs) + output = _run(command, root, warnings, "git_untracked_failed") + if output is None: + return [] + + entries: List[SnapshotEntry] = [] + for raw_path in output.split(b"\0"): + if not raw_path: + continue + path = os.fsdecode(raw_path) + full_path = os.path.join(root, path) + try: + file_stat = os.lstat(full_path) + except OSError as error: + warnings.append(ResolutionWarning("worktree_read_failed", str(error), path)) + continue + + if stat.S_ISLNK(file_stat.st_mode): + mode = "120000" + elif stat.S_ISREG(file_stat.st_mode): + mode = "100755" if file_stat.st_mode & stat.S_IXUSR else "100644" + else: + warnings.append(ResolutionWarning( + "unsupported_worktree_entry", + "Cannot derive a Git blob ID for untracked filesystem entry", + path, + )) + continue + + new_oid = _working_tree_oid(root, path, mode, warnings) + if new_oid is not None: + entries.append(SnapshotEntry( + status="A", + old_path=None, + new_path=path, + old_mode=None, + new_mode=mode, + old_oid=None, + new_oid=new_oid, + )) + return entries + + def _repository_root( repository: str, warnings: List[ResolutionWarning] ) -> Optional[str]: diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py index eed0bf6..8403bcf 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -260,6 +260,56 @@ def test_unstaged_intent_to_add_and_rename_have_exact_identities(tmp_path): ).decode("ascii").strip() +def test_unstaged_includes_non_ignored_untracked_files_with_exact_identities(tmp_path): + repo = make_repo(tmp_path) + write(repo, ".gitignore", b"ignored.py\n") + write(repo, "tracked.py", b"def tracked():\n return 1\n") + commit_all(repo) + + write(repo, "new.py", b"def added():\n return 1\n") + write(repo, "ignored.py", b"def ignored():\n return 1\n") + write(repo, "bin/tool", b"#!/bin/sh\nexit 0\n") + os.chmod(repo / "bin/tool", 0o755) + os.symlink("new.py", repo / "new-link") + + result = resolve_unstaged(str(repo)) + entries = {entry.new_path: entry for entry in result.entries} + + assert result.warnings == () + assert set(entries) == {"new.py", "bin/tool", "new-link"} + assert entries["new.py"].status == "A" + assert entries["new.py"].old_oid is None + assert entries["new.py"].new_mode == "100644" + assert entries["new.py"].new_oid == git( + repo, + "hash-object", + "--stdin", + "--path=new.py", + input_bytes=(repo / "new.py").read_bytes(), + ).decode("ascii").strip() + assert entries["bin/tool"].new_mode == "100755" + assert entries["new-link"].new_mode == "120000" + assert entries["new-link"].new_oid == git( + repo, "hash-object", "--stdin", input_bytes=b"new.py" + ).decode("ascii").strip() + + +def test_untracked_pathspec_scope_is_relative_and_not_widened(tmp_path): + repo = make_repo(tmp_path) + write(repo, "tracked.txt", b"baseline\n") + commit_all(repo) + write(repo, "inside/new.py", b"inside = True\n") + write(repo, "outside.py", b"outside = True\n") + + scoped = resolve_unstaged(str(repo / "inside"), ["new.py"]) + no_match = resolve_unstaged(str(repo), ["missing"]) + + assert scoped.warnings == () + assert [entry.new_path for entry in scoped.entries] == ["inside/new.py"] + assert no_match.entries == () + assert no_match.warnings == () + + def test_pathspecs_are_relative_to_the_calling_subdirectory(tmp_path): repo = make_repo(tmp_path) write(repo, "src/app.py", b"def value():\n return 1\n") diff --git a/tests/test_structural.py b/tests/test_structural.py index 9e48ad6..265c1c8 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -123,6 +123,27 @@ def test_unstaged_uses_index_to_worktree_exact_identity_and_is_stable(tmp_path): assert first["symbols"][0]["change_kind"] == "modified" +def test_untracked_python_is_an_exact_added_snapshot(tmp_path): + root = repo(tmp_path) + write(root, "tracked.txt", "baseline\n") + commit(root) + write(root, "new.py", "def added():\n return 1\n") + + artifact = analyze_local_diff(str(root)) + + assert_valid(artifact) + assert [item["path"] for item in artifact["files"]] == ["new.py"] + file_entry = artifact["files"][0] + provenance = json.loads(file_entry["evidence"][0]["detail"]) + assert file_entry["change_kind"] == "added" + assert provenance["old_oid"] is None + assert provenance["new_oid"] == git( + root, "hash-object", "--path=new.py", "new.py" + ) + assert [item["name"] for item in artifact["symbols"]] == ["added"] + assert artifact["symbols"][0]["change_kind"] == "added" + + def test_pathspec_scope_is_not_widened(tmp_path): root = repo(tmp_path) write(root, "inside/a.py", "def a():\n return 1\n") From 4d65ce5c3416983031d02b17ffa1058fcbb532c8 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sat, 22 Aug 2026 01:31:52 +0530 Subject: [PATCH 2/2] test: use owner-only executable fixture mode --- tests/test_git_snapshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py index 8403bcf..ea08037 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -269,7 +269,7 @@ def test_unstaged_includes_non_ignored_untracked_files_with_exact_identities(tmp write(repo, "new.py", b"def added():\n return 1\n") write(repo, "ignored.py", b"def ignored():\n return 1\n") write(repo, "bin/tool", b"#!/bin/sh\nexit 0\n") - os.chmod(repo / "bin/tool", 0o755) + os.chmod(repo / "bin/tool", 0o700) os.symlink("new.py", repo / "new-link") result = resolve_unstaged(str(repo))