gitrelevance 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (62) hide show
  1. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/PKG-INFO +6 -6
  2. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/README.md +1 -1
  3. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/pyproject.toml +5 -5
  4. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/matcher.py +31 -1
  5. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/cli/commands.py +35 -5
  6. gitrelevance-0.1.4/src/gitrelevance/config.py +56 -0
  7. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/files.py +66 -26
  8. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/history.py +21 -10
  9. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/repository.py +13 -2
  10. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/cli/test_commands.py +30 -0
  11. gitrelevance-0.1.2/src/gitrelevance/config.py +0 -27
  12. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/.github/workflows/ci.yml +0 -0
  13. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/.gitignore +0 -0
  14. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/LICENSE +0 -0
  15. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/pytest +0 -0
  16. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/__init__.py +0 -0
  17. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/__init__.py +0 -0
  18. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/classifier.py +0 -0
  19. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/confidence.py +0 -0
  20. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/current_state.py +0 -0
  21. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/engine.py +0 -0
  22. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/analysis/evidence.py +0 -0
  23. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/cli/__init__.py +0 -0
  24. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/__init__.py +0 -0
  25. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/git/commits.py +0 -0
  26. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/issues/__init__.py +0 -0
  27. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/issues/models.py +0 -0
  28. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/models.py +0 -0
  29. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/output/__init__.py +0 -0
  30. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/output/json.py +0 -0
  31. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/output/terminal.py +0 -0
  32. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/providers/__init__.py +0 -0
  33. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/providers/base.py +0 -0
  34. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/src/gitrelevance/providers/github.py +0 -0
  35. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/__init__.py +0 -0
  36. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/__init__.py +0 -0
  37. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_classifier.py +0 -0
  38. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_confidence.py +0 -0
  39. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_current_state.py +0 -0
  40. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_evidence.py +0 -0
  41. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_explainability.py +0 -0
  42. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/analysis/test_matcher.py +0 -0
  43. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/fixtures/__init__.py +0 -0
  44. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/fixtures/repo_builder.py +0 -0
  45. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/__init__.py +0 -0
  46. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/test_files.py +0 -0
  47. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/test_history.py +0 -0
  48. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/git/test_repository.py +0 -0
  49. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/output/test_json.py +0 -0
  50. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/output/test_terminal.py +0 -0
  51. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/providers/test_github.py +0 -0
  52. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/__init__.py +0 -0
  53. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/conftest.py +0 -0
  54. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_cross_contamination.py +0 -0
  55. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_multiple_prs.py +0 -0
  56. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_obsolete.py +0 -0
  57. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_renamed_not_deleted.py +0 -0
  58. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_resolved.py +0 -0
  59. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_reverted.py +0 -0
  60. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_still_relevant.py +0 -0
  61. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenario_unknown.py +0 -0
  62. {gitrelevance-0.1.2 → gitrelevance-0.1.4}/tests/scenarios/test_scenarios.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitrelevance
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Analyze whether historical GitHub issues are still relevant to a codebase, using Git history as evidence
5
- Project-URL: Homepage, https://github.com/gitrelevance/gitrelevance
6
- Project-URL: Repository, https://github.com/gitrelevance/gitrelevance
7
- Project-URL: Issues, https://github.com/gitrelevance/gitrelevance/issues
8
- Author: GitRelevance Contributors
5
+ Project-URL: Homepage, https://github.com/Aditya30ag/gitrelevance
6
+ Project-URL: Repository, https://github.com/Aditya30ag/gitrelevance
7
+ Project-URL: Issues, https://github.com/Aditya30ag/gitrelevance/issues
8
+ Author: Aditya30ag
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Classifier: Development Status :: 4 - Beta
@@ -66,7 +66,7 @@ pip install gitrelevance
66
66
 
67
67
  For development:
68
68
  ```bash
69
- git clone https://github.com/gitrelevance/gitrelevance.git
69
+ git clone https://github.com/Aditya30ag/gitrelevance.git
70
70
  cd gitrelevance
71
71
  pip install -e ".[dev]"
72
72
  ```
@@ -31,7 +31,7 @@ pip install gitrelevance
31
31
 
32
32
  For development:
33
33
  ```bash
34
- git clone https://github.com/gitrelevance/gitrelevance.git
34
+ git clone https://github.com/Aditya30ag/gitrelevance.git
35
35
  cd gitrelevance
36
36
  pip install -e ".[dev]"
37
37
  ```
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gitrelevance"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Analyze whether historical GitHub issues are still relevant to a codebase, using Git history as evidence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
12
12
  authors = [
13
- { name = "GitRelevance Contributors" },
13
+ { name = "Aditya30ag" },
14
14
  ]
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
@@ -46,9 +46,9 @@ dev = [
46
46
  gitrelevance = "gitrelevance.cli.commands:app"
47
47
 
48
48
  [project.urls]
49
- Homepage = "https://github.com/gitrelevance/gitrelevance"
50
- Repository = "https://github.com/gitrelevance/gitrelevance"
51
- Issues = "https://github.com/gitrelevance/gitrelevance/issues"
49
+ Homepage = "https://github.com/Aditya30ag/gitrelevance"
50
+ Repository = "https://github.com/Aditya30ag/gitrelevance"
51
+ Issues = "https://github.com/Aditya30ag/gitrelevance/issues"
52
52
 
53
53
  [tool.hatch.build.targets.wheel]
54
54
  packages = ["src/gitrelevance"]
@@ -188,6 +188,9 @@ def build_all_match_sets(
188
188
  ) -> dict[int, MatchSet]:
189
189
  """Build match sets for all given issues, fetching pull requests only once.
190
190
 
191
+ Pre-indexes pull requests by closing issue numbers and PR numbers to correlate
192
+ issues in O(I + P) time rather than O(I * P).
193
+
191
194
  Args:
192
195
  issues: List of issues to correlate.
193
196
  repo: The local Git repository wrapper.
@@ -197,4 +200,31 @@ def build_all_match_sets(
197
200
  Dictionary mapping issue number to its corresponding MatchSet.
198
201
  """
199
202
  prs = provider.get_pull_requests(state="all")
200
- return {issue.number: build_match_set(issue, repo, prs) for issue in issues}
203
+
204
+ # Invert PR index for O(1) issue-to-PR lookups
205
+ issue_to_closing_prs: dict[int, list[PullRequest]] = {}
206
+ pr_by_number: dict[int, PullRequest] = {}
207
+ for pr in prs:
208
+ pr_by_number[pr.number] = pr
209
+ for issue_num in pr.closes_issue_numbers:
210
+ issue_to_closing_prs.setdefault(issue_num, []).append(pr)
211
+
212
+ match_sets: dict[int, MatchSet] = {}
213
+ for issue in issues:
214
+ # Collect candidate PRs for this issue
215
+ candidate_prs: list[PullRequest] = []
216
+ seen_cand_nums: set[int] = set()
217
+
218
+ for pr in issue_to_closing_prs.get(issue.number, []):
219
+ if pr.number not in seen_cand_nums:
220
+ seen_cand_nums.add(pr.number)
221
+ candidate_prs.append(pr)
222
+
223
+ for pr_num in issue.linked_pr_numbers:
224
+ if pr_num in pr_by_number and pr_num not in seen_cand_nums:
225
+ seen_cand_nums.add(pr_num)
226
+ candidate_prs.append(pr_by_number[pr_num])
227
+
228
+ match_sets[issue.number] = build_match_set(issue, repo, candidate_prs)
229
+
230
+ return match_sets
@@ -75,6 +75,13 @@ def analyze(
75
75
  "--since",
76
76
  help="Filter issues created on or after date (YYYY-MM-DD).",
77
77
  ),
78
+ token: Optional[str] = typer.Option(
79
+ None,
80
+ "--token",
81
+ "-t",
82
+ "--api-key",
83
+ help="GitHub Personal Access Token or API key for authenticated requests.",
84
+ ),
78
85
  json_output: bool = typer.Option(
79
86
  False,
80
87
  "--json",
@@ -122,18 +129,18 @@ def analyze(
122
129
  owner, repo_name = parsed
123
130
 
124
131
  # 3. Construct provider
125
- token = config.load_github_token()
132
+ resolved_token = token or config.load_github_token()
126
133
  if _provider_override is not None:
127
134
  provider = _provider_override
128
135
  elif _provider_factory is not None:
129
- provider = _provider_factory(owner, repo_name, token)
136
+ provider = _provider_factory(owner, repo_name, resolved_token)
130
137
  else:
131
- if not token and not json_output:
138
+ if not resolved_token and not json_output:
132
139
  err_console.print(
133
- "[yellow]Note:[/yellow] Running in unauthenticated mode (GITHUB_TOKEN not set). "
140
+ "[yellow]Note:[/yellow] Running in unauthenticated mode (GITHUB_TOKEN / --token not set). "
134
141
  "GitHub API rate limit is 60 requests/hour."
135
142
  )
136
- provider = GitHubProvider(owner=owner, repo=repo_name, token=token)
143
+ provider = GitHubProvider(owner=owner, repo=repo_name, token=resolved_token)
137
144
 
138
145
  # Validate state option
139
146
  state_normalized = state.lower()
@@ -253,6 +260,29 @@ def analyze(
253
260
  raise typer.Exit(code=1)
254
261
 
255
262
 
263
+ @app.command(name="auth")
264
+ def auth(
265
+ token: Optional[str] = typer.Option(
266
+ None,
267
+ "--token",
268
+ "-t",
269
+ "--api-key",
270
+ help="GitHub Personal Access Token or API key to store.",
271
+ ),
272
+ ) -> None:
273
+ """Configure and store GitHub Personal Access Token / API key."""
274
+ console = Console()
275
+ if not token:
276
+ token = typer.prompt("Enter your GitHub Personal Access Token", hide_input=True)
277
+
278
+ if token and token.strip():
279
+ saved_path = config.save_github_token(token.strip())
280
+ console.print(f"[green]✓ Token saved successfully to {saved_path}[/green]")
281
+ else:
282
+ console.print("[red]Error: Token cannot be empty.[/red]")
283
+ raise typer.Exit(code=1)
284
+
285
+
256
286
  def main() -> None:
257
287
  """CLI entry point function."""
258
288
  app()
@@ -0,0 +1,56 @@
1
+ """Configuration and environment loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ CONFIG_DIR = Path.home() / ".gitrelevance"
9
+ TOKEN_FILE = CONFIG_DIR / "token"
10
+
11
+ try:
12
+ from dotenv import load_dotenv # type: ignore[import-not-found]
13
+
14
+ # Try loading .env if python-dotenv is installed
15
+ load_dotenv()
16
+ except ImportError:
17
+ pass
18
+
19
+
20
+ def load_github_token() -> str | None:
21
+ """Load GitHub Personal Access Token or API key.
22
+
23
+ Checks environment variables (GITHUB_TOKEN, GH_TOKEN, GITHUB_API_KEY)
24
+ and falls back to persistent token file (~/.gitrelevance/token).
25
+
26
+ Returns:
27
+ The token string if found, None otherwise.
28
+ """
29
+ for var in ("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_API_KEY"):
30
+ token = os.getenv(var)
31
+ if token and token.strip():
32
+ return token.strip()
33
+
34
+ if TOKEN_FILE.is_file():
35
+ try:
36
+ content = TOKEN_FILE.read_text(encoding="utf-8").strip()
37
+ if content:
38
+ return content
39
+ except OSError:
40
+ pass
41
+
42
+ return None
43
+
44
+
45
+ def save_github_token(token: str) -> Path:
46
+ """Save GitHub Personal Access Token or API key to user config directory.
47
+
48
+ Args:
49
+ token: The token/API key string to save.
50
+
51
+ Returns:
52
+ Path to the saved token file.
53
+ """
54
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
55
+ TOKEN_FILE.write_text(token.strip(), encoding="utf-8")
56
+ return TOKEN_FILE
@@ -46,6 +46,54 @@ class FileOperations:
46
46
  """Access the underlying GitPython repo object."""
47
47
  return self._repo._repo # type: ignore[return-value]
48
48
 
49
+ def _get_head_files(self) -> set[str]:
50
+ """Get or compute cached set of all file paths present at HEAD."""
51
+ if not hasattr(self._repo, "_head_files_cache"):
52
+ head_files: set[str] = set()
53
+ try:
54
+ head_commit = self._git_repo.head.commit
55
+ for item in head_commit.tree.traverse():
56
+ if item.type == "blob":
57
+ head_files.add(item.path)
58
+ except Exception:
59
+ pass
60
+ setattr(self._repo, "_head_files_cache", head_files)
61
+ return getattr(self._repo, "_head_files_cache")
62
+
63
+ def _get_rename_index(self) -> dict[str, list[tuple[str, str]]]:
64
+ """Get or compute cached repository-wide rename index."""
65
+ if not hasattr(self._repo, "_rename_index_cache"):
66
+ rename_index: dict[str, list[tuple[str, str]]] = {}
67
+ all_renames: list[tuple[str, str]] = []
68
+ try:
69
+ output: str = self._git_repo.git.log(
70
+ "-M",
71
+ "--diff-filter=R",
72
+ "--name-status",
73
+ "--format=",
74
+ )
75
+ for line in output.splitlines():
76
+ line = line.strip()
77
+ if not line or not line.startswith("R"):
78
+ continue
79
+ parts = line.split("\t")
80
+ if len(parts) >= 3:
81
+ old, new = parts[1].strip(), parts[2].strip()
82
+ all_renames.append((old, new))
83
+ except gitmodule.GitCommandError:
84
+ pass
85
+
86
+ # git log returns newest-first; reverse to get chronological order
87
+ all_renames.reverse()
88
+
89
+ for old, new in all_renames:
90
+ rename_index.setdefault(old, []).append((old, new))
91
+ if new != old:
92
+ rename_index.setdefault(new, []).append((old, new))
93
+
94
+ setattr(self._repo, "_rename_index_cache", rename_index)
95
+ return getattr(self._repo, "_rename_index_cache")
96
+
49
97
  def file_exists_at_head(self, path: str) -> bool:
50
98
  """Check if a file exists at HEAD.
51
99
 
@@ -55,6 +103,9 @@ class FileOperations:
55
103
  Returns:
56
104
  True if the file exists at HEAD
57
105
  """
106
+ head_files = self._get_head_files()
107
+ if head_files:
108
+ return path in head_files
58
109
  try:
59
110
  self._git_repo.head.commit.tree[path]
60
111
  return True
@@ -111,6 +162,14 @@ class FileOperations:
111
162
  if self.file_exists_at_head(path):
112
163
  return False
113
164
 
165
+ deleted_cache = getattr(self._repo, "_was_deleted_cache", None)
166
+ if deleted_cache is None:
167
+ deleted_cache = {}
168
+ setattr(self._repo, "_was_deleted_cache", deleted_cache)
169
+
170
+ if path in deleted_cache:
171
+ return deleted_cache[path]
172
+
114
173
  # Check whether the path ever appeared in commit history
115
174
  try:
116
175
  output: str = self._git_repo.git.log(
@@ -122,6 +181,7 @@ class FileOperations:
122
181
  )
123
182
  # If git log --diff-filter=D finds anything, the file was deleted
124
183
  if output.strip():
184
+ deleted_cache[path] = True
125
185
  return True
126
186
  # Also check if it was ever added at all (it might be renamed out)
127
187
  output2: str = self._git_repo.git.log(
@@ -131,8 +191,11 @@ class FileOperations:
131
191
  "--",
132
192
  path,
133
193
  )
134
- return bool(output2.strip())
194
+ res = bool(output2.strip())
195
+ deleted_cache[path] = res
196
+ return res
135
197
  except Exception:
198
+ deleted_cache[path] = False
136
199
  return False
137
200
 
138
201
  def find_renames(self, path: str) -> list[tuple[str, str]]:
@@ -145,31 +208,8 @@ class FileOperations:
145
208
  List of (old_path, new_path) tuples showing the rename chain,
146
209
  in chronological order (oldest rename first).
147
210
  """
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
211
+ index = self._get_rename_index()
212
+ return list(index.get(path, []))
173
213
 
174
214
 
175
215
  # Convenience function to get file operations for a repository
@@ -11,6 +11,7 @@ from gitrelevance.git.repository import GitRepository
11
11
 
12
12
  # Cache key for the commit reference index on GitRepository instances
13
13
  _INDEX_CACHE_KEY = "_gitrelevance_commit_reference_index"
14
+ _REVERT_INDEX_CACHE_KEY = "_gitrelevance_revert_index"
14
15
 
15
16
  # ---------------------------------------------------------------------------
16
17
  # Issue-number extraction helpers (private)
@@ -22,6 +23,9 @@ _HASH_ISSUE_RE = re.compile(r"#(\d+)")
22
23
  # Matches GH-123-style references (unambiguous, never a PR number)
23
24
  _GH_ISSUE_RE = re.compile(r"GH-(\d+)", re.IGNORECASE)
24
25
 
26
+ # Matches standard git revert trailer: "This reverts commit <sha>."
27
+ _REVERT_TRAILER_RE = re.compile(r"This reverts commit ([0-9a-fA-F]{40})\.")
28
+
25
29
  # Detects PR-number contexts like "pull request #42" or "PR #42".
26
30
  # We check whether the 30 characters *before* the # match this pattern.
27
31
  _PR_CONTEXT_RE = re.compile(r"(?:pull\s+request|PR)\s+$", re.IGNORECASE)
@@ -89,6 +93,8 @@ def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]
89
93
  ``pull request #42``, ``PR #42``) are explicitly excluded to prevent
90
94
  evidence cross-contamination.
91
95
 
96
+ Also populates the revert index mapping reverted SHAs to revert commits.
97
+
92
98
  Args:
93
99
  repo: The GitRepository to build the index for
94
100
 
@@ -99,8 +105,9 @@ def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]
99
105
  if hasattr(repo, _INDEX_CACHE_KEY):
100
106
  return getattr(repo, _INDEX_CACHE_KEY)
101
107
 
102
- # Build the index by walking commits once
108
+ # Build the indices by walking commits once
103
109
  index: dict[int, list[Commit]] = {}
110
+ revert_index: dict[str, list[Commit]] = {}
104
111
 
105
112
  for commit in repo.commits_since(None):
106
113
  issue_nums = _extract_issue_numbers(commit.message)
@@ -109,8 +116,15 @@ def build_commit_reference_index(repo: GitRepository) -> dict[int, list[Commit]]
109
116
  index[num] = []
110
117
  index[num].append(commit)
111
118
 
112
- # Cache the index on the repo instance
119
+ for match in _REVERT_TRAILER_RE.finditer(commit.message):
120
+ reverted_sha = match.group(1)
121
+ if reverted_sha not in revert_index:
122
+ revert_index[reverted_sha] = []
123
+ revert_index[reverted_sha].append(commit)
124
+
125
+ # Cache the indices on the repo instance
113
126
  setattr(repo, _INDEX_CACHE_KEY, index)
127
+ setattr(repo, _REVERT_INDEX_CACHE_KEY, revert_index)
114
128
 
115
129
  return index
116
130
 
@@ -186,15 +200,12 @@ class GitNativeRevertDetector:
186
200
  return []
187
201
  full_sha = target_commit.sha
188
202
 
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)
203
+ revert_index = getattr(repo, _REVERT_INDEX_CACHE_KEY, None)
204
+ if revert_index is None:
205
+ build_commit_reference_index(repo)
206
+ revert_index = getattr(repo, _REVERT_INDEX_CACHE_KEY, {})
196
207
 
197
- return reverts
208
+ return list(revert_index.get(full_sha, []))
198
209
 
199
210
 
200
211
  # Default detector instance (module-level singleton)
@@ -47,6 +47,7 @@ class GitRepository:
47
47
  try:
48
48
  self._repo = git.Repo(path, search_parent_directories=True)
49
49
  self.path = str(self._repo.working_dir)
50
+ self._ancestor_cache: dict[tuple[str, str], bool] = {}
50
51
  except (InvalidGitRepositoryError, NoSuchPathError, git.exc.GitError) as e:
51
52
  raise NotAGitRepositoryError(path) from e
52
53
 
@@ -133,12 +134,22 @@ class GitRepository:
133
134
  Raises:
134
135
  git.GitCommandError: If either commit doesn't exist
135
136
  """
137
+ cache = getattr(self, "_ancestor_cache", None)
138
+ if cache is not None:
139
+ cached = cache.get((ancestor_sha, descendant_sha))
140
+ if cached is not None:
141
+ return cached
142
+
136
143
  # Use git merge-base --is-ancestor which returns 0 if true, 1 if false
137
144
  try:
138
145
  self._repo.git.merge_base("--is-ancestor", ancestor_sha, descendant_sha)
139
- return True # git command succeeded, so it is an ancestor
146
+ res = True # git command succeeded, so it is an ancestor
140
147
  except git.GitCommandError:
141
- return False # exit code 1 means not an ancestor
148
+ res = False # exit code 1 means not an ancestor
149
+
150
+ if cache is not None:
151
+ cache[(ancestor_sha, descendant_sha)] = res
152
+ return res
142
153
 
143
154
  def commits_since(self, since: datetime | None = None) -> Iterator[Commit]:
144
155
  """Get commits since a given datetime.
@@ -160,5 +160,35 @@ def test_cli_since_filter() -> None:
160
160
  data = json.loads(result.output)
161
161
  assert data["total_issues"] == 1
162
162
  assert data["results"][0]["issue"]["number"] == 2
163
+ finally:
164
+ builder.cleanup()
165
+
166
+
167
+ def test_cli_token_option_and_auth_command(tmp_path, monkeypatch) -> None:
168
+ """--token flag and auth command work properly."""
169
+ # Test auth command
170
+ custom_token_file = tmp_path / "token"
171
+ monkeypatch.setattr("gitrelevance.config.TOKEN_FILE", custom_token_file)
172
+ monkeypatch.setattr("gitrelevance.config.CONFIG_DIR", tmp_path)
173
+
174
+ result = runner.invoke(app, ["auth", "--token", "ghp_mock_token_12345"])
175
+ assert result.exit_code == 0
176
+ assert "Token saved successfully" in result.output
177
+ assert custom_token_file.read_text(encoding="utf-8").strip() == "ghp_mock_token_12345"
178
+
179
+ # Test analyze with --token
180
+ builder = (
181
+ RepoBuilder()
182
+ .commit("Initial commit", files={"README.md": "readme"})
183
+ .add_remote("origin", "https://github.com/testowner/testrepo.git")
184
+ )
185
+ path = builder.build()
186
+ try:
187
+ issue = make_issue(number=1, title="Test", state="closed")
188
+ fake_provider = FakeCLIProvider(issues=[issue])
189
+ set_provider_override(fake_provider)
190
+
191
+ res = runner.invoke(app, ["analyze", "--path", path, "--token", "ghp_custom_token", "--json"])
192
+ assert res.exit_code == 0
163
193
  finally:
164
194
  builder.cleanup()
@@ -1,27 +0,0 @@
1
- """Configuration and environment loading."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
-
7
- try:
8
- from dotenv import load_dotenv # type: ignore[import-not-found]
9
-
10
- # Try loading .env if python-dotenv is installed
11
- load_dotenv()
12
- except ImportError:
13
- pass
14
-
15
-
16
- def load_github_token() -> str | None:
17
- """Load GitHub Personal Access Token from environment.
18
-
19
- Reads GITHUB_TOKEN environment variable. Returns None if not set.
20
-
21
- Returns:
22
- The token string if set in environment, None otherwise.
23
- """
24
- token = os.getenv("GITHUB_TOKEN")
25
- if not token or not token.strip():
26
- return None
27
- return token.strip()
File without changes
File without changes
File without changes