srcly 0.2.0__tar.gz → 0.3.0__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 (107) hide show
  1. {srcly-0.2.0 → srcly-0.3.0}/.gitignore +3 -0
  2. {srcly-0.2.0 → srcly-0.3.0}/PKG-INFO +1 -1
  3. {srcly-0.2.0 → srcly-0.3.0}/app/routers/compare.py +61 -2
  4. {srcly-0.2.0 → srcly-0.3.0}/app/run.py +19 -5
  5. srcly-0.3.0/app/services/compare/git_refs.py +429 -0
  6. srcly-0.3.0/app/services/compare/github.py +177 -0
  7. {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/jobs.py +51 -2
  8. {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/service.py +89 -13
  9. {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/snapshots.py +55 -9
  10. srcly-0.3.0/app/services/compare/sources.py +216 -0
  11. srcly-0.3.0/app/static/assets/CodeModal-CTBbqRv_.js +29 -0
  12. srcly-0.3.0/app/static/assets/CompareApp-BSOMRmdx.js +3 -0
  13. srcly-0.3.0/app/static/assets/DependencyView-dzVV2_Up.js +1 -0
  14. srcly-0.3.0/app/static/assets/index-BVNn0QLM.js +2 -0
  15. srcly-0.3.0/app/static/assets/index-D2ClTmny.css +1 -0
  16. srcly-0.3.0/app/static/assets/layout-DSK4VztQ.js +1 -0
  17. {srcly-0.2.0 → srcly-0.3.0}/app/static/index.html +2 -2
  18. {srcly-0.2.0 → srcly-0.3.0}/pyproject.toml +1 -1
  19. srcly-0.2.0/app/services/compare/git_refs.py +0 -185
  20. srcly-0.2.0/app/static/assets/CodeModal-BC4-51lF.js +0 -29
  21. srcly-0.2.0/app/static/assets/CompareApp-CtVSUR0J.js +0 -2
  22. srcly-0.2.0/app/static/assets/DependencyView-Cz2blz64.js +0 -1
  23. srcly-0.2.0/app/static/assets/index-F49dMNqG.css +0 -1
  24. srcly-0.2.0/app/static/assets/index-mm3bqu0K.js +0 -2
  25. srcly-0.2.0/app/static/assets/layout-CCJmnknz.js +0 -1
  26. {srcly-0.2.0 → srcly-0.3.0}/README.md +0 -0
  27. {srcly-0.2.0 → srcly-0.3.0}/app/.gitignore +0 -0
  28. {srcly-0.2.0 → srcly-0.3.0}/app/__init__.py +0 -0
  29. {srcly-0.2.0 → srcly-0.3.0}/app/config.py +0 -0
  30. {srcly-0.2.0 → srcly-0.3.0}/app/main.py +0 -0
  31. {srcly-0.2.0 → srcly-0.3.0}/app/models.py +0 -0
  32. {srcly-0.2.0 → srcly-0.3.0}/app/routers/analysis.py +0 -0
  33. {srcly-0.2.0 → srcly-0.3.0}/app/routers/files.py +0 -0
  34. {srcly-0.2.0 → srcly-0.3.0}/app/routers/scans.py +0 -0
  35. {srcly-0.2.0 → srcly-0.3.0}/app/services/analysis.py +0 -0
  36. {srcly-0.2.0 → srcly-0.3.0}/app/services/analysis_types.py +0 -0
  37. {srcly-0.2.0 → srcly-0.3.0}/app/services/cache.py +0 -0
  38. {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/__init__.py +0 -0
  39. {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/diff.py +0 -0
  40. {srcly-0.2.0 → srcly-0.3.0}/app/services/css/css_analysis.py +0 -0
  41. {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/__init__.py +0 -0
  42. {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/common.py +0 -0
  43. {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/graph.py +0 -0
  44. {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/python_deps.py +0 -0
  45. {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/tsconfig.py +0 -0
  46. {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/typescript_deps.py +0 -0
  47. {srcly-0.2.0 → srcly-0.3.0}/app/services/ipynb/ipynb_analysis.py +0 -0
  48. {srcly-0.2.0 → srcly-0.3.0}/app/services/markdown/markdown_analysis.py +0 -0
  49. {srcly-0.2.0 → srcly-0.3.0}/app/services/python/python_analysis.py +0 -0
  50. {srcly-0.2.0 → srcly-0.3.0}/app/services/reporting.py +0 -0
  51. {srcly-0.2.0 → srcly-0.3.0}/app/services/scan_jobs.py +0 -0
  52. {srcly-0.2.0 → srcly-0.3.0}/app/services/scan_progress.py +0 -0
  53. {srcly-0.2.0 → srcly-0.3.0}/app/services/scan_workers.py +0 -0
  54. {srcly-0.2.0 → srcly-0.3.0}/app/services/typescript/typescript_analysis.py +0 -0
  55. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
  56. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/css-PZBBrkI0.js +0 -0
  57. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
  58. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
  59. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/html-DOf0XnR2.js +0 -0
  60. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  61. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  62. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  63. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  64. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  65. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  66. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  67. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  68. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  69. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  70. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  71. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  72. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  73. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  74. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  75. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  76. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  77. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  78. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  79. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  80. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  81. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  82. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  83. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  84. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  85. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  86. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  87. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  88. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  89. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  90. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  91. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  92. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  93. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  94. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  95. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  96. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  97. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
  98. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/json-B8JryHcM.js +0 -0
  99. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
  100. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
  101. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
  102. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/toml-Ms459DBw.js +0 -0
  103. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
  104. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
  105. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
  106. {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
  107. {srcly-0.2.0 → srcly-0.3.0}/app/static/favicon.svg +0 -0
@@ -68,3 +68,6 @@ venv.bak/
68
68
  # uv.lock is intentionally not ignored as this is an application
69
69
 
70
70
  .tmp
71
+
72
+ # Local dependency-graph captures (may come from private repos)
73
+ client/src/viz/deps/__fixtures__/local/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: srcly
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Interactive codebase treemap and metrics viewer.
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: fastapi>=0.121.3
@@ -2,6 +2,9 @@
2
2
  Compare two git refs (PR compare mode).
3
3
 
4
4
  GET /api/compare/refs branches + recent commits for the picker
5
+ GET /api/compare/sources one picker section: prs | worktrees | branches
6
+ GET /api/compare/commits first-parent commits in base..head (commit stepper)
7
+ POST /api/compare/prewarm scan one commit side in the background
5
8
  POST /api/compare start (or join) a compare -> snapshot
6
9
  GET /api/compare/{id} current snapshot (poll)
7
10
  GET /api/compare/{id}/result compare payload once complete
@@ -18,8 +21,17 @@ from fastapi.responses import Response
18
21
  from pydantic import BaseModel
19
22
 
20
23
  from app.routers import analysis as analysis_router
21
- from app.services.compare import jobs
22
- from app.services.compare.git_refs import GitError, default_branch, repo_root, run_git
24
+ from app.services.compare import jobs, sources
25
+ from app.services.compare.git_refs import (
26
+ AUTO,
27
+ GitError,
28
+ default_branch,
29
+ list_commits,
30
+ repo_root,
31
+ resolve_auto_base,
32
+ resolve_ref,
33
+ run_git,
34
+ )
23
35
  from app.services.compare.service import DEFAULT_BASE, DEFAULT_HEAD
24
36
 
25
37
  router = APIRouter(prefix="/api/compare", tags=["compare"])
@@ -86,6 +98,53 @@ def list_refs(path: Optional[str] = None) -> dict:
86
98
  }
87
99
 
88
100
 
101
+ @router.get("/sources")
102
+ def list_sources(kind: str, path: Optional[str] = None, all: bool = False, refresh: bool = False) -> dict:
103
+ """
104
+ One picker section. Each kind is its own request so worktrees and branches
105
+ render while ``gh`` is still answering. A source that can't load (no gh,
106
+ not signed in, offline) comes back with ``available: false`` and a reason.
107
+ """
108
+ if kind not in ("prs", "worktrees", "branches"):
109
+ raise HTTPException(status_code=400, detail=f"Unknown source kind: {kind}")
110
+ try:
111
+ repo = repo_root(_target(path))
112
+ except GitError as exc:
113
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
114
+ return {"repo": str(repo), **sources.load_source(repo, kind, mine=not all, refresh=refresh)}
115
+
116
+
117
+ @router.get("/commits")
118
+ def get_commits(base: str, head: str, path: Optional[str] = None) -> dict:
119
+ """The commits a stepper walks: ``base`` resolves as a base (``pr:<n>`` → its merge-base, ``auto``)."""
120
+ try:
121
+ repo = repo_root(_target(path))
122
+ head_ref = resolve_ref(repo, head, side="head")
123
+ base_ref = resolve_auto_base(repo, head, head_ref) if base == AUTO else resolve_ref(repo, base, side="base")
124
+ listing = list_commits(repo, base_ref, head_ref)
125
+ except GitError as exc:
126
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
127
+ return {
128
+ "base": {"sha": base_ref.sha, "label": base_ref.label, "subject": base_ref.subject},
129
+ "head": {"sha": head_ref.sha, "label": head_ref.label},
130
+ **listing,
131
+ }
132
+
133
+
134
+ class PrewarmRequest(BaseModel):
135
+ path: Optional[str] = None
136
+ ref: str
137
+
138
+
139
+ @router.post("/prewarm", status_code=202)
140
+ def prewarm(req: PrewarmRequest) -> dict:
141
+ try:
142
+ state = jobs.manager.prewarm(_target(req.path), req.ref.strip())
143
+ except GitError as exc:
144
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
145
+ return {"ref": req.ref, "state": state}
146
+
147
+
89
148
  @router.post("", status_code=202)
90
149
  def start_compare(req: StartCompareRequest) -> dict:
91
150
  target = _target(req.path)
@@ -259,13 +259,19 @@ def _run_headless(argv: list[str]) -> None:
259
259
  "base",
260
260
  nargs="?",
261
261
  default="merge-base",
262
- help="Base ref. Defaults to merge-base (of the default branch and HEAD).",
262
+ help=(
263
+ "Base ref. Defaults to merge-base (of the default branch and HEAD). "
264
+ "pr:<n> means that PR's merge-base with its own base branch."
265
+ ),
263
266
  )
264
267
  compare_parser.add_argument(
265
268
  "head",
266
269
  nargs="?",
267
- default="WORKTREE",
268
- help="Head ref. Defaults to WORKTREE (files on disk, including uncommitted edits).",
270
+ default=None,
271
+ help=(
272
+ "Head ref. Defaults to WORKTREE (files on disk, including uncommitted edits), "
273
+ "or to pr:<n> when the base is pr:<n>. pr:<n> means that PR's head commit (read via gh)."
274
+ ),
269
275
  )
270
276
  compare_parser.add_argument("--repo", default=None, help="Path inside the git repo. Defaults to the cwd.")
271
277
  compare_parser.add_argument("--json", action="store_true", help="Print the compare payload as JSON.")
@@ -374,21 +380,29 @@ def _run_headless(argv: list[str]) -> None:
374
380
 
375
381
 
376
382
  def _run_compare(args) -> None:
377
- from app.services.compare.git_refs import GitError
383
+ from app.services.compare.git_refs import PR_REF, WORKTREE, GitError
378
384
  from app.services.compare.service import build_compare, render_markdown
379
385
 
380
386
  repo_path = Path(args.repo).resolve() if args.repo else Path(os.getcwd())
387
+ # `srcly compare pr:42` alone means the whole PR.
388
+ head = args.head or (args.base if PR_REF.match(args.base) else WORKTREE)
381
389
  try:
382
390
  payload = build_compare(
383
391
  repo_path,
384
392
  args.base,
385
- args.head,
393
+ head,
386
394
  include_sides=args.include_sides,
387
395
  force=args.refresh,
388
396
  on_phase=lambda phase: print(f"srcly compare: {phase}", file=sys.stderr, flush=True),
389
397
  )
390
398
  except GitError as exc:
391
399
  raise SystemExit(str(exc)) from exc
400
+ timings = payload.get("timings", {})
401
+ for group in ("phases", "base", "head"):
402
+ steps = timings.get(group) or {}
403
+ if steps:
404
+ text = ", ".join(f"{name} {seconds:.2f}s" for name, seconds in steps.items())
405
+ print(f"srcly compare: {group} timings: {text}", file=sys.stderr, flush=True)
392
406
  if args.out:
393
407
  out_path = Path(args.out).resolve()
394
408
  out_path.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,429 @@
1
+ """Ref resolution and change listing via the git CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Literal, Optional
10
+
11
+ WORKTREE = "WORKTREE"
12
+ """Pseudo-ref for the files on disk, including uncommitted and untracked edits."""
13
+ MERGE_BASE = "merge-base"
14
+ """Pseudo-ref for ``git merge-base <default branch> HEAD``."""
15
+ AUTO = "auto"
16
+ """Base pseudo-ref that follows the head: its open PR's base, else the merge-base with the default branch."""
17
+ PR_REF = re.compile(r"^(?:pr:|#)(\d+)$")
18
+ """``pr:<n>`` (or ``#<n>``): a GitHub PR. Head side is its head commit; base side is its merge-base."""
19
+ WORKTREE_PREFIX = "worktree:"
20
+ """``worktree:<path>``: the files on disk in another worktree of this repo (head only)."""
21
+
22
+ ChangeStatus = Literal["added", "removed", "modified", "renamed"]
23
+
24
+
25
+ class GitError(RuntimeError):
26
+ pass
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ResolvedRef:
31
+ ref: str
32
+ """What the user asked for (``main``, ``HEAD~2``, ``WORKTREE``, ...)."""
33
+ sha: Optional[str]
34
+ """Full commit SHA, or None for the working tree."""
35
+ label: str
36
+ """Short display name: ``abc1234`` or ``working tree``."""
37
+ subject: str = ""
38
+ pr: Optional[dict[str, Any]] = field(default=None, compare=False, hash=False)
39
+ """PR context (number, title, url, branches, author) for a ``pr:<n>`` ref."""
40
+ dir: Optional[Path] = None
41
+ """For a working-tree side: the worktree scanned (None = the repo itself)."""
42
+
43
+ def root(self, repo: Path) -> Path:
44
+ """Where this side's files live on disk when it is a working tree."""
45
+ return self.dir or repo
46
+
47
+ @property
48
+ def is_worktree(self) -> bool:
49
+ return self.sha is None
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class FileChange:
54
+ path: str
55
+ """Repo-relative path on the head side (the old path for removals)."""
56
+ status: ChangeStatus
57
+ old_path: Optional[str] = None
58
+ """Repo-relative path on the base side when it differs (renames)."""
59
+ additions: int = 0
60
+ deletions: int = 0
61
+
62
+ @property
63
+ def base_path(self) -> Optional[str]:
64
+ if self.status == "added":
65
+ return None
66
+ return self.old_path or self.path
67
+
68
+ @property
69
+ def head_path(self) -> Optional[str]:
70
+ return None if self.status == "removed" else self.path
71
+
72
+
73
+ def run_git(repo: Path, *args: str, input: Optional[str] = None) -> str:
74
+ try:
75
+ proc = subprocess.run(
76
+ ["git", "-c", "core.quotepath=off", *args],
77
+ cwd=repo,
78
+ input=input,
79
+ capture_output=True,
80
+ text=True,
81
+ encoding="utf-8",
82
+ errors="replace",
83
+ check=False,
84
+ )
85
+ except OSError as exc:
86
+ raise GitError(f"git is not available: {exc}") from exc
87
+ if proc.returncode != 0:
88
+ raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}")
89
+ return proc.stdout
90
+
91
+
92
+ def repo_root(path: Path) -> Path:
93
+ """The top level of the git work tree containing ``path``."""
94
+ start = path if path.is_dir() else path.parent
95
+ return Path(run_git(start, "rev-parse", "--show-toplevel").strip()).resolve()
96
+
97
+
98
+ def default_branch(repo: Path) -> str:
99
+ """``origin/HEAD``'s branch if set, else the first of main/master that exists."""
100
+ try:
101
+ ref = run_git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").strip()
102
+ if ref:
103
+ return ref
104
+ except GitError:
105
+ pass
106
+ for name in ("main", "master"):
107
+ try:
108
+ run_git(repo, "rev-parse", "--verify", "--quiet", f"{name}^{{commit}}")
109
+ return name
110
+ except GitError:
111
+ continue
112
+ raise GitError("Could not find a default branch (origin/HEAD, main, or master)")
113
+
114
+
115
+ Side = Literal["base", "head"]
116
+
117
+
118
+ def resolve_ref(repo: Path, ref: str, side: Side = "head") -> ResolvedRef:
119
+ """
120
+ Resolve ``ref`` to a commit (or the working tree). ``side`` matters only for
121
+ ``pr:<n>``: the head side is the PR's head commit, the base side is the
122
+ merge-base of the PR's own base branch and that head, as GitHub diffs it.
123
+ """
124
+ if ref == WORKTREE:
125
+ return ResolvedRef(ref=ref, sha=None, label="working tree")
126
+ match = PR_REF.match(ref.strip())
127
+ if match:
128
+ return _resolve_pr(repo, ref, int(match.group(1)), side)
129
+ if ref.startswith(WORKTREE_PREFIX):
130
+ if side == "base":
131
+ raise GitError("A worktree can only be the head side (its files on disk are not a commit)")
132
+ return _resolve_worktree(repo, ref)
133
+ target = ref
134
+ if ref == MERGE_BASE:
135
+ target = run_git(repo, "merge-base", default_branch(repo), "HEAD").strip()
136
+ sha = run_git(repo, "rev-parse", "--verify", f"{target}^{{commit}}").strip()
137
+ subject = run_git(repo, "log", "-1", "--format=%s", sha).strip()
138
+ return ResolvedRef(ref=ref, sha=sha, label=sha[:7], subject=subject)
139
+
140
+
141
+ def _resolve_worktree(repo: Path, ref: str) -> ResolvedRef:
142
+ """``worktree:<path>``: must be a worktree of this same repository (shared object store)."""
143
+ raw = ref[len(WORKTREE_PREFIX) :]
144
+ path = Path(raw).expanduser()
145
+ if not path.is_dir():
146
+ raise GitError(f"Worktree not found: {raw}")
147
+ try:
148
+ top = Path(run_git(path, "rev-parse", "--show-toplevel").strip()).resolve()
149
+ common = Path(run_git(top, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()).resolve()
150
+ ours = Path(run_git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()).resolve()
151
+ except GitError as exc:
152
+ raise GitError(f"{raw} is not a git worktree: {exc}") from exc
153
+ if common != ours:
154
+ raise GitError(f"{raw} is a worktree of a different repository")
155
+ if top == repo.resolve():
156
+ return ResolvedRef(ref=ref, sha=None, label="working tree")
157
+ try:
158
+ branch = run_git(top, "symbolic-ref", "--quiet", "--short", "HEAD").strip()
159
+ except GitError:
160
+ branch = ""
161
+ label = branch or f"detached {run_git(top, 'rev-parse', '--short', 'HEAD').strip()}"
162
+ home = str(Path.home())
163
+ shown = "~" + str(top)[len(home) :] if str(top).startswith(home + "/") else str(top)
164
+ return ResolvedRef(ref=ref, sha=None, label=label, subject=f"files on disk in {shown}", dir=top)
165
+
166
+
167
+ def _has_commit(repo: Path, sha: str) -> bool:
168
+ try:
169
+ run_git(repo, "cat-file", "-e", f"{sha}^{{commit}}")
170
+ return True
171
+ except GitError:
172
+ return False
173
+
174
+
175
+ def _fetch_objects(repo: Path, *refspecs: str) -> None:
176
+ """
177
+ Fetch commits from ``origin`` without touching any ref: no branch, no
178
+ remote-tracking ref (``--refmap=``), no FETCH_HEAD, no tags. Objects only.
179
+ """
180
+ run_git(
181
+ repo,
182
+ "fetch",
183
+ "--quiet",
184
+ "--no-tags",
185
+ "--no-write-fetch-head",
186
+ "--refmap=",
187
+ "origin",
188
+ *refspecs,
189
+ )
190
+
191
+
192
+ def pr_context(pr: dict[str, Any]) -> dict[str, Any]:
193
+ author = pr.get("author") or {}
194
+ return {
195
+ "number": pr["number"],
196
+ "title": pr.get("title", ""),
197
+ "url": pr.get("url", ""),
198
+ "headRefName": pr.get("headRefName", ""),
199
+ "baseRefName": pr.get("baseRefName", ""),
200
+ "author": author.get("login", "") if isinstance(author, dict) else str(author),
201
+ "isDraft": bool(pr.get("isDraft")),
202
+ "changedFiles": pr.get("changedFiles"),
203
+ }
204
+
205
+
206
+ def _resolve_pr(repo: Path, ref: str, number: int, side: Side) -> ResolvedRef:
207
+ from app.services.compare import github
208
+
209
+ try:
210
+ pr = github.pr_info(repo, number)
211
+ except github.GhUnavailable as exc:
212
+ raise GitError(f"Could not read PR #{number}: {exc.reason}") from exc
213
+
214
+ head_sha = pr.get("headRefOid") or ""
215
+ base_name = pr.get("baseRefName") or ""
216
+ base_oid = pr.get("baseRefOid") or ""
217
+ if not head_sha:
218
+ raise GitError(f"PR #{number} has no head commit")
219
+
220
+ missing = [s for s in (head_sha, base_oid) if s and not _has_commit(repo, s)]
221
+ if missing:
222
+ specs = []
223
+ if head_sha in missing:
224
+ specs.append(f"refs/pull/{number}/head")
225
+ if base_oid in missing and base_name:
226
+ specs.append(f"refs/heads/{base_name}")
227
+ try:
228
+ _fetch_objects(repo, *specs)
229
+ except GitError as exc:
230
+ if not _has_commit(repo, head_sha):
231
+ raise GitError(f"PR #{number}'s head {head_sha[:7]} is not local and could not be fetched: {exc}") from exc
232
+ if not _has_commit(repo, head_sha):
233
+ raise GitError(f"PR #{number}'s head {head_sha[:7]} is not available locally")
234
+
235
+ if side == "head":
236
+ # The PR title says more than the head commit's subject.
237
+ sha = head_sha
238
+ label = f"#{number}"
239
+ subject = pr.get("title", "")
240
+ else:
241
+ # GitHub diffs a PR against merge-base(base branch tip, head). The tip
242
+ # GitHub reports is exact; local branch names are fallbacks for when
243
+ # that commit can't be fetched.
244
+ sha = _pr_merge_base(repo, pr, head_sha)
245
+ label = sha[:7]
246
+ subject = run_git(repo, "log", "-1", "--format=%s", sha).strip()
247
+ return ResolvedRef(ref=ref, sha=sha, label=label, subject=subject, pr=pr_context(pr))
248
+
249
+
250
+ def _pr_merge_base(repo: Path, pr: dict[str, Any], head_sha: str) -> str:
251
+ """merge-base of the PR's base branch tip (GitHub's, else local names) and ``head_sha``."""
252
+ base_name = pr.get("baseRefName") or ""
253
+ for candidate in (pr.get("baseRefOid") or "", f"origin/{base_name}", base_name):
254
+ if not candidate:
255
+ continue
256
+ try:
257
+ return run_git(repo, "merge-base", candidate, head_sha).strip()
258
+ except GitError:
259
+ continue
260
+ raise GitError(f"Could not find PR #{pr.get('number')}'s base branch {base_name!r} locally")
261
+
262
+
263
+ def branch_of(repo: Path, ref: str) -> Optional[str]:
264
+ """The branch name ``ref`` names (``feature``, ``origin/feature``), or None for a SHA, tag or expression."""
265
+ if ref in (WORKTREE, "HEAD"):
266
+ try:
267
+ name = run_git(repo, "symbolic-ref", "--quiet", "--short", "HEAD").strip()
268
+ return name or None
269
+ except GitError:
270
+ return None
271
+ try:
272
+ full = run_git(repo, "rev-parse", "--symbolic-full-name", ref).strip()
273
+ except GitError:
274
+ return None
275
+ if full.startswith("refs/heads/"):
276
+ return full[len("refs/heads/") :]
277
+ if full.startswith("refs/remotes/"):
278
+ # origin/feature -> feature
279
+ return full[len("refs/remotes/") :].partition("/")[2] or None
280
+ return None
281
+
282
+
283
+ def resolve_auto_base(repo: Path, head_input: str, head: ResolvedRef, branch: Optional[str] = None) -> ResolvedRef:
284
+ """
285
+ The base for ``auto``: a PR head uses its PR base; a branch with an open
286
+ PR uses that PR's base (so stacked branches show only their own changes);
287
+ anything else uses the merge-base with the default branch. ``gh`` being
288
+ unavailable just means no PR is found.
289
+ """
290
+ match = PR_REF.match(head_input.strip())
291
+ if match:
292
+ return resolve_ref(repo, head_input, side="base")
293
+ head_commit = head.sha or run_git(head.root(repo), "rev-parse", "HEAD").strip()
294
+ if head.dir is not None:
295
+ branch = branch or branch_of(head.dir, WORKTREE)
296
+ branch = branch or branch_of(repo, head_input)
297
+ pr = None
298
+ if branch:
299
+ from app.services.compare import github
300
+
301
+ try:
302
+ pr = github.pr_for_branch(repo, branch)
303
+ except github.GhUnavailable:
304
+ pr = None
305
+ if pr is not None:
306
+ sha = _pr_merge_base(repo, pr, head_commit)
307
+ else:
308
+ sha = run_git(repo, "merge-base", default_branch(repo), head_commit).strip()
309
+ subject = run_git(repo, "log", "-1", "--format=%s", sha).strip()
310
+ return ResolvedRef(ref=AUTO, sha=sha, label=sha[:7], subject=subject, pr=pr_context(pr) if pr else None)
311
+
312
+
313
+ MAX_COMMITS = 100
314
+
315
+
316
+ def list_commits(repo: Path, base: ResolvedRef, head: ResolvedRef, limit: int = MAX_COMMITS) -> dict[str, Any]:
317
+ """
318
+ First-parent commits in ``base..head``, oldest first, for stepping through
319
+ a PR or range, each with its first parent: a step compares a commit with
320
+ its parent. That is the previous commit in the list, except for the first
321
+ one when the branch merged its base in later (its parent predates ``base``).
322
+ A working-tree head lists up to its HEAD commit. Only the newest ``limit``
323
+ are kept.
324
+ """
325
+ if base.sha is None:
326
+ raise GitError("The base side must be a commit")
327
+ head_sha = head.sha or run_git(head.root(repo), "rev-parse", "HEAD").strip()
328
+ raw = run_git(
329
+ repo,
330
+ "log",
331
+ "--first-parent",
332
+ "--reverse",
333
+ "--format=%H%x00%h%x00%s%x00%an%x00%ct%x00%P",
334
+ f"{base.sha}..{head_sha}",
335
+ )
336
+ commits = []
337
+ for line in raw.splitlines():
338
+ sha, short, subject, author, date, parents = (line.split("\0") + [""] * 6)[:6]
339
+ commits.append(
340
+ {
341
+ "sha": sha,
342
+ "short": short,
343
+ "subject": subject,
344
+ "author": author,
345
+ "date": int(date) if date.isdigit() else 0,
346
+ "parent": parents.split(" ")[0] or None,
347
+ }
348
+ )
349
+ return {"commits": commits[-limit:], "total": len(commits), "truncated": len(commits) > limit}
350
+
351
+
352
+ _STATUS = {"A": "added", "D": "removed", "M": "modified", "T": "modified", "R": "renamed", "C": "added"}
353
+
354
+
355
+ def list_changes(repo: Path, base: ResolvedRef, head: ResolvedRef) -> list[FileChange]:
356
+ """
357
+ Files that differ between ``base`` and ``head``, with rename detection and
358
+ line counts. A working-tree head also counts untracked (non-ignored) files.
359
+ """
360
+ if base.sha is None:
361
+ raise GitError("The base side must be a commit")
362
+ range_args = [base.sha] if head.sha is None else [base.sha, head.sha]
363
+ # A working-tree head diffs from inside that worktree; the object store is shared.
364
+ repo = head.root(repo) if head.sha is None else repo
365
+
366
+ name_status = run_git(repo, "diff", "-z", "--name-status", "-M", *range_args)
367
+ numstat = run_git(repo, "diff", "-z", "--numstat", "-M", *range_args)
368
+ lines = _parse_numstat(numstat)
369
+
370
+ changes: list[FileChange] = []
371
+ tokens = name_status.split("\0")
372
+ i = 0
373
+ while i < len(tokens) and tokens[i]:
374
+ code = tokens[i]
375
+ kind = code[0]
376
+ if kind in ("R", "C"):
377
+ old, new = tokens[i + 1], tokens[i + 2]
378
+ i += 3
379
+ else:
380
+ old = new = tokens[i + 1]
381
+ i += 2
382
+ status = _STATUS.get(kind)
383
+ if status is None: # unmerged / unknown
384
+ continue
385
+ adds, dels = lines.get(new, lines.get(old, (0, 0)))
386
+ if status == "renamed":
387
+ changes.append(FileChange(path=new, status="renamed", old_path=old, additions=adds, deletions=dels))
388
+ elif status == "added" and kind == "C":
389
+ changes.append(FileChange(path=new, status="added", additions=adds, deletions=dels))
390
+ else:
391
+ changes.append(FileChange(path=new, status=status, additions=adds, deletions=dels))
392
+
393
+ if head.sha is None:
394
+ untracked = run_git(repo, "ls-files", "-z", "--others", "--exclude-standard")
395
+ for path in filter(None, untracked.split("\0")):
396
+ changes.append(FileChange(path=path, status="added", additions=_count_lines(repo / path)))
397
+
398
+ changes.sort(key=lambda c: c.path)
399
+ return changes
400
+
401
+
402
+ def _parse_numstat(raw: str) -> dict[str, tuple[int, int]]:
403
+ """``--numstat -z``: ``adds\\tdels\\tpath\\0`` or, for renames, ``adds\\tdels\\t\\0old\\0new\\0``."""
404
+ out: dict[str, tuple[int, int]] = {}
405
+ tokens = raw.split("\0")
406
+ i = 0
407
+ while i < len(tokens) and tokens[i]:
408
+ adds, dels, path = tokens[i].split("\t", 2)
409
+ counts = (_int(adds), _int(dels))
410
+ if path == "":
411
+ out[tokens[i + 2]] = counts
412
+ i += 3
413
+ else:
414
+ out[path] = counts
415
+ i += 1
416
+ return out
417
+
418
+
419
+ def _int(value: str) -> int:
420
+ # Binary files report "-".
421
+ return int(value) if value.isdigit() else 0
422
+
423
+
424
+ def _count_lines(path: Path) -> int:
425
+ try:
426
+ with open(path, "rb") as f:
427
+ return sum(1 for _ in f)
428
+ except OSError:
429
+ return 0