Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/loadpath/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from loadpath.review.engine import run_review
from loadpath.review.render import render_html, render_markdown
from loadpath.settings import AppSettings, public_settings, register_workspace, settings_path, _should_update_secret
from loadpath.workspace import DEFAULT_COMMIT_LIMIT, list_directory, list_git_refs


class IndexRequest(BaseModel):
Expand Down Expand Up @@ -206,6 +207,15 @@ def api_index_status(repo_path: str) -> dict[str, Any]:
report.pop("edges", None)
return report

@app.get("/api/fs")
def api_fs(path: str | None = None) -> dict[str, Any]:
return list_directory(path)

@app.get("/api/git/refs")
def api_git_refs(repo_path: str, limit: int = DEFAULT_COMMIT_LIMIT) -> dict[str, Any]:
root = require_repo_path(repo_path)
return list_git_refs(root, commit_limit=limit)

@app.get("/api/repos")
def api_repos() -> dict[str, Any]:
settings = AppSettings.load()
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 0 additions & 62 deletions src/loadpath/static/assets/index-DVQVwbDy.js

This file was deleted.

62 changes: 62 additions & 0 deletions src/loadpath/static/assets/index-aoV_COFI.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/loadpath/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="./assets/index-DVQVwbDy.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Bh26i1BD.css">
<script type="module" crossorigin src="./assets/index-aoV_COFI.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-fLLLv9tC.css">
</head>
<body>
<div id="root"></div>
Expand Down
188 changes: 187 additions & 1 deletion src/loadpath/workspace.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,195 @@
"""Git workspace facts used by review: dirty tree, merge-base, three-dot range."""
"""Local workspace helpers: directory browsing, git refs, dirty tree, merge-base."""

from __future__ import annotations

import subprocess
from pathlib import Path
from typing import Any

_GIT_TIMEOUT = 8
_MAX_DIR_ENTRIES = 400
_MAX_DIR_SCAN = 2000
DEFAULT_COMMIT_LIMIT = 50
_MAX_COMMIT_LIMIT = 100
_MAX_BRANCHES = 50
_MAX_TAGS = 20


def _git_output(repo_root: Path, *args: str) -> str:
return subprocess.check_output(
["git", "-C", str(repo_root), *args],
text=True,
stderr=subprocess.DEVNULL,
timeout=_GIT_TIMEOUT,
)


def resolve_existing_dir(path: str | None) -> Path:
"""Walk up from path until a real directory is found, else home."""
home = Path.home().resolve()
if not (path or "").strip():
return home
current = Path(path).expanduser()
try:
current = current.resolve()
except OSError:
pass
while True:
try:
if current.is_dir():
return current
except OSError:
pass
parent = current.parent
if parent == current:
return home
current = parent


def _is_git_root(path: Path) -> bool:
git = path / ".git"
try:
return git.is_dir() or git.is_file()
except OSError:
return False


def list_directory(path: str | None = None) -> dict[str, Any]:
"""List child directories for the in-app repository picker."""
home = Path.home().resolve()
root = resolve_existing_dir(path)
parent = str(root.parent) if root.parent != root else None
entries: list[dict[str, Any]] = []
truncated = False
try:
children = root.iterdir()
except OSError:
children = iter(())
scanned = 0
for child in children:
scanned += 1
if scanned > _MAX_DIR_SCAN:
truncated = True
break
name = child.name
if name.startswith("."):
continue
try:
if not child.is_dir():
continue
resolved = child.resolve()
except OSError:
continue
entries.append(
{
"name": name,
"path": str(resolved),
"is_dir": True,
"is_git": _is_git_root(resolved),
}
)
entries.sort(key=lambda item: (not item["is_git"], item["name"].lower()))
if len(entries) > _MAX_DIR_ENTRIES:
truncated = True
entries = entries[:_MAX_DIR_ENTRIES]
return {
"path": str(root),
"name": root.name or str(root),
"parent": parent,
"home": str(home),
"is_git": _is_git_root(root),
"truncated": truncated,
"entries": entries,
}


def list_git_refs(repo_root: Path, *, commit_limit: int = DEFAULT_COMMIT_LIMIT) -> dict[str, Any]:
"""Branches, tags, and recent commits for base/head pickers."""
repo_root = repo_root.resolve()
limit = max(1, min(int(commit_limit), _MAX_COMMIT_LIMIT))
payload: dict[str, Any] = {
"git": False,
"repo_path": str(repo_root),
"head": None,
"head_short": None,
"branches": [],
"tags": [],
"commits": [],
"presets": ["HEAD", "HEAD~1"],
}
if not repo_root.is_dir():
return payload
try:
inside = _git_output(repo_root, "rev-parse", "--is-inside-work-tree").strip()
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return payload
if inside != "true":
return payload
payload["git"] = True
head = git_rev_parse(repo_root, "HEAD")
payload["head"] = head
payload["head_short"] = head[:12] if head else None
payload["commits"] = _list_commits(repo_root, limit)
payload["branches"] = _list_refs(repo_root, ("refs/heads", "refs/remotes"), _MAX_BRANCHES)
payload["tags"] = _list_refs(repo_root, ("refs/tags",), _MAX_TAGS, sort="-creatordate")
return payload


def _list_commits(repo_root: Path, limit: int) -> list[dict[str, str]]:
try:
raw = _git_output(
repo_root,
"log",
f"-n{limit}",
"--format=%H%x09%h%x09%an%x09%cI%x09%s",
)
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return []
commits: list[dict[str, str]] = []
for line in raw.splitlines():
sha, short, author, date, subject = (line.split("\t", 4) + [""] * 5)[:5]
if not sha:
continue
commits.append(
{"sha": sha, "short": short, "subject": subject, "author": author, "date": date}
)
return commits


def _list_refs(
repo_root: Path,
patterns: tuple[str, ...],
limit: int,
*,
sort: str = "-committerdate",
) -> list[dict[str, Any]]:
try:
raw = _git_output(
repo_root,
"for-each-ref",
f"--sort={sort}",
*patterns,
"--format=%(refname:short)%09%(objectname)%09%(objectname:short)%09%(HEAD)%09%(contents:subject)",
)
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return []
refs: list[dict[str, Any]] = []
for line in raw.splitlines():
name, sha, short, head_mark, subject = (line.split("\t", 4) + [""] * 5)[:5]
if not name or name == "HEAD" or name.endswith("/HEAD"):
continue
refs.append(
{
"name": name,
"sha": sha,
"short": short,
"subject": subject,
"current": head_mark.strip() == "*",
}
)
if len(refs) >= limit:
break
return refs


def git_dirty_paths(repo_root: Path) -> list[str]:
Expand Down
38 changes: 38 additions & 0 deletions tests/e2e/test_api_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,44 @@ def test_blank_and_missing_repo_path_rejected(tmp_path, monkeypatch):
assert "not found" in missing.json()["detail"].lower()


def test_api_browse_fs_and_git_refs(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
repo = prepare_review_repo(tmp_path)
client = TestClient(create_app())

home = client.get("/api/fs")
assert home.status_code == 200
assert home.json()["path"] == str((tmp_path / "home").resolve())

listing = client.get("/api/fs", params={"path": str(repo.parent)})
assert listing.status_code == 200
names = {item["name"]: item for item in listing.json()["entries"]}
assert names[repo.name]["is_git"] is True

here = client.get("/api/fs", params={"path": str(repo)})
assert here.json()["is_git"] is True

refs = client.get("/api/git/refs", params={"repo_path": str(repo), "limit": 50})
assert refs.status_code == 200, refs.text
body = refs.json()
assert body["git"] is True
assert body["presets"] == ["HEAD", "HEAD~1"]
subjects = [c["subject"] for c in body["commits"]]
assert "tighten Invoice.total contract" in subjects
assert any(b["name"] == "main" for b in body["branches"])
assert len(body["commits"]) <= 50

missing = client.get("/api/git/refs", params={"repo_path": "/no/such/loadpath-repo"})
assert missing.status_code == 404

plain = tmp_path / "plain"
plain.mkdir()
not_git = client.get("/api/git/refs", params={"repo_path": str(plain)})
assert not_git.status_code == 200
assert not_git.json()["git"] is False


def test_settings_empty_model_does_not_wipe(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
Expand Down
53 changes: 53 additions & 0 deletions tests/e2e/test_ui_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,56 @@ def test_ui_pr_review_this_range_fills_refs(live_app, browser_page):
page.get_by_test_id("review-layout").wait_for()
assert page.get_by_test_id("base-ref").input_value() == "abc111base"
assert page.get_by_test_id("head-ref").input_value() == "def222head"


@pytest.mark.playwright
def test_ui_browse_repo_and_pick_git_refs(live_app, browser_page):
base_url, repo = live_app
page = browser_page
page.goto(base_url, wait_until="networkidle")
_wait_fonts(page)

page.get_by_test_id("repo-path").fill(str(repo))
page.get_by_test_id("btn-browse-repo").click()
explorer = page.get_by_test_id("repo-explorer")
explorer.wait_for()
page.get_by_test_id("explorer-path").wait_for()
page.wait_for_function(
"path => document.querySelector('[data-testid=\"explorer-path\"]')?.value.includes(path)",
arg=repo.name,
)
page.get_by_test_id("explorer-use").click()
explorer.wait_for(state="hidden")
first = page.get_by_test_id("repo-path").input_value()
assert repo.name in first

page.get_by_test_id("btn-browse-repo").click()
explorer.wait_for()
page.get_by_test_id("explorer-path").fill(str(repo.parent))
page.get_by_role("button", name="Go").click()
page.get_by_test_id("explorer-entry").filter(has_text=repo.name).wait_for()
page.get_by_test_id("explorer-entry").filter(has_text=repo.name).click()
page.get_by_test_id("explorer-use").click()
explorer.wait_for(state="hidden")
chosen = page.get_by_test_id("repo-path").input_value()
assert repo.name in chosen

page.get_by_test_id("base-ref").fill("custom-base")
assert page.get_by_test_id("base-ref").input_value() == "custom-base"

with page.expect_response(lambda r: "/api/git/refs" in r.url, timeout=15_000):
page.get_by_test_id("base-ref-toggle").click()
menu = page.get_by_test_id("base-ref-menu")
menu.wait_for()
menu.get_by_text("HEAD~1", exact=True).wait_for()
menu.get_by_test_id("ref-option-commit").filter(has_text="tighten Invoice.total contract").wait_for()
menu.get_by_test_id("ref-option-commit").filter(has_text="baseline").click()
selected = page.get_by_test_id("base-ref").input_value()
assert selected != "custom-base"
assert len(selected) >= 7

page.get_by_test_id("head-ref-toggle").click()
heads = page.get_by_test_id("head-ref-menu")
heads.wait_for()
heads.get_by_test_id("ref-option-branch").filter(has_text="main").click()
assert page.get_by_test_id("head-ref").input_value() == "main"
48 changes: 47 additions & 1 deletion tests/unit/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path

from loadpath.review.diff import git_diff
from loadpath.workspace import git_dirty_paths, git_merge_base, resolve_review_range
from loadpath.workspace import git_dirty_paths, git_merge_base, list_directory, list_git_refs, resolve_review_range
from tests.conftest import git_commit_all, git_init_with_main


Expand All @@ -29,3 +29,49 @@ def test_dirty_paths_include_uncommitted(tmp_path: Path):
git_init_with_main(repo)
(repo / "dirty.txt").write_text("nope\n")
assert "dirty.txt" in git_dirty_paths(repo)


def test_list_directory_marks_git_repos(tmp_path: Path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
nested = tmp_path / "workspace" / "acme"
nested.mkdir(parents=True)
(nested / "README.md").write_text("hi\n")
git_init_with_main(nested)
(tmp_path / "workspace" / "notes").mkdir()
(tmp_path / "workspace" / "notes" / "todo.txt").write_text("x\n")
listing = list_directory(str(tmp_path / "workspace"))
names = {item["name"]: item for item in listing["entries"]}
assert names["acme"]["is_git"] is True
assert names["notes"]["is_git"] is False
assert listing["is_git"] is False
jumped = list_directory(str(nested / "missing-child"))
assert jumped["path"] == str(nested.resolve())
assert jumped["is_git"] is True


def test_list_git_refs_includes_commits_and_branches(tmp_path: Path):
repo = tmp_path / "r"
repo.mkdir()
(repo / "a.txt").write_text("one\n")
git_init_with_main(repo)
(repo / "b.txt").write_text("two\n")
git_commit_all(repo, "second commit")
refs = list_git_refs(repo, commit_limit=50)
assert refs["git"] is True
assert refs["presets"] == ["HEAD", "HEAD~1"]
subjects = [c["subject"] for c in refs["commits"]]
assert "second commit" in subjects
assert "baseline" in subjects
assert len(refs["commits"]) == 2
names = {b["name"] for b in refs["branches"]}
assert "main" in names
assert any(b["current"] for b in refs["branches"])
empty = list_git_refs(tmp_path / "not-a-repo")
assert empty["git"] is False
assert empty["commits"] == []
nested = repo / "backend"
nested.mkdir()
from_subdir = list_git_refs(nested)
assert from_subdir["git"] is True
assert from_subdir["commits"]
Loading
Loading