Skip to content

Commit 1bfcee0

Browse files
Merge pull request #151 from PortSwigger/scoped-git-commit
Confine git status and staging to the paths being generated
2 parents 9ae6cf7 + 69cf9f8 commit 1bfcee0

3 files changed

Lines changed: 216 additions & 89 deletions

File tree

‎src/manifest_builder/api.py‎

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from manifest_builder.discovery import ExternalPlugins, discover_blocks
2121
from manifest_builder.generator import generate_manifests, plural
2222
from manifest_builder.git_utils import (
23-
GitManifestChanges,
2423
create_manifest_commit,
2524
get_git_commit,
2625
get_git_commit_subject,
@@ -268,9 +267,8 @@ def _collect_generation_result(
268267
if not is_git_checkout(output):
269268
return GenerationResult(written_paths=written_paths)
270269

271-
changes = get_git_manifest_changes(output)
272-
if managed_roots is not None:
273-
changes = _filter_manifest_changes(output, changes, managed_roots)
270+
roots = None if managed_roots is None else {output / root for root in managed_roots}
271+
changes = get_git_manifest_changes(output, roots)
274272

275273
return GenerationResult(
276274
written_paths=written_paths,
@@ -429,28 +427,3 @@ def _validate_output_root(root: str, source: Path) -> str:
429427
):
430428
raise ValueError(f"Invalid output root {root!r} in {source}")
431429
return root
432-
433-
434-
def _filter_manifest_changes(
435-
output: Path, changes: GitManifestChanges, managed_roots: set[str]
436-
) -> GitManifestChanges:
437-
"""Limit git change reporting to the roots owned by this invocation."""
438-
return GitManifestChanges(
439-
added=_filter_paths_to_roots(output, changes.added, managed_roots),
440-
modified=_filter_paths_to_roots(output, changes.modified, managed_roots),
441-
deleted=_filter_paths_to_roots(output, changes.deleted, managed_roots),
442-
)
443-
444-
445-
def _filter_paths_to_roots(
446-
output: Path, paths: set[Path], managed_roots: set[str]
447-
) -> set[Path]:
448-
return {path for path in paths if _path_output_root(output, path) in managed_roots}
449-
450-
451-
def _path_output_root(output: Path, path: Path) -> str | None:
452-
try:
453-
rel_parts = path.resolve().relative_to(output.resolve()).parts
454-
except ValueError:
455-
return None
456-
return rel_parts[0] if rel_parts else None

‎src/manifest_builder/git_utils.py‎

Lines changed: 132 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,29 @@
33
"""Git utilities for manifest generation and versioning."""
44

55
import logging
6+
import os
67
from collections.abc import Iterable
78
from dataclasses import dataclass, field
89
from pathlib import Path
910
from typing import Protocol, cast
1011

1112
from dulwich import porcelain
13+
from dulwich.diff_tree import CHANGE_ADD, CHANGE_DELETE, TreeChange, tree_changes
1214
from dulwich.errors import NotGitRepository
13-
from dulwich.objects import Blob, Commit, Tree
15+
from dulwich.ignore import IgnoreFilterManager
16+
from dulwich.index import (
17+
ConflictedIndexEntry,
18+
blob_from_path_and_stat,
19+
cleanup_mode,
20+
commit_index,
21+
commit_tree,
22+
)
23+
from dulwich.object_store import (
24+
BaseObjectStore,
25+
MemoryObjectStore,
26+
OverlayObjectStore,
27+
)
28+
from dulwich.objects import Blob, Commit, ObjectID, Tree
1429
from dulwich.refs import Ref
1530
from dulwich.repo import Repo
1631

@@ -183,39 +198,52 @@ def is_git_dirty(path: Path) -> bool:
183198
raise RuntimeError(f"Failed to check git status for {path}: {e}") from e
184199

185200

186-
def get_git_manifest_changes(path: Path) -> GitManifestChanges:
187-
"""Return changed YAML files below ``path`` using Dulwich status.
201+
def get_git_manifest_changes(
202+
path: Path, roots: set[Path] | None = None
203+
) -> GitManifestChanges:
204+
"""Return changed YAML files below ``path``, comparing ``roots`` with HEAD.
188205
189-
The status is taken with ``untracked_files="all"``: Dulwich's default
190-
reports a wholly untracked directory as the directory itself rather than
191-
the files in it, which would hide every manifest of a newly added
192-
namespace or application from the change set.
206+
Only files under ``roots`` (``path`` by default) are read: they are hashed
207+
into a tree held in memory and diffed against HEAD's, so directories
208+
outside them cost one hash comparison each and the index is left alone.
193209
"""
194210
try:
195211
repo = Repo.discover(path)
196212
try:
197213
repo_root = Path(repo.path).resolve()
198214
output_root = path.resolve()
199-
status = porcelain.status(repo, untracked_files="all")
200-
changes = GitManifestChanges()
201-
202-
for raw_path in status.staged.get("add", []):
203-
_add_status_path(changes.added, repo_root, output_root, raw_path)
204-
for raw_path in status.staged.get("modify", []):
205-
_add_status_path(changes.modified, repo_root, output_root, raw_path)
206-
for raw_path in status.staged.get("delete", []):
207-
_add_status_path(changes.deleted, repo_root, output_root, raw_path)
208-
209-
for raw_path in status.untracked:
210-
_add_status_path(changes.added, repo_root, output_root, raw_path)
215+
prefixes = _tree_prefixes(repo_root, roots or {path})
216+
index = repo.open_index()
217+
scratch = MemoryObjectStore()
218+
store = OverlayObjectStore([scratch, repo.object_store], scratch)
219+
normalizer = repo.get_blob_normalizer()
220+
entries: dict[bytes, tuple[ObjectID, int]] = {}
221+
for tree_path, entry in index.iteritems():
222+
if isinstance(entry, ConflictedIndexEntry) or _under_any(
223+
tree_path, prefixes
224+
):
225+
continue
226+
entries[tree_path] = (entry.sha, entry.mode)
227+
for tree_path in _files_on_disk(repo, repo_root, prefixes):
228+
full_path = os.path.join(os.fsencode(repo_root), tree_path)
229+
st = os.lstat(full_path)
230+
blob = normalizer.checkin_normalize(
231+
blob_from_path_and_stat(full_path, st), tree_path
232+
)
233+
entries[tree_path] = (blob.id, cleanup_mode(st.st_mode))
234+
tree = commit_tree(
235+
store, [(p, sha, mode) for p, (sha, mode) in entries.items()]
236+
)
211237

212-
for raw_path in status.unstaged:
213-
absolute_path = repo_root / raw_path.decode("utf-8")
214-
if absolute_path.exists():
215-
_add_status_path(changes.modified, repo_root, output_root, raw_path)
238+
changes = GitManifestChanges()
239+
for change in _changes_under(store, _head_tree(repo), tree, prefixes):
240+
if change.type == CHANGE_ADD:
241+
paths = changes.added
242+
elif change.type == CHANGE_DELETE:
243+
paths = changes.deleted
216244
else:
217-
_add_status_path(changes.deleted, repo_root, output_root, raw_path)
218-
245+
paths = changes.modified
246+
_add_status_path(paths, repo_root, output_root, _changed_path(change))
219247
return changes
220248
finally:
221249
repo.close()
@@ -282,20 +310,72 @@ def _relative_to_repo(repo: Repo, path: Path) -> Path:
282310
return path.resolve().relative_to(repo_root)
283311

284312

285-
def _staged_status_has_path_under_any(
286-
status: porcelain.GitStatus, repo_root: Path, roots: set[Path]
287-
) -> bool:
288-
"""Return whether staged status contains a path below any root."""
289-
for paths in status.staged.values():
290-
for raw_path in paths:
291-
absolute_path = repo_root / raw_path.decode("utf-8")
292-
for root in roots:
293-
try:
294-
absolute_path.relative_to(root)
295-
except ValueError:
296-
continue
297-
return True
298-
return False
313+
def _tree_prefixes(repo_root: Path, roots: Iterable[Path]) -> set[bytes]:
314+
"""Return ``roots`` as tree paths; the repository root becomes ``b""``."""
315+
prefixes: set[bytes] = set()
316+
for root in roots:
317+
relative = root.resolve().relative_to(repo_root).as_posix()
318+
prefixes.add(b"" if relative == "." else relative.encode("utf-8"))
319+
return prefixes
320+
321+
322+
def _under_any(tree_path: bytes, prefixes: set[bytes]) -> bool:
323+
return any(
324+
not prefix or tree_path == prefix or tree_path.startswith(prefix + b"/")
325+
for prefix in prefixes
326+
)
327+
328+
329+
def _files_on_disk(repo: Repo, repo_root: Path, prefixes: set[bytes]) -> set[bytes]:
330+
"""Return tree paths of the files below ``prefixes`` git would track."""
331+
ignore_manager = IgnoreFilterManager.from_repo(repo)
332+
found: set[bytes] = set()
333+
for prefix in prefixes:
334+
top = repo_root / prefix.decode("utf-8")
335+
if top.is_file() or top.is_symlink():
336+
candidates: Iterable[Path] = [top]
337+
elif top.is_dir():
338+
candidates = _walk_files(top)
339+
else:
340+
continue
341+
for candidate in candidates:
342+
relative = candidate.relative_to(repo_root).as_posix()
343+
if not ignore_manager.is_ignored(relative):
344+
found.add(relative.encode("utf-8"))
345+
return found
346+
347+
348+
def _walk_files(top: Path) -> Iterable[Path]:
349+
for directory, subdirectories, files in os.walk(top):
350+
subdirectories[:] = [name for name in subdirectories if name != ".git"]
351+
for name in files + subdirectories:
352+
path = Path(directory) / name
353+
if name in files or path.is_symlink():
354+
yield path
355+
356+
357+
def _head_tree(repo: Repo) -> ObjectID | None:
358+
try:
359+
return cast(Commit, repo[repo.head()]).tree
360+
except KeyError:
361+
return None
362+
363+
364+
def _changes_under(
365+
store: BaseObjectStore,
366+
old_tree: ObjectID | None,
367+
new_tree: ObjectID,
368+
prefixes: set[bytes],
369+
) -> Iterable[TreeChange]:
370+
for change in tree_changes(store, old_tree, new_tree):
371+
if _under_any(_changed_path(change), prefixes):
372+
yield change
373+
374+
375+
def _changed_path(change: TreeChange) -> bytes:
376+
entry = change.new if change.type == CHANGE_ADD else change.old
377+
assert entry is not None and entry.path is not None
378+
return entry.path
299379

300380

301381
def create_manifest_commit(
@@ -333,17 +413,19 @@ def create_manifest_commit(
333413
repo = Repo.discover(output_dir)
334414
try:
335415
repo_root = Path(repo.path).resolve()
336-
output_root = output_dir.resolve()
337-
roots = {output_root}
338-
if stage_paths is None:
339-
pathspecs = [str(_relative_to_repo(repo, output_dir))]
340-
else:
341-
roots = {path.resolve() for path in stage_paths}
342-
pathspecs = _relative_stage_paths(repo, stage_paths)
343-
344-
porcelain.add(repo, paths=pathspecs)
345-
status = porcelain.status(repo)
346-
if not _staged_status_has_path_under_any(status, repo_root, roots):
416+
prefixes = _tree_prefixes(repo_root, stage_paths or {output_dir})
417+
tracked = {
418+
tree_path
419+
for tree_path, _entry in repo.open_index().iteritems()
420+
if _under_any(tree_path, prefixes)
421+
}
422+
repo.get_worktree().stage(
423+
sorted(tracked | _files_on_disk(repo, repo_root, prefixes))
424+
)
425+
tree = commit_index(repo.object_store, repo.open_index())
426+
if not any(
427+
_changes_under(repo.object_store, _head_tree(repo), tree, prefixes)
428+
):
347429
logger.info("There is nothing to commit.")
348430
return
349431

@@ -373,13 +455,3 @@ def create_manifest_commit(
373455
logger.info("Created manifest commit in %s", output_dir)
374456
except Exception as e:
375457
raise RuntimeError(f"Failed to create git commit in {output_dir}: {e}") from e
376-
377-
378-
def _relative_stage_paths(repo: Repo, stage_paths: set[Path]) -> list[str]:
379-
relative_paths: list[str] = []
380-
for path in sorted(stage_paths):
381-
try:
382-
relative_paths.append(str(_relative_to_repo(repo, path)))
383-
except ValueError:
384-
relative_paths.append(str(path))
385-
return relative_paths

‎tests/test_git_utils.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
import pytest
1010
from conftest import init_test_repo
1111
from dulwich import porcelain
12+
from dulwich.index import IndexEntry
1213
from dulwich.objects import Commit
1314
from dulwich.repo import Repo
1415

1516
from manifest_builder.git_utils import (
17+
GitManifestChanges,
1618
create_manifest_commit,
1719
get_git_commit_subject,
20+
get_git_manifest_changes,
1821
get_git_tracked_remote,
1922
is_git_checkout,
2023
is_git_dirty,
@@ -294,3 +297,82 @@ def test_create_manifest_commit_stages_only_requested_paths(
294297
assert porcelain.status(tmp_path).unstaged == [
295298
b"cluster/clusterrole-system:metrics-server.yaml"
296299
]
300+
301+
302+
def test_get_git_manifest_changes_reports_only_the_given_roots(
303+
tmp_path: Path,
304+
) -> None:
305+
"""Changes outside the roots are neither reported nor read."""
306+
init_test_repo(tmp_path)
307+
kept = tmp_path / "team-a" / "configmap-kept.yaml"
308+
edited = tmp_path / "team-a" / "configmap-edited.yaml"
309+
gone = tmp_path / "team-a" / "configmap-gone.yaml"
310+
elsewhere = tmp_path / "team-b" / "configmap-elsewhere.yaml"
311+
for path in (kept, edited, gone, elsewhere):
312+
path.parent.mkdir(exist_ok=True)
313+
path.write_text("apiVersion: v1\nkind: ConfigMap\n")
314+
_commit_all(tmp_path)
315+
316+
kept.write_text("apiVersion: v1\nkind: ConfigMap\n")
317+
edited.write_text("apiVersion: v1\nkind: ConfigMap\ndata: {}\n")
318+
gone.unlink()
319+
added = tmp_path / "team-a" / "new" / "configmap-added.yaml"
320+
added.parent.mkdir()
321+
added.write_text("apiVersion: v1\nkind: ConfigMap\n")
322+
elsewhere.write_text("apiVersion: v1\nkind: ConfigMap\ndata: {}\n")
323+
324+
changes = get_git_manifest_changes(tmp_path, {tmp_path / "team-a"})
325+
326+
assert changes == GitManifestChanges(
327+
added={added}, modified={edited}, deleted={gone}
328+
)
329+
330+
331+
def test_create_manifest_commit_refreshes_rewritten_unchanged_files(
332+
tmp_path: Path,
333+
) -> None:
334+
"""A rewrite with identical content leaves no stale stat in the index.
335+
336+
A stale entry makes every later status re-read and re-hash the file.
337+
"""
338+
init_test_repo(tmp_path)
339+
manifest = tmp_path / "team-a" / "configmap-app.yaml"
340+
manifest.parent.mkdir()
341+
manifest.write_text("apiVersion: v1\nkind: ConfigMap\n")
342+
first_commit = _commit_all(tmp_path)
343+
344+
manifest.unlink()
345+
manifest.write_text("apiVersion: v1\nkind: ConfigMap\n")
346+
create_manifest_commit(
347+
output_dir=tmp_path,
348+
version="1.2.3",
349+
config_remote="https://example.com/config.git",
350+
config_commit="abc123",
351+
config_subject="Update production config",
352+
generated_files={manifest},
353+
stage_paths={tmp_path / "team-a"},
354+
)
355+
356+
with Repo.discover(tmp_path) as repo:
357+
assert repo.head() == first_commit
358+
entry = repo.open_index()[b"team-a/configmap-app.yaml"]
359+
assert isinstance(entry, IndexEntry)
360+
assert entry.ino == manifest.stat().st_ino
361+
362+
363+
def test_get_git_manifest_changes_tracks_a_symlinked_directory_as_a_file(
364+
tmp_path: Path,
365+
) -> None:
366+
"""Git stores a symlink to a directory as a link, never descending into it."""
367+
init_test_repo(tmp_path)
368+
(tmp_path / "team-a").mkdir()
369+
(tmp_path / "shared").mkdir()
370+
(tmp_path / "shared" / "configmap-shared.yaml").write_text("kind: ConfigMap\n")
371+
_commit_all(tmp_path)
372+
373+
link = tmp_path / "team-a" / "linked.yaml"
374+
link.symlink_to(tmp_path / "shared")
375+
376+
changes = get_git_manifest_changes(tmp_path, {tmp_path / "team-a"})
377+
378+
assert changes == GitManifestChanges(added={link})

0 commit comments

Comments
 (0)