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.
- {srcly-0.2.0 → srcly-0.3.0}/.gitignore +3 -0
- {srcly-0.2.0 → srcly-0.3.0}/PKG-INFO +1 -1
- {srcly-0.2.0 → srcly-0.3.0}/app/routers/compare.py +61 -2
- {srcly-0.2.0 → srcly-0.3.0}/app/run.py +19 -5
- srcly-0.3.0/app/services/compare/git_refs.py +429 -0
- srcly-0.3.0/app/services/compare/github.py +177 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/jobs.py +51 -2
- {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/service.py +89 -13
- {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/snapshots.py +55 -9
- srcly-0.3.0/app/services/compare/sources.py +216 -0
- srcly-0.3.0/app/static/assets/CodeModal-CTBbqRv_.js +29 -0
- srcly-0.3.0/app/static/assets/CompareApp-BSOMRmdx.js +3 -0
- srcly-0.3.0/app/static/assets/DependencyView-dzVV2_Up.js +1 -0
- srcly-0.3.0/app/static/assets/index-BVNn0QLM.js +2 -0
- srcly-0.3.0/app/static/assets/index-D2ClTmny.css +1 -0
- srcly-0.3.0/app/static/assets/layout-DSK4VztQ.js +1 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/index.html +2 -2
- {srcly-0.2.0 → srcly-0.3.0}/pyproject.toml +1 -1
- srcly-0.2.0/app/services/compare/git_refs.py +0 -185
- srcly-0.2.0/app/static/assets/CodeModal-BC4-51lF.js +0 -29
- srcly-0.2.0/app/static/assets/CompareApp-CtVSUR0J.js +0 -2
- srcly-0.2.0/app/static/assets/DependencyView-Cz2blz64.js +0 -1
- srcly-0.2.0/app/static/assets/index-F49dMNqG.css +0 -1
- srcly-0.2.0/app/static/assets/index-mm3bqu0K.js +0 -2
- srcly-0.2.0/app/static/assets/layout-CCJmnknz.js +0 -1
- {srcly-0.2.0 → srcly-0.3.0}/README.md +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/.gitignore +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/__init__.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/config.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/main.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/models.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/routers/analysis.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/routers/files.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/routers/scans.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/analysis.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/analysis_types.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/cache.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/__init__.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/compare/diff.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/css/css_analysis.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/__init__.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/common.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/graph.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/python_deps.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/tsconfig.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/deps/typescript_deps.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/ipynb/ipynb_analysis.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/markdown/markdown_analysis.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/python/python_analysis.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/reporting.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/scan_jobs.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/scan_progress.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/scan_workers.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/services/typescript/typescript_analysis.py +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/css-PZBBrkI0.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/html-DOf0XnR2.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/json-B8JryHcM.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/toml-Ms459DBw.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
- {srcly-0.2.0 → srcly-0.3.0}/app/static/favicon.svg +0 -0
|
@@ -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
|
|
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=
|
|
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=
|
|
268
|
-
help=
|
|
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
|
-
|
|
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
|