git-juggler 0.1.0__py3-none-any.whl

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.
File without changes
git_juggler/app.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from . import config
11
+ from .commit_detail import get_commit_detail
12
+ from .git_data import get_graph, get_repo_status
13
+ from .github_actions import get_github_actions_runs
14
+ from .repos import list_repos, resolve_repo_path
15
+ from .schemas import CommitDetail, ConfigResponse, ConfigUpdateRequest, GitHubActionsRunInfo, GraphResponse, RepoStatusResponse, RepoSummary
16
+ from .terminal import run_terminal_session
17
+
18
+
19
+ def create_app(root_path: Path, frontend_dist: Path | None = None) -> FastAPI:
20
+ app = FastAPI(title="git-juggler")
21
+ app.state.root_path = root_path
22
+ config.ensure_seeded(root_path)
23
+
24
+ # Only needed for local dev, when the Vite dev server (a different origin)
25
+ # talks to this API directly instead of through its proxy.
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+ def _resolve_repo_path(repo_id: str) -> Path:
34
+ path = resolve_repo_path(config.load_repo_paths(), repo_id)
35
+ if path is None:
36
+ raise HTTPException(status_code=404, detail="repo not found")
37
+ return path
38
+
39
+ @app.get("/api/repos", response_model=list[RepoSummary])
40
+ def api_list_repos() -> list[RepoSummary]:
41
+ return list_repos(config.load_repo_paths())
42
+
43
+ def _current_config() -> ConfigResponse:
44
+ return ConfigResponse(
45
+ repo_paths=[str(p) for p in config.load_repo_paths()],
46
+ pinned_repo_paths=config.load_pinned_repo_paths(),
47
+ github=config.load_github_config(),
48
+ )
49
+
50
+ @app.get("/api/config", response_model=ConfigResponse)
51
+ def api_get_config() -> ConfigResponse:
52
+ return _current_config()
53
+
54
+ @app.put("/api/config", response_model=ConfigResponse)
55
+ def api_update_config(body: ConfigUpdateRequest) -> ConfigResponse:
56
+ if body.repo_paths is not None:
57
+ resolved: list[Path] = []
58
+ seen: set[str] = set()
59
+ for raw in body.repo_paths:
60
+ path = Path(raw).expanduser().resolve()
61
+ if not path.is_dir():
62
+ raise HTTPException(status_code=400, detail=f"not a directory: {raw}")
63
+ key = str(path)
64
+ if key in seen:
65
+ continue
66
+ seen.add(key)
67
+ resolved.append(path)
68
+ config.save_repo_paths(resolved)
69
+
70
+ if body.pinned_repo_paths is not None:
71
+ deduped = list(dict.fromkeys(body.pinned_repo_paths))
72
+ config.save_pinned_repo_paths(deduped)
73
+
74
+ if body.github is not None:
75
+ config.save_github_config(body.github.model_dump())
76
+
77
+ return _current_config()
78
+
79
+ @app.get("/api/repos/{repo_id}/graph", response_model=GraphResponse)
80
+ def api_graph(repo_id: str) -> GraphResponse:
81
+ path = _resolve_repo_path(repo_id)
82
+ commits, branches, current_branch, head_commit, upstream_commit, is_dirty, uncommitted_files, checked_out_branches = get_graph(path)
83
+ return GraphResponse(
84
+ commits=commits,
85
+ branches=branches,
86
+ current_branch=current_branch,
87
+ head_commit=head_commit,
88
+ upstream_commit=upstream_commit,
89
+ is_dirty=is_dirty,
90
+ uncommitted_files=uncommitted_files,
91
+ checked_out_branches=checked_out_branches,
92
+ )
93
+
94
+ @app.get("/api/repos/{repo_id}/status", response_model=RepoStatusResponse)
95
+ def api_repo_status(repo_id: str) -> RepoStatusResponse:
96
+ path = _resolve_repo_path(repo_id)
97
+ return get_repo_status(path)
98
+
99
+ @app.get("/api/repos/{repo_id}/commits/{sha}", response_model=CommitDetail)
100
+ def api_commit_detail(repo_id: str, sha: str) -> CommitDetail:
101
+ path = _resolve_repo_path(repo_id)
102
+ try:
103
+ return get_commit_detail(path, sha)
104
+ except Exception as exc: # noqa: BLE001 - surfaced as a 404 either way
105
+ raise HTTPException(status_code=404, detail="commit not found") from exc
106
+
107
+ @app.get("/api/repos/{repo_id}/github/actions", response_model=dict[str, list[GitHubActionsRunInfo]])
108
+ def api_github_actions(repo_id: str) -> dict[str, list[GitHubActionsRunInfo]]:
109
+ path = _resolve_repo_path(repo_id)
110
+ commits, _, _, _, _, _, _, _ = get_graph(path)
111
+ return get_github_actions_runs(path, {c.hash for c in commits}, config.load_github_config())
112
+
113
+ @app.websocket("/ws/terminal")
114
+ async def ws_terminal(websocket: WebSocket) -> None:
115
+ repo_id = websocket.query_params.get("repo")
116
+ cwd = root_path
117
+ if repo_id:
118
+ resolved = resolve_repo_path(config.load_repo_paths(), repo_id)
119
+ if resolved is None:
120
+ await websocket.close(code=1008)
121
+ return
122
+ cwd = resolved
123
+ await websocket.accept()
124
+ try:
125
+ await run_terminal_session(websocket, cwd=cwd)
126
+ except WebSocketDisconnect:
127
+ pass
128
+
129
+ if frontend_dist and frontend_dist.exists():
130
+ assets_dir = frontend_dist / "assets"
131
+ if assets_dir.exists():
132
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
133
+
134
+ @app.get("/{full_path:path}")
135
+ def spa(full_path: str) -> FileResponse:
136
+ candidate = frontend_dist / full_path
137
+ if full_path and candidate.is_file():
138
+ return FileResponse(candidate)
139
+ return FileResponse(frontend_dist / "index.html")
140
+
141
+ return app
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from git import NULL_TREE, Repo
6
+
7
+ from .schemas import CommitDetail, FileChange, PersonInfo
8
+
9
+ _STATUS_MAP = {
10
+ "A": "added",
11
+ "M": "modified",
12
+ "D": "deleted",
13
+ "R": "renamed",
14
+ "C": "copied",
15
+ "T": "modified",
16
+ }
17
+
18
+
19
+ def get_commit_detail(repo_path: Path, sha: str) -> CommitDetail:
20
+ repo = Repo(repo_path)
21
+ commit = repo.commit(sha)
22
+
23
+ if commit.parents:
24
+ diffs = commit.parents[0].diff(commit)
25
+ else:
26
+ diffs = commit.diff(NULL_TREE)
27
+
28
+ files: list[FileChange] = []
29
+ for d in diffs:
30
+ status = _STATUS_MAP.get(d.change_type or "M", "modified")
31
+ path = d.b_path or d.a_path or "?"
32
+ files.append(FileChange(path=path, status=status))
33
+ files.sort(key=lambda f: f.path)
34
+
35
+ subject = commit.summary if isinstance(commit.summary, str) else commit.summary.decode()
36
+ message = commit.message if isinstance(commit.message, str) else commit.message.decode()
37
+
38
+ return CommitDetail(
39
+ hash=commit.hexsha,
40
+ short_hash=commit.hexsha[:7],
41
+ parents=[p.hexsha for p in commit.parents],
42
+ author=PersonInfo(name=commit.author.name or "", email=commit.author.email or ""),
43
+ committer=PersonInfo(name=commit.committer.name or "", email=commit.committer.email or ""),
44
+ authored_date=commit.authored_datetime.isoformat(),
45
+ committed_date=commit.committed_datetime.isoformat(),
46
+ subject=subject,
47
+ message=message,
48
+ files=files,
49
+ )
git_juggler/config.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ CONFIG_DIR = Path.home() / ".config" / "git-juggler"
7
+ CONFIG_PATH = CONFIG_DIR / "config.json"
8
+
9
+
10
+ def _load_raw() -> dict:
11
+ if not CONFIG_PATH.exists():
12
+ return {}
13
+ try:
14
+ data = json.loads(CONFIG_PATH.read_text())
15
+ except (OSError, json.JSONDecodeError):
16
+ return {}
17
+ return data if isinstance(data, dict) else {}
18
+
19
+
20
+ def _save_raw(data: dict) -> None:
21
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
22
+ tmp_path = CONFIG_PATH.with_suffix(".tmp")
23
+ tmp_path.write_text(json.dumps(data, indent=2))
24
+ tmp_path.replace(CONFIG_PATH)
25
+
26
+
27
+ def load_repo_paths() -> list[Path]:
28
+ raw = _load_raw().get("repo_paths", [])
29
+ if not isinstance(raw, list):
30
+ return []
31
+ return [Path(p) for p in raw if isinstance(p, str)]
32
+
33
+
34
+ def save_repo_paths(paths: list[Path]) -> None:
35
+ data = _load_raw()
36
+ data["repo_paths"] = [str(p) for p in paths]
37
+ _save_raw(data)
38
+
39
+
40
+ def load_pinned_repo_paths() -> list[str]:
41
+ raw = _load_raw().get("pinned_repo_paths", [])
42
+ if not isinstance(raw, list):
43
+ return []
44
+ return [p for p in raw if isinstance(p, str)]
45
+
46
+
47
+ def save_pinned_repo_paths(paths: list[str]) -> None:
48
+ data = _load_raw()
49
+ data["pinned_repo_paths"] = paths
50
+ _save_raw(data)
51
+
52
+
53
+ def load_github_config() -> dict | None:
54
+ raw = _load_raw().get("github")
55
+ return raw if isinstance(raw, dict) else None
56
+
57
+
58
+ def save_github_config(github: dict | None) -> None:
59
+ data = _load_raw()
60
+ if github is None:
61
+ data.pop("github", None)
62
+ else:
63
+ data["github"] = github
64
+ _save_raw(data)
65
+
66
+
67
+ def ensure_seeded(default_path: Path) -> None:
68
+ """On first run (no config file yet), seed it with the CLI-provided path
69
+ so existing single-path usage keeps working without extra setup."""
70
+ if CONFIG_PATH.exists():
71
+ return
72
+ save_repo_paths([default_path])
git_juggler/dev_app.py ADDED
@@ -0,0 +1,16 @@
1
+ """ASGI app factory used only by `uvicorn --reload`, which needs an import
2
+ string (not a live app object) so it can restart the app in a subprocess.
3
+ The scanned root path is passed through an env var by main.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from .app import create_app
12
+
13
+ _root_path = Path(os.environ["GIT_JUGGLER_ROOT"])
14
+ _frontend_dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
15
+
16
+ app = create_app(_root_path, frontend_dist=_frontend_dist if _frontend_dist.exists() else None)