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.
- git_juggler/__init__.py +0 -0
- git_juggler/app.py +141 -0
- git_juggler/commit_detail.py +49 -0
- git_juggler/config.py +72 -0
- git_juggler/dev_app.py +16 -0
- git_juggler/frontend_dist/assets/index-BeJ91Usy.js +17 -0
- git_juggler/frontend_dist/assets/index-Br0FgpWw.css +1 -0
- git_juggler/frontend_dist/favicon.svg +1 -0
- git_juggler/frontend_dist/icons.svg +24 -0
- git_juggler/frontend_dist/index.html +14 -0
- git_juggler/git_data.py +262 -0
- git_juggler/git_utils.py +32 -0
- git_juggler/github_actions.py +212 -0
- git_juggler/main.py +55 -0
- git_juggler/repos.py +61 -0
- git_juggler/schemas.py +109 -0
- git_juggler/terminal.py +89 -0
- git_juggler-0.1.0.dist-info/METADATA +9 -0
- git_juggler-0.1.0.dist-info/RECORD +21 -0
- git_juggler-0.1.0.dist-info/WHEEL +4 -0
- git_juggler-0.1.0.dist-info/entry_points.txt +2 -0
git_juggler/__init__.py
ADDED
|
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)
|