Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 45 additions & 10 deletions diffgraph/git_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,15 @@ def resolve_commit_range(
"git", "diff", "--raw", "-z", "--no-abbrev", "--no-ext-diff",
"--find-renames=50%", comparison_base_oid, head_oid,
]
scoped_pathspecs = _root_relative_pathspecs(repository, root, pathspecs)
scoped_pathspecs = _root_relative_pathspecs(
repository, root, pathspecs, warnings
)
if scoped_pathspecs is None:
return _commit_range_result(
base_ref, head_ref, three_dot, warnings=warnings,
base_oid=base_oid, head_oid=head_oid,
comparison_base_oid=comparison_base_oid,
)
if scoped_pathspecs:
command.append("--")
command.extend(scoped_pathspecs)
Expand Down Expand Up @@ -255,7 +263,11 @@ def _resolve(
command.extend(
["--raw", "-z", "--no-abbrev", "--no-ext-diff", "--find-renames=50%"]
)
scoped_pathspecs = _root_relative_pathspecs(repository, root, pathspecs)
scoped_pathspecs = _root_relative_pathspecs(
repository, root, pathspecs, warnings
)
if scoped_pathspecs is None:
return SnapshotResolution((), tuple(warnings))
if scoped_pathspecs:
command.append("--")
command.extend(scoped_pathspecs)
Expand Down Expand Up @@ -408,20 +420,43 @@ def _root_relative_pathspecs(
repository: str,
root: str,
pathspecs: Optional[Sequence[str]],
) -> List[str]:
"""Translate caller-relative pathspecs for a Git process run at ``root``."""
warnings: List[ResolutionWarning],
) -> Optional[List[str]]:
"""Translate caller-relative pathspecs for a Git process run at ``root``.

An absolute scope outside the repository cannot be passed to Git safely.
Reject it rather than normalising it to ``../...`` and relying on a
command failure, so callers receive an actionable warning and never risk
falling back to a broader query.
"""

if not pathspecs:
return []
caller = os.path.abspath(os.fspath(repository))
prefix = os.path.relpath(caller, root)
if prefix == ".":
return list(pathspecs)
prefix = prefix.replace(os.sep, "/")
canonical_root = os.path.realpath(root)
caller = os.path.realpath(os.fspath(repository))
prefix = os.path.relpath(caller, canonical_root)
prefix = "" if prefix == "." else prefix.replace(os.sep, "/")
scoped: List[str] = []
for pathspec in pathspecs:
if os.path.isabs(pathspec):
scoped.append(os.path.relpath(pathspec, root).replace(os.sep, "/"))
absolute_path = os.path.realpath(pathspec)
try:
inside_root = (
os.path.commonpath([canonical_root, absolute_path])
== canonical_root
)
except ValueError:
inside_root = False
if not inside_root:
warnings.append(ResolutionWarning(
"pathspec_outside_repository",
"Absolute pathspec is outside the repository and was not resolved",
pathspec,
))
return None
scoped.append(
os.path.relpath(absolute_path, canonical_root).replace(os.sep, "/")
)
else:
scoped.append(_prefix_pathspec(pathspec, prefix))
return scoped
Expand Down
36 changes: 36 additions & 0 deletions tests/test_git_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,42 @@ def test_explicit_pathspec_scope_is_not_widened(tmp_path):
assert no_match.warnings == ()


def test_absolute_pathspec_outside_repository_is_a_scoped_warning(tmp_path):
"""An invalid absolute scope must not turn into an unscoped diff."""
repo = make_repo(tmp_path)
write(repo, "tracked.txt", b"old\n")
commit_all(repo)
write(repo, "tracked.txt", b"new\n")

outside = tmp_path / "outside"
staged = resolve_staged(str(repo), [str(outside)])
unstaged = resolve_unstaged(str(repo), [str(outside)])
ranged = resolve_commit_range(
str(repo), "HEAD", "HEAD", pathspecs=[str(outside)]
)

for result in (staged, unstaged, ranged):
assert result.entries == ()
assert [(warning.code, warning.path) for warning in result.warnings] == [
("pathspec_outside_repository", str(outside))
]


def test_absolute_pathspec_via_symlink_alias_is_in_repository(tmp_path):
"""A symlinked repository path resolves to the canonical repository scope."""
repo = make_repo(tmp_path)
write(repo, "tracked.txt", b"old\n")
commit_all(repo)
write(repo, "tracked.txt", b"new\n")
alias = tmp_path / "repo-alias"
os.symlink(repo, alias, target_is_directory=True)

result = resolve_unstaged(str(alias), [str(alias / "tracked.txt")])

assert result.warnings == ()
assert [entry.new_path for entry in result.entries] == ["tracked.txt"]


def test_nul_parsing_preserves_tabs_and_newlines_in_paths(tmp_path):
repo = make_repo(tmp_path)
old_name = "old\tname\npart.txt"
Expand Down
Loading