|
3 | 3 | """Git utilities for manifest generation and versioning.""" |
4 | 4 |
|
5 | 5 | import logging |
| 6 | +import os |
6 | 7 | from collections.abc import Iterable |
7 | 8 | from dataclasses import dataclass, field |
8 | 9 | from pathlib import Path |
9 | 10 | from typing import Protocol, cast |
10 | 11 |
|
11 | 12 | from dulwich import porcelain |
| 13 | +from dulwich.diff_tree import CHANGE_ADD, CHANGE_DELETE, TreeChange, tree_changes |
12 | 14 | 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 |
14 | 29 | from dulwich.refs import Ref |
15 | 30 | from dulwich.repo import Repo |
16 | 31 |
|
@@ -183,39 +198,52 @@ def is_git_dirty(path: Path) -> bool: |
183 | 198 | raise RuntimeError(f"Failed to check git status for {path}: {e}") from e |
184 | 199 |
|
185 | 200 |
|
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. |
188 | 205 |
|
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. |
193 | 209 | """ |
194 | 210 | try: |
195 | 211 | repo = Repo.discover(path) |
196 | 212 | try: |
197 | 213 | repo_root = Path(repo.path).resolve() |
198 | 214 | 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 | + ) |
211 | 237 |
|
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 |
216 | 244 | 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)) |
219 | 247 | return changes |
220 | 248 | finally: |
221 | 249 | repo.close() |
@@ -282,20 +310,72 @@ def _relative_to_repo(repo: Repo, path: Path) -> Path: |
282 | 310 | return path.resolve().relative_to(repo_root) |
283 | 311 |
|
284 | 312 |
|
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 |
299 | 379 |
|
300 | 380 |
|
301 | 381 | def create_manifest_commit( |
@@ -333,17 +413,19 @@ def create_manifest_commit( |
333 | 413 | repo = Repo.discover(output_dir) |
334 | 414 | try: |
335 | 415 | 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 | + ): |
347 | 429 | logger.info("There is nothing to commit.") |
348 | 430 | return |
349 | 431 |
|
@@ -373,13 +455,3 @@ def create_manifest_commit( |
373 | 455 | logger.info("Created manifest commit in %s", output_dir) |
374 | 456 | except Exception as e: |
375 | 457 | 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 |
|
0 commit comments