Skip to content

Commit 44b2047

Browse files
authored
Merge pull request #5 from Axect/worktree-streamed-weaving-hollerith
feat: review overhaul + auto-update checker
2 parents 647a2bf + c1f9aed commit 44b2047

8 files changed

Lines changed: 1281 additions & 150 deletions

File tree

src/arxiv_explorer/cli/main.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rich.console import Console
55

66
from ..core.database import init_db
7+
from ..core.update_checker import UpdateStatus, check_for_updates, pull_updates
78

89
app = typer.Typer(
910
name="axp",
@@ -22,6 +23,58 @@ def version_callback(value: bool):
2223
raise typer.Exit()
2324

2425

26+
def _prompt_update(status: UpdateStatus) -> None:
27+
"""Display update info, warn about conflicts, and prompt user."""
28+
console.print(
29+
f"\n[bold yellow]Update available[/bold yellow]: "
30+
f"{status.behind_count} new commit{'s' if status.behind_count != 1 else ''} "
31+
f"on remote"
32+
)
33+
34+
if status.ahead_count > 0:
35+
console.print(
36+
f"[dim](local is also {status.ahead_count} commit{'s' if status.ahead_count != 1 else ''} "
37+
f"ahead of remote)[/dim]"
38+
)
39+
40+
# Show changed files summary
41+
if status.changed_files:
42+
n = len(status.changed_files)
43+
console.print(f"[dim]Changed files: {n}[/dim]")
44+
45+
# Warn about conflicts
46+
if status.conflict_files:
47+
console.print(
48+
"\n[bold red]Warning:[/bold red] "
49+
"The following locally modified files also changed on remote:"
50+
)
51+
for f in status.conflict_files:
52+
console.print(f" [red]- {f}[/red]")
53+
console.print(
54+
"[yellow]Pulling may cause merge conflicts. "
55+
"Consider committing or stashing your local changes first.[/yellow]\n"
56+
)
57+
58+
try:
59+
answer = typer.prompt("Update now? [y/n]", default="n")
60+
except (EOFError, KeyboardInterrupt):
61+
console.print()
62+
return
63+
64+
if answer.strip().lower() in ("y", "yes"):
65+
console.print("[dim]Pulling updates...[/dim]")
66+
success, message = pull_updates()
67+
if success:
68+
console.print(f"[green]Updated successfully.[/green] {message}")
69+
console.print(
70+
"[yellow]Note: if dependencies changed, run 'uv sync' to update them.[/yellow]\n"
71+
)
72+
else:
73+
console.print(f"[red]Update failed:[/red] {message}\n")
74+
else:
75+
console.print("[dim]Skipped.[/dim]\n")
76+
77+
2578
@app.callback()
2679
def main(
2780
version: bool = typer.Option(
@@ -32,11 +85,23 @@ def main(
3285
is_eager=True,
3386
help="Show version",
3487
),
88+
no_update_check: bool = typer.Option(
89+
False,
90+
"--no-update-check",
91+
hidden=True,
92+
help="Skip update check",
93+
),
3594
):
3695
"""arXiv Explorer - Personalized paper recommendation system."""
3796
# Initialize DB
3897
init_db()
3998

99+
# Check for git updates (throttled, silent on failure)
100+
if not no_update_check:
101+
status = check_for_updates()
102+
if status and status.has_update:
103+
_prompt_update(status)
104+
40105

41106
# Import and register subcommands
42107
from . import config, daily, export, lists, notes, preferences, review, search # noqa: E402

src/arxiv_explorer/cli/review.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626
ReviewSectionType.SECTION_SUMMARIES: "Section Summaries",
2727
ReviewSectionType.METHODOLOGY: "Methodology Analysis",
2828
ReviewSectionType.MATH_FORMULATIONS: "Math Formulations",
29-
ReviewSectionType.FIGURES: "Figure Descriptions",
30-
ReviewSectionType.TABLES: "Table Descriptions",
29+
ReviewSectionType.FIGURES: "Figure Analysis",
30+
ReviewSectionType.TABLES: "Table Analysis",
3131
ReviewSectionType.EXPERIMENTAL_RESULTS: "Experimental Results",
32+
ReviewSectionType.REPRODUCIBILITY: "Reproducibility Assessment",
3233
ReviewSectionType.STRENGTHS_WEAKNESSES: "Strengths & Weaknesses",
34+
ReviewSectionType.IMPACT_SIGNIFICANCE: "Impact & Significance",
3335
ReviewSectionType.RELATED_WORK: "Related Work",
3436
ReviewSectionType.GLOSSARY: "Glossary",
35-
ReviewSectionType.QUESTIONS: "Questions",
37+
ReviewSectionType.QUESTIONS: "Questions for Authors",
38+
ReviewSectionType.READING_GUIDE: "Reading Guide",
3639
}
3740

3841

src/arxiv_explorer/core/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,13 @@ class ReviewSectionType(str, Enum):
6060
FIGURES = "figures"
6161
TABLES = "tables"
6262
EXPERIMENTAL_RESULTS = "experimental_results"
63+
REPRODUCIBILITY = "reproducibility"
6364
STRENGTHS_WEAKNESSES = "strengths_weaknesses"
65+
IMPACT_SIGNIFICANCE = "impact_significance"
6466
RELATED_WORK = "related_work"
6567
GLOSSARY = "glossary"
6668
QUESTIONS = "questions"
69+
READING_GUIDE = "reading_guide"
6770

6871

6972
@dataclass
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Git-based update checker with throttling and conflict detection."""
2+
3+
import subprocess
4+
import time
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
# Throttle: check at most once per this many seconds
9+
CHECK_INTERVAL_SECONDS = 12 * 60 * 60 # 12 hours
10+
11+
# Git command timeout
12+
GIT_TIMEOUT_SECONDS = 10
13+
14+
15+
@dataclass
16+
class UpdateStatus:
17+
"""Result of an update check."""
18+
19+
has_update: bool = False
20+
local_ref: str = ""
21+
remote_ref: str = ""
22+
behind_count: int = 0
23+
ahead_count: int = 0
24+
changed_files: list[str] | None = None # files changed on remote
25+
conflict_files: list[str] | None = None # locally modified files that remote also changed
26+
error: str | None = None
27+
28+
29+
def _get_repo_root() -> Path | None:
30+
"""Find the git repo root from the package's installed location."""
31+
# Walk up from this file to find .git
32+
current = Path(__file__).resolve().parent
33+
for _ in range(10):
34+
if (current / ".git").exists():
35+
return current
36+
parent = current.parent
37+
if parent == current:
38+
break
39+
current = parent
40+
return None
41+
42+
43+
def _run_git(repo: Path, *args: str, timeout: int = GIT_TIMEOUT_SECONDS) -> str | None:
44+
"""Run a git command, return stdout or None on failure."""
45+
try:
46+
result = subprocess.run(
47+
["git", "-C", str(repo), *args],
48+
capture_output=True,
49+
text=True,
50+
timeout=timeout,
51+
)
52+
if result.returncode == 0:
53+
return result.stdout.strip()
54+
return None
55+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
56+
return None
57+
58+
59+
def _get_stamp_path(repo: Path) -> Path:
60+
"""Path to the last-check timestamp file."""
61+
return repo / ".git" / "axp_update_check"
62+
63+
64+
def _should_check(repo: Path) -> bool:
65+
"""Return True if enough time has passed since the last check."""
66+
stamp = _get_stamp_path(repo)
67+
if not stamp.exists():
68+
return True
69+
try:
70+
last = float(stamp.read_text().strip())
71+
return (time.time() - last) >= CHECK_INTERVAL_SECONDS
72+
except (ValueError, OSError):
73+
return True
74+
75+
76+
def _touch_stamp(repo: Path) -> None:
77+
"""Record the current time as last-checked."""
78+
try:
79+
_get_stamp_path(repo).write_text(str(time.time()))
80+
except OSError:
81+
pass
82+
83+
84+
def _get_tracking_branch(repo: Path) -> str | None:
85+
"""Get the remote tracking branch for the current branch (e.g. 'origin/main')."""
86+
branch = _run_git(repo, "rev-parse", "--abbrev-ref", "HEAD")
87+
if not branch:
88+
return None
89+
upstream = _run_git(repo, "rev-parse", "--abbrev-ref", f"{branch}@{{upstream}}")
90+
return upstream # e.g. "origin/main"
91+
92+
93+
def check_for_updates(repo: Path | None = None, force: bool = False) -> UpdateStatus | None:
94+
"""Check if the remote has new commits.
95+
96+
Returns UpdateStatus if a check was performed, None if skipped (throttled or not a repo).
97+
"""
98+
if repo is None:
99+
repo = _get_repo_root()
100+
if repo is None:
101+
return None
102+
103+
if not force and not _should_check(repo):
104+
return None
105+
106+
# Find tracking branch
107+
upstream = _get_tracking_branch(repo)
108+
if not upstream:
109+
_touch_stamp(repo)
110+
return None
111+
112+
remote_name = upstream.split("/")[0] if "/" in upstream else "origin"
113+
114+
# Fetch from remote (lightweight, no merge)
115+
fetch_result = _run_git(repo, "fetch", remote_name, "--quiet")
116+
if fetch_result is None:
117+
# Network failure — silently skip
118+
_touch_stamp(repo)
119+
return UpdateStatus(error="fetch failed (network issue?)")
120+
121+
_touch_stamp(repo)
122+
123+
# Compare local HEAD vs upstream
124+
local_ref = _run_git(repo, "rev-parse", "HEAD") or ""
125+
remote_ref = _run_git(repo, "rev-parse", upstream) or ""
126+
127+
if local_ref == remote_ref:
128+
return UpdateStatus(local_ref=local_ref, remote_ref=remote_ref)
129+
130+
# Count ahead/behind
131+
rev_list = _run_git(repo, "rev-list", "--left-right", "--count", f"HEAD...{upstream}")
132+
ahead, behind = 0, 0
133+
if rev_list:
134+
parts = rev_list.split()
135+
if len(parts) == 2:
136+
ahead, behind = int(parts[0]), int(parts[1])
137+
138+
if behind == 0:
139+
# Local is ahead or in sync — no update needed
140+
return UpdateStatus(
141+
local_ref=local_ref,
142+
remote_ref=remote_ref,
143+
ahead_count=ahead,
144+
)
145+
146+
# There are updates to pull — find which files changed
147+
changed_raw = _run_git(repo, "diff", "--name-only", f"HEAD...{upstream}")
148+
changed_files = changed_raw.splitlines() if changed_raw else []
149+
150+
# Detect potential conflicts: locally modified files that also changed on remote
151+
local_modified_raw = _run_git(repo, "diff", "--name-only")
152+
local_staged_raw = _run_git(repo, "diff", "--name-only", "--cached")
153+
154+
local_dirty: set[str] = set()
155+
if local_modified_raw:
156+
local_dirty.update(local_modified_raw.splitlines())
157+
if local_staged_raw:
158+
local_dirty.update(local_staged_raw.splitlines())
159+
160+
# Also check untracked files that overlap with remote changes
161+
# (not common but possible if remote adds a file the user also created)
162+
untracked_raw = _run_git(repo, "ls-files", "--others", "--exclude-standard")
163+
if untracked_raw:
164+
local_dirty.update(untracked_raw.splitlines())
165+
166+
conflict_files = sorted(local_dirty & set(changed_files))
167+
168+
return UpdateStatus(
169+
has_update=True,
170+
local_ref=local_ref,
171+
remote_ref=remote_ref,
172+
behind_count=behind,
173+
ahead_count=ahead,
174+
changed_files=changed_files,
175+
conflict_files=conflict_files if conflict_files else None,
176+
)
177+
178+
179+
def pull_updates(repo: Path | None = None) -> tuple[bool, str]:
180+
"""Run git pull. Returns (success, message)."""
181+
if repo is None:
182+
repo = _get_repo_root()
183+
if repo is None:
184+
return False, "Not a git repository"
185+
186+
result = _run_git(repo, "pull", "--ff-only", timeout=30)
187+
if result is not None:
188+
return True, result
189+
190+
# --ff-only failed, try normal pull
191+
result = _run_git(repo, "pull", timeout=30)
192+
if result is not None:
193+
return True, result
194+
195+
return False, "git pull failed — you may need to resolve conflicts manually"

src/arxiv_explorer/services/arxiv_client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ def _build_query(query: str) -> str:
4040
import re
4141

4242
# Already formatted: contains field prefix or boolean operator
43-
if re.search(r'\b(all|ti|au|abs|cat|co|jr|rn|id):', query) or \
44-
re.search(r'\b(AND|OR|ANDNOT)\b', query):
43+
if re.search(r"\b(all|ti|au|abs|cat|co|jr|rn|id):", query) or re.search(
44+
r"\b(AND|OR|ANDNOT)\b", query
45+
):
4546
return query
4647

4748
words = query.split()

0 commit comments

Comments
 (0)