gitrelevance 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ """File change tracking and file history operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ import git as gitmodule
9
+
10
+ from gitrelevance.git.commits import Commit
11
+ from gitrelevance.git.repository import GitRepository
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class FileChange:
16
+ """Represents a change to a file in a commit.
17
+
18
+ Attributes:
19
+ path: Current path of the file
20
+ change_type: Type of change (added, modified, deleted, renamed)
21
+ old_path: Previous path if the file was renamed, None otherwise
22
+ """
23
+
24
+ path: str
25
+ change_type: Literal["added", "modified", "deleted", "renamed"]
26
+ old_path: str | None
27
+
28
+
29
+ class FileOperations:
30
+ """Provides file-related operations for GitRepository.
31
+
32
+ This class wraps a GitRepository to add file-related functionality
33
+ without modifying the main class.
34
+ """
35
+
36
+ def __init__(self, repo: GitRepository) -> None:
37
+ """Initialize with a GitRepository instance.
38
+
39
+ Args:
40
+ repo: The GitRepository to operate on
41
+ """
42
+ self._repo = repo
43
+
44
+ @property
45
+ def _git_repo(self) -> gitmodule.Repo:
46
+ """Access the underlying GitPython repo object."""
47
+ return self._repo._repo # type: ignore[return-value]
48
+
49
+ def file_exists_at_head(self, path: str) -> bool:
50
+ """Check if a file exists at HEAD.
51
+
52
+ Args:
53
+ path: Path to the file relative to repository root
54
+
55
+ Returns:
56
+ True if the file exists at HEAD
57
+ """
58
+ try:
59
+ self._git_repo.head.commit.tree[path]
60
+ return True
61
+ except (KeyError, Exception):
62
+ return False
63
+
64
+ def file_history(self, path: str) -> list[Commit]:
65
+ """Get the history of a file, following renames.
66
+
67
+ Uses ``git log --follow`` (not ``git rev-list --follow``, which is
68
+ unsupported) so that rename tracking works correctly.
69
+
70
+ Args:
71
+ path: Current path to the file
72
+
73
+ Returns:
74
+ List of commits that touched this file, newest first
75
+ """
76
+ try:
77
+ # git log --follow --format=%H emits one sha per commit, newest first.
78
+ # We must call git log (not rev-list); --follow is only a log option.
79
+ output: str = self._git_repo.git.log(
80
+ "--follow",
81
+ "--format=%H",
82
+ "--",
83
+ path,
84
+ )
85
+ except gitmodule.GitCommandError:
86
+ return []
87
+
88
+ if not output.strip():
89
+ return []
90
+
91
+ commits: list[Commit] = []
92
+ for sha in output.strip().splitlines():
93
+ sha = sha.strip()
94
+ if sha:
95
+ try:
96
+ git_commit = self._git_repo.commit(sha)
97
+ commits.append(Commit.from_gitpython(git_commit))
98
+ except Exception:
99
+ continue
100
+ return commits
101
+
102
+ def was_file_deleted(self, path: str) -> bool:
103
+ """Check if a file was deleted (does not exist at HEAD).
104
+
105
+ Args:
106
+ path: Path to check
107
+
108
+ Returns:
109
+ True if the file does not exist at HEAD but existed in history
110
+ """
111
+ if self.file_exists_at_head(path):
112
+ return False
113
+
114
+ # Check whether the path ever appeared in commit history
115
+ try:
116
+ output: str = self._git_repo.git.log(
117
+ "--format=%H",
118
+ "--max-count=1",
119
+ "--diff-filter=D",
120
+ "--",
121
+ path,
122
+ )
123
+ # If git log --diff-filter=D finds anything, the file was deleted
124
+ if output.strip():
125
+ return True
126
+ # Also check if it was ever added at all (it might be renamed out)
127
+ output2: str = self._git_repo.git.log(
128
+ "--follow",
129
+ "--format=%H",
130
+ "--max-count=1",
131
+ "--",
132
+ path,
133
+ )
134
+ return bool(output2.strip())
135
+ except Exception:
136
+ return False
137
+
138
+ def find_renames(self, path: str) -> list[tuple[str, str]]:
139
+ """Find all renames of a file, returning (old_path, new_path) pairs.
140
+
141
+ Args:
142
+ path: Current or historical path of the file
143
+
144
+ Returns:
145
+ List of (old_path, new_path) tuples showing the rename chain,
146
+ in chronological order (oldest rename first).
147
+ """
148
+ try:
149
+ # -M enables rename detection across repository commits
150
+ output: str = self._git_repo.git.log(
151
+ "-M",
152
+ "--diff-filter=R",
153
+ "--name-status",
154
+ "--format=",
155
+ )
156
+ except gitmodule.GitCommandError:
157
+ return []
158
+
159
+ renames: list[tuple[str, str]] = []
160
+ for line in output.splitlines():
161
+ line = line.strip()
162
+ if not line or not line.startswith("R"):
163
+ continue
164
+ parts = line.split("\t")
165
+ if len(parts) >= 3:
166
+ old, new = parts[1].strip(), parts[2].strip()
167
+ if path == old or path == new:
168
+ renames.append((old, new))
169
+
170
+ # git log returns newest-first; reverse to get chronological order
171
+ renames.reverse()
172
+ return renames
173
+
174
+
175
+ # Convenience function to get file operations for a repository
176
+ def get_file_operations(repo: GitRepository) -> FileOperations:
177
+ """Get a FileOperations instance for a repository.
178
+
179
+ Args:
180
+ repo: The GitRepository to get file operations for
181
+
182
+ Returns:
183
+ FileOperations instance
184
+ """
185
+ return FileOperations(repo)
@@ -0,0 +1,217 @@
1
+ """Commit reference index and revert detection for issue tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Protocol
7
+
8
+ from gitrelevance.git.commits import Commit
9
+ from gitrelevance.git.repository import GitRepository
10
+
11
+
12
+ # Cache key for the commit reference index on GitRepository instances
13
+ _INDEX_CACHE_KEY = "_gitrelevance_commit_reference_index"
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Issue-number extraction helpers (private)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ # Matches any #123-style number in a message body
20
+ _HASH_ISSUE_RE = re.compile(r"#(\d+)")
21
+
22
+ # Matches GH-123-style references (unambiguous, never a PR number)
23
+ _GH_ISSUE_RE = re.compile(r"GH-(\d+)", re.IGNORECASE)
24
+
25
+ # Detects PR-number contexts like "pull request #42" or "PR #42".
26
+ # We check whether the 30 characters *before* the # match this pattern.
27
+ _PR_CONTEXT_RE = re.compile(r"(?:pull\s+request|PR)\s+$", re.IGNORECASE)
28
+
29
+
30
+ def _extract_issue_numbers(message: str) -> list[int]:
31
+ """Extract genuine issue numbers from a commit message.
32
+
33
+ Matches ``#123`` and ``GH-123`` patterns, but **excludes** numbers that
34
+ appear in PR-number contexts (e.g. ``pull request #42``, ``PR #42``).
35
+ Those are GitHub pull-request identifiers, *not* issue references, and
36
+ attributing a commit's file changes to a PR number causes evidence
37
+ cross-contamination across unrelated issues.
38
+
39
+ Returns a de-duplicated list of issue numbers in first-seen order.
40
+ """
41
+ numbers: list[int] = []
42
+ seen: set[int] = set()
43
+
44
+ # --- #NNN references ---
45
+ for match in _HASH_ISSUE_RE.finditer(message):
46
+ num = int(match.group(1))
47
+ if num in seen:
48
+ continue
49
+
50
+ start = match.start()
51
+
52
+ # Require a word boundary before '#': whitespace or start-of-string.
53
+ # This prevents matching inside URLs, file paths, or parenthetical
54
+ # PR-title patterns like "Feature (#42)".
55
+ if start > 0 and not message[start - 1].isspace():
56
+ continue
57
+
58
+ # Filter PR-number false positives.
59
+ # In squash-merge / merge-commit messages the subject line often
60
+ # reads "Merge pull request #42 from …". The #42 there is a PR
61
+ # number, not an issue number. We check the ~30 characters of
62
+ # context preceding the # to decide.
63
+ prefix = message[max(0, start - 30) : start]
64
+ if _PR_CONTEXT_RE.search(prefix):
65
+ continue
66
+
67
+ seen.add(num)
68
+ numbers.append(num)
69
+
70
+ # --- GH-NNN references (always unambiguous) ---
71
+ for match in _GH_ISSUE_RE.finditer(message):
72
+ num = int(match.group(1))
73
+ if num not in seen:
74
+ seen.add(num)
75
+ numbers.append(num)
76
+
77
+ return numbers
78
+
79
+
80
+ def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]:
81
+ """Build an index mapping issue numbers to commits that reference them.
82
+
83
+ This function walks the commit log exactly once and caches the result
84
+ on the repository instance for reuse. This is critical for performance
85
+ with large repositories (10,000+ commits).
86
+
87
+ Issue references are detected by patterns like #123 or GH-123 in commit
88
+ messages, where N is an issue number. PR-number references (e.g.
89
+ ``pull request #42``, ``PR #42``) are explicitly excluded to prevent
90
+ evidence cross-contamination.
91
+
92
+ Args:
93
+ repo: The GitRepository to build the index for
94
+
95
+ Returns:
96
+ Dictionary mapping issue numbers to lists of commits referencing them
97
+ """
98
+ # Check if index already cached on repo instance
99
+ if hasattr(repo, _INDEX_CACHE_KEY):
100
+ return getattr(repo, _INDEX_CACHE_KEY)
101
+
102
+ # Build the index by walking commits once
103
+ index: dict[int, list[Commit]] = {}
104
+
105
+ for commit in repo.commits_since(None):
106
+ issue_nums = _extract_issue_numbers(commit.message)
107
+ for num in issue_nums:
108
+ if num not in index:
109
+ index[num] = []
110
+ index[num].append(commit)
111
+
112
+ # Cache the index on the repo instance
113
+ setattr(repo, _INDEX_CACHE_KEY, index)
114
+
115
+ return index
116
+
117
+
118
+ def commits_referencing(repo: GitRepository, issue_number: int) -> list[Commit]:
119
+ """Get all commits that reference a specific issue.
120
+
121
+ Uses the cached index instead of scanning history itself.
122
+
123
+ Args:
124
+ repo: The GitRepository to search
125
+ issue_number: The issue number to find commits for
126
+
127
+ Returns:
128
+ List of commits referencing the issue, newest first
129
+ """
130
+ index = build_commit_reference_index(repo)
131
+ return index.get(issue_number, [])
132
+
133
+
134
+ class RevertDetector(Protocol):
135
+ """Protocol for detecting reverts of commits.
136
+
137
+ Future implementations could use inverse-diff comparison,
138
+ message heuristics, patch similarity, or semantic analysis.
139
+ """
140
+
141
+ def find_reverts_of(self, repo: GitRepository, commit_sha: str) -> list[Commit]:
142
+ """Find commits that revert the specified commit.
143
+
144
+ Args:
145
+ repo: The GitRepository to search
146
+ commit_sha: SHA of the commit to find reverts of
147
+
148
+ Returns:
149
+ List of commits that revert the specified commit
150
+ """
151
+ ...
152
+
153
+
154
+ class GitNativeRevertDetector:
155
+ """MVP implementation: detects only commits Git's own revert machinery produced.
156
+
157
+ This detector uses Git's native revert detection, which relies on
158
+ the parent/trailer relationship that 'git revert' creates. It does
159
+ NOT use free-text commit-message pattern matching or inverse-diff
160
+ comparison.
161
+ """
162
+
163
+ def find_reverts_of(self, repo: GitRepository, commit_sha: str) -> list[Commit]:
164
+ """Find commits that revert the specified commit using Git's native detection.
165
+
166
+ ``git revert`` always inserts exactly this sentence into the commit
167
+ message::
168
+
169
+ This reverts commit <full-sha>.
170
+
171
+ That line is generated by Git's own revert machinery and is the
172
+ authoritative signal we use. We deliberately do NOT match commits
173
+ that merely mention "revert" in their message (the fake-revert test
174
+ exercises this).
175
+
176
+ Args:
177
+ repo: The GitRepository to search
178
+ commit_sha: SHA of the commit to find reverts of
179
+
180
+ Returns:
181
+ List of commits that revert the specified commit, or empty list
182
+ """
183
+ # Resolve the full SHA so partial SHAs compare correctly
184
+ target_commit = repo.get_commit(commit_sha)
185
+ if target_commit is None:
186
+ return []
187
+ full_sha = target_commit.sha
188
+
189
+ reverts: list[Commit] = []
190
+ for commit in repo.commits_since(None):
191
+ # Look for the exact Git-generated trailer line.
192
+ # git revert writes: "This reverts commit <sha>."
193
+ # where <sha> is the full 40-character hex SHA.
194
+ if f"This reverts commit {full_sha}." in commit.message:
195
+ reverts.append(commit)
196
+
197
+ return reverts
198
+
199
+
200
+ # Default detector instance (module-level singleton)
201
+ _default_detector: GitNativeRevertDetector | None = None
202
+
203
+
204
+ def default_revert_detector() -> RevertDetector:
205
+ """Get the default revert detector.
206
+
207
+ Returns the singleton instance of GitNativeRevertDetector.
208
+ This can be swapped out later by reassigning the module-level variable
209
+ or by providing an alternative factory.
210
+
211
+ Returns:
212
+ The default RevertDetector implementation
213
+ """
214
+ global _default_detector
215
+ if _default_detector is None:
216
+ _default_detector = GitNativeRevertDetector()
217
+ return _default_detector
@@ -0,0 +1,161 @@
1
+ """GitRepository class for accessing Git repository data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Iterator
7
+
8
+ import git
9
+ from git.exc import InvalidGitRepositoryError, NoSuchPathError
10
+
11
+ from gitrelevance.git.commits import Commit
12
+
13
+
14
+ class NotAGitRepositoryError(Exception):
15
+ """Raised when the provided path is not a valid Git repository."""
16
+
17
+ def __init__(self, path: str) -> None:
18
+ """Initialize the exception.
19
+
20
+ Args:
21
+ path: The path that was not a valid Git repository
22
+ """
23
+ self.path = path
24
+ super().__init__(f"Not a Git repository: {path}")
25
+
26
+
27
+ class GitRepository:
28
+ """A read-only wrapper around a GitPython repository.
29
+
30
+ This class provides a clean interface for reading Git repository data
31
+ without modifying the .git directory.
32
+
33
+ Attributes:
34
+ path: Absolute path to the repository root
35
+ _repo: The underlying GitPython Repo object
36
+ """
37
+
38
+ def __init__(self, path: str) -> None:
39
+ """Initialize the GitRepository.
40
+
41
+ Args:
42
+ path: Path to the Git repository root (or a directory within it)
43
+
44
+ Raises:
45
+ NotAGitRepositoryError: If the path is not a valid Git repository
46
+ """
47
+ try:
48
+ self._repo = git.Repo(path, search_parent_directories=True)
49
+ self.path = str(self._repo.working_dir)
50
+ except (InvalidGitRepositoryError, NoSuchPathError, git.exc.GitError) as e:
51
+ raise NotAGitRepositoryError(path) from e
52
+
53
+ def current_branch(self) -> str:
54
+ """Get the name of the current branch.
55
+
56
+ Returns:
57
+ Name of the current branch (without 'refs/heads/' prefix)
58
+
59
+ Raises:
60
+ RuntimeError: If in detached HEAD state
61
+ """
62
+ if self._repo.head.is_detached:
63
+ raise RuntimeError("Repository is in detached HEAD state")
64
+ return self._repo.active_branch.name
65
+
66
+ def head_commit(self) -> Commit:
67
+ """Get the commit at HEAD.
68
+
69
+ Returns:
70
+ Commit object for HEAD
71
+
72
+ Raises:
73
+ RuntimeError: If there are no commits yet
74
+ """
75
+ try:
76
+ return Commit.from_gitpython(self._repo.head.commit)
77
+ except (TypeError, AttributeError):
78
+ raise RuntimeError("Repository has no commits yet")
79
+
80
+ def remote_url(self, name: str = "origin") -> str | None:
81
+ """Get the URL of a remote.
82
+
83
+ Args:
84
+ name: Name of the remote (default: "origin")
85
+
86
+ Returns:
87
+ Remote URL if the remote exists, None otherwise
88
+ """
89
+ try:
90
+ remote = self._repo.remote(name)
91
+ if remote.refs:
92
+ return str(remote.url)
93
+ return str(remote.url) if remote.url else None
94
+ except (ValueError, AttributeError):
95
+ return None
96
+
97
+ def get_commit(self, sha: str) -> Commit | None:
98
+ """Get a commit by its SHA.
99
+
100
+ Args:
101
+ sha: Full or abbreviated commit SHA
102
+
103
+ Returns:
104
+ Commit object if found, None otherwise
105
+ """
106
+ try:
107
+ commit = self._repo.commit(sha)
108
+ return Commit.from_gitpython(commit)
109
+ except (git.BadName, ValueError):
110
+ return None
111
+
112
+ def commit_exists(self, sha: str) -> bool:
113
+ """Check if a commit exists.
114
+
115
+ Args:
116
+ sha: Full or abbreviated commit SHA
117
+
118
+ Returns:
119
+ True if the commit exists, False otherwise
120
+ """
121
+ return self.get_commit(sha) is not None
122
+
123
+ def is_ancestor(self, ancestor_sha: str, descendant_sha: str) -> bool:
124
+ """Check if one commit is an ancestor of another.
125
+
126
+ Args:
127
+ ancestor_sha: Potential ancestor commit SHA
128
+ descendant_sha: Potential descendant commit SHA
129
+
130
+ Returns:
131
+ True if ancestor_sha is an ancestor of descendant_sha
132
+
133
+ Raises:
134
+ git.GitCommandError: If either commit doesn't exist
135
+ """
136
+ # Use git merge-base --is-ancestor which returns 0 if true, 1 if false
137
+ try:
138
+ self._repo.git.merge_base("--is-ancestor", ancestor_sha, descendant_sha)
139
+ return True # git command succeeded, so it is an ancestor
140
+ except git.GitCommandError:
141
+ return False # exit code 1 means not an ancestor
142
+
143
+ def commits_since(self, since: datetime | None = None) -> Iterator[Commit]:
144
+ """Get commits since a given datetime.
145
+
146
+ Args:
147
+ since: Optional datetime to filter commits from. If None, returns all commits.
148
+
149
+ Yields:
150
+ Commit objects in reverse chronological order
151
+ """
152
+ kwargs: dict[str, object] = {"reverse": False} # newest first
153
+
154
+ if since is not None:
155
+ # Ensure we have timezone-aware datetime
156
+ if since.tzinfo is None:
157
+ since = since.replace(tzinfo=timezone.utc)
158
+ kwargs["since"] = since
159
+
160
+ for commit in self._repo.iter_commits(**kwargs):
161
+ yield Commit.from_gitpython(commit)
@@ -0,0 +1,5 @@
1
+ """Issue and Pull Request data models package."""
2
+
3
+ from gitrelevance.issues.models import Issue, PullRequest
4
+
5
+ __all__ = ["Issue", "PullRequest"]
@@ -0,0 +1,53 @@
1
+ """Issue and Pull Request data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Literal
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Issue:
12
+ """Represents a GitHub issue.
13
+
14
+ Attributes:
15
+ number: Issue number
16
+ title: Issue title
17
+ body: Issue body text
18
+ state: State of issue ("open" or "closed")
19
+ created_at: Datetime issue was created
20
+ closed_at: Datetime issue was closed, or None if open
21
+ labels: Tuple of label names attached to the issue
22
+ linked_pr_numbers: Tuple of PR numbers linked to this issue
23
+ """
24
+
25
+ number: int
26
+ title: str
27
+ body: str
28
+ state: Literal["open", "closed"]
29
+ created_at: datetime
30
+ closed_at: datetime | None
31
+ labels: tuple[str, ...]
32
+ linked_pr_numbers: tuple[int, ...]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class PullRequest:
37
+ """Represents a GitHub pull request.
38
+
39
+ Attributes:
40
+ number: Pull request number
41
+ title: Pull request title
42
+ merged: Whether the pull request was merged
43
+ merge_commit_sha: SHA of the merge commit, if merged
44
+ closes_issue_numbers: Tuple of issue numbers closed by this PR
45
+ files_changed: Tuple of file paths changed in this PR
46
+ """
47
+
48
+ number: int
49
+ title: str
50
+ merged: bool
51
+ merge_commit_sha: str | None
52
+ closes_issue_numbers: tuple[int, ...]
53
+ files_changed: tuple[str, ...]
gitrelevance/models.py ADDED
@@ -0,0 +1,60 @@
1
+ """Shared core data models for GitRelevance analysis and reporting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Literal
8
+
9
+ from gitrelevance.issues.models import Issue
10
+
11
+
12
+ class Classification(str, Enum):
13
+ """Classification of issue relevance relative to current Git repository state."""
14
+
15
+ RESOLVED = "RESOLVED"
16
+ PROBABLY_RESOLVED = "PROBABLY_RESOLVED"
17
+ STILL_RELEVANT = "STILL_RELEVANT"
18
+ OBSOLETE = "OBSOLETE"
19
+ UNKNOWN = "UNKNOWN"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class EvidenceItem:
24
+ """Individual piece of evidence supporting or opposing issue relevance.
25
+
26
+ Attributes:
27
+ description: Human-readable explanation of the evidence.
28
+ weight: Signed integer weight indicating evidence strength (positive = resolved, negative = obsolete/unresolved).
29
+ category: Evidence category ("strong", "medium", or "obsolescence").
30
+ source_ref: Optional reference string (e.g. commit SHA or PR number).
31
+ """
32
+
33
+ description: str
34
+ weight: int
35
+ category: Literal["strong", "medium", "obsolescence"]
36
+ source_ref: str | None = None
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class AnalysisResult:
41
+ """Complete analysis result for a single issue.
42
+
43
+ Attributes:
44
+ issue: The issue being analyzed.
45
+ classification: Final classification decision.
46
+ confidence: Evidence-strength score clamped to [0.05, 0.98].
47
+ This is a relative evidence-strength heuristic, NOT a calibrated
48
+ statistical probability. A higher value means more and stronger
49
+ evidence supports the classification, not that the classification
50
+ is more likely to be correct in a Bayesian sense.
51
+ evidence: Tuple of collected evidence items explaining the decision.
52
+ Each item carries a human-readable description, a signed weight,
53
+ a category, and an optional source_ref (short commit SHA or
54
+ "PR #N") so the result is self-contained for display.
55
+ """
56
+
57
+ issue: Issue
58
+ classification: Classification
59
+ confidence: float
60
+ evidence: tuple[EvidenceItem, ...]
@@ -0,0 +1,8 @@
1
+ """Output rendering modules for terminal and JSON output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gitrelevance.output.json import to_json
6
+ from gitrelevance.output.terminal import TerminalRenderer
7
+
8
+ __all__ = ["TerminalRenderer", "to_json"]