Detect changes via git. Returns None if not in a git repo or if no previous commit is recorded (so the caller can fall through to mtime). Checks committed changes (diff against stored commit_id), staged changes (``index.diff('HEAD')``), and unstaged/untracked changes.
(
repo_path: Path,
metadata: Dict[str, Any],
output_dir: Path | None = None,
)
| 88 | |
| 89 | |
| 90 | def _detect_via_git( |
| 91 | repo_path: Path, |
| 92 | metadata: Dict[str, Any], |
| 93 | output_dir: Path | None = None, |
| 94 | ) -> Optional[Dict[str, Any]]: |
| 95 | """Detect changes via git. Returns None if not in a git repo or if no |
| 96 | previous commit is recorded (so the caller can fall through to mtime). |
| 97 | |
| 98 | Checks committed changes (diff against stored commit_id), staged changes |
| 99 | (``index.diff('HEAD')``), and unstaged/untracked changes. |
| 100 | """ |
| 101 | try: |
| 102 | import git |
| 103 | repo = git.Repo(repo_path, search_parent_directories=True) |
| 104 | except Exception: |
| 105 | return None |
| 106 | |
| 107 | prev_commit = metadata.get("generation_info", {}).get("commit_id") |
| 108 | if not prev_commit: |
| 109 | return None # No baseline to compare; let mtime fallback handle it |
| 110 | |
| 111 | try: |
| 112 | current_commit = repo.head.commit.hexsha |
| 113 | except Exception: |
| 114 | return None |
| 115 | |
| 116 | # Compute subpath prefix for monorepo support. |
| 117 | # Git diff returns paths relative to the git root, but component IDs |
| 118 | # use paths relative to repo_path. Strip the prefix so they align. |
| 119 | git_root = Path(repo.working_dir).resolve() |
| 120 | repo_root = repo_path.resolve() |
| 121 | try: |
| 122 | subpath = repo_root.relative_to(git_root).as_posix() |
| 123 | except ValueError: |
| 124 | subpath = "" |
| 125 | if subpath == ".": |
| 126 | subpath = "" |
| 127 | |
| 128 | # Output-dir prefix (relative to repo_path) so generated docs, metadata |
| 129 | # and session workspace files never count as source changes. |
| 130 | output_dir_rel = "" |
| 131 | if output_dir is not None: |
| 132 | try: |
| 133 | output_dir_rel = Path(output_dir).resolve().relative_to(repo_root).as_posix() |
| 134 | if output_dir_rel == ".": |
| 135 | output_dir_rel = "" |
| 136 | except (ValueError, TypeError): |
| 137 | pass |
| 138 | |
| 139 | def _normalize(p: str) -> Optional[str]: |
| 140 | """Strip the monorepo subpath and drop generated/non-source paths.""" |
| 141 | if subpath: |
| 142 | if not p.startswith(subpath + "/"): |
| 143 | return None # outside target subdirectory |
| 144 | p = p[len(subpath) + 1:] |
| 145 | if p.startswith(".codewiki/"): |
| 146 | return None |
| 147 | if output_dir_rel and (p == output_dir_rel or p.startswith(output_dir_rel + "/")): |
no test coverage detected