Skip to content

Commit 0dfb206

Browse files
committed
Add git support
Add git support * Add git client * Add git branch tree view
1 parent b65b237 commit 0dfb206

File tree

14 files changed

+1161
-17
lines changed

14 files changed

+1161
-17
lines changed

.idea/discord.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

je_editor/git/commit_graph.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import logging
2+
from dataclasses import dataclass, field
3+
from typing import List, Dict
4+
5+
log = logging.getLogger(__name__)
6+
7+
8+
@dataclass
9+
class CommitNode:
10+
sha: str
11+
author: str
12+
date: str
13+
message: str
14+
parents: List[str]
15+
lane: int = -1 # assigned later
16+
17+
18+
@dataclass
19+
class CommitGraph:
20+
nodes: List[CommitNode] = field(default_factory=list)
21+
index: Dict[str, int] = field(default_factory=dict) # sha -> row
22+
23+
def build(self, commits: List[Dict], refs: Dict[str, str]) -> None:
24+
# commits are topo-ordered by git log --topo-order; we keep it.
25+
self.nodes = [
26+
CommitNode(
27+
sha=c["sha"],
28+
author=c["author"],
29+
date=c["date"],
30+
message=c["message"],
31+
parents=c["parents"],
32+
) for c in commits
33+
]
34+
self.index = {n.sha: i for i, n in enumerate(self.nodes)}
35+
self._assign_lanes()
36+
37+
def _assign_lanes(self) -> None:
38+
"""
39+
Simple lane assignment similar to 'git log --graph' lanes.
40+
Greedy: reuse freed lanes; parents may create new lanes.
41+
"""
42+
active: Dict[int, str] = {} # lane -> sha
43+
free_lanes: List[int] = []
44+
45+
for i, node in enumerate(self.nodes):
46+
# If any active lane points to this commit, use that lane
47+
lane_found = None
48+
for lane, sha in list(active.items()):
49+
if sha == node.sha:
50+
lane_found = lane
51+
break
52+
53+
if lane_found is None:
54+
if free_lanes:
55+
node.lane = free_lanes.pop(0)
56+
else:
57+
node.lane = 0 if not active else max(active.keys()) + 1
58+
else:
59+
node.lane = lane_found
60+
61+
# Update active: current node consumes its lane, parents occupy lanes
62+
# Remove the current sha from any lane that pointed to it
63+
for lane, sha in list(active.items()):
64+
if sha == node.sha:
65+
del active[lane]
66+
67+
# First parent continues in the same lane; others go to free/new lanes
68+
if node.parents:
69+
first = node.parents[0]
70+
active[node.lane] = first
71+
# Side branches
72+
for p in node.parents[1:]:
73+
# Pick a free lane or new one
74+
if free_lanes:
75+
pl = free_lanes.pop(0)
76+
else:
77+
pl = 0 if not active else max(active.keys()) + 1
78+
active[pl] = p
79+
80+
# Any lane whose target no longer appears in the future will be freed later
81+
# We approximate by freeing lanes when a target didn't appear in the next rows;
82+
# but for minimal viable, free when lane not reassigned by parents this row.
83+
used_lanes = set(active.keys())
84+
# Collect gaps below max lane as free lanes to reuse
85+
max_lane = max(used_lanes) if used_lanes else -1
86+
present = set(range(max_lane + 1))
87+
missing = sorted(list(present - used_lanes))
88+
# Merge missing into free_lanes maintaining order
89+
free_lanes = sorted(set(free_lanes + missing))

je_editor/git/git.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import os
2+
from datetime import datetime
3+
4+
from PySide6.QtCore import QThread, Signal
5+
from git import Repo, GitCommandError, InvalidGitRepositoryError, NoSuchPathError
6+
7+
8+
# -----------------------
9+
# Simple audit logger
10+
# -----------------------
11+
def audit_log(repo_path: str, action: str, detail: str, ok: bool, err: str = ""):
12+
"""
13+
Append an audit log entry to 'audit.log' in the repo directory.
14+
This is useful for compliance and traceability.
15+
"""
16+
try:
17+
path = os.path.join(repo_path if repo_path else ".", "audit.log")
18+
with open(path, "a", encoding="utf-8") as f:
19+
ts = datetime.now().isoformat(timespec="seconds")
20+
f.write(f"{ts}\taction={action}\tok={ok}\tdetail={detail}\terr={err}\n")
21+
except Exception:
22+
pass # Never let audit logging failure break the UI
23+
24+
25+
# -----------------------
26+
# Git service layer
27+
# -----------------------
28+
class GitService:
29+
"""
30+
Encapsulates Git operations using GitPython.
31+
Keeps UI logic separate from Git logic.
32+
"""
33+
34+
def __init__(self):
35+
self.repo: Repo | None = None
36+
self.repo_path: str | None = None
37+
38+
def open_repo(self, path: str):
39+
try:
40+
self.repo = Repo(path)
41+
self.repo_path = path
42+
audit_log(path, "open_repo", path, True)
43+
except (InvalidGitRepositoryError, NoSuchPathError) as e:
44+
audit_log(path, "open_repo", path, False, str(e))
45+
raise
46+
47+
def list_branches(self):
48+
self._ensure_repo()
49+
branches = [head.name for head in self.repo.heads]
50+
audit_log(self.repo_path, "list_branches", ",".join(branches), True)
51+
return branches
52+
53+
def current_branch(self):
54+
self._ensure_repo()
55+
try:
56+
return self.repo.active_branch.name
57+
except TypeError:
58+
return "(detached HEAD)"
59+
60+
def checkout(self, branch: str):
61+
self._ensure_repo()
62+
try:
63+
self.repo.git.checkout(branch)
64+
audit_log(self.repo_path, "checkout", branch, True)
65+
except GitCommandError as e:
66+
audit_log(self.repo_path, "checkout", branch, False, str(e))
67+
raise
68+
69+
def list_commits(self, branch: str, max_count: int = 100):
70+
self._ensure_repo()
71+
commits = list(self.repo.iter_commits(branch, max_count=max_count))
72+
data = [
73+
{
74+
"hexsha": c.hexsha,
75+
"summary": c.summary,
76+
"author": c.author.name if c.author else "",
77+
"date": datetime.fromtimestamp(c.committed_date).isoformat(sep=" ", timespec="seconds"),
78+
}
79+
for c in commits
80+
]
81+
audit_log(self.repo_path, "list_commits", f"{branch}:{len(data)}", True)
82+
return data
83+
84+
def show_diff_of_commit(self, commit_sha: str) -> str:
85+
self._ensure_repo()
86+
commit = self.repo.commit(commit_sha)
87+
parent = commit.parents[0] if commit.parents else None
88+
if parent is None:
89+
null_tree = self.repo.tree(NULL_TREE)
90+
diffs = commit.diff(null_tree, create_patch=True)
91+
else:
92+
diffs = commit.diff(parent, create_patch=True)
93+
text = []
94+
for d in diffs:
95+
try:
96+
text.append(d.diff.decode("utf-8", errors="replace"))
97+
except Exception:
98+
pass
99+
out = "".join(text) if text else "(No patch content)"
100+
audit_log(self.repo_path, "show_diff", commit_sha, True)
101+
return out
102+
103+
def stage_all(self):
104+
self._ensure_repo()
105+
try:
106+
self.repo.git.add(all=True)
107+
audit_log(self.repo_path, "stage_all", "git add -A", True)
108+
except GitCommandError as e:
109+
audit_log(self.repo_path, "stage_all", "git add -A", False, str(e))
110+
raise
111+
112+
def commit(self, message: str):
113+
self._ensure_repo()
114+
if not message.strip():
115+
raise ValueError("Commit message is empty.")
116+
try:
117+
self.repo.index.commit(message)
118+
audit_log(self.repo_path, "commit", message, True)
119+
except Exception as e:
120+
audit_log(self.repo_path, "commit", message, False, str(e))
121+
raise
122+
123+
def pull(self, remote: str = "origin", branch: str | None = None):
124+
self._ensure_repo()
125+
if branch is None:
126+
branch = self.current_branch()
127+
try:
128+
res = self.repo.git.pull(remote, branch)
129+
audit_log(self.repo_path, "pull", f"{remote}/{branch}", True)
130+
return res
131+
except GitCommandError as e:
132+
audit_log(self.repo_path, "pull", f"{remote}/{branch}", False, str(e))
133+
raise
134+
135+
def push(self, remote: str = "origin", branch: str | None = None):
136+
self._ensure_repo()
137+
if branch is None:
138+
branch = self.current_branch()
139+
try:
140+
res = self.repo.git.push(remote, branch)
141+
audit_log(self.repo_path, "push", f"{remote}/{branch}", True)
142+
return res
143+
except GitCommandError as e:
144+
audit_log(self.repo_path, "push", f"{remote}/{branch}", False, str(e))
145+
raise
146+
147+
def remotes(self):
148+
self._ensure_repo()
149+
return [r.name for r in self.repo.remotes]
150+
151+
def _ensure_repo(self):
152+
if self.repo is None:
153+
raise RuntimeError("Repository not opened.")
154+
155+
156+
# Null tree constant for initial commit diff
157+
NULL_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
158+
159+
160+
# -----------------------
161+
# Worker thread wrapper
162+
# -----------------------
163+
class Worker(QThread):
164+
"""
165+
Runs a function in a separate thread to avoid blocking the UI.
166+
Emits (result, error) when done.
167+
"""
168+
done = Signal(object, object)
169+
170+
def __init__(self, fn, *args, **kwargs):
171+
super().__init__()
172+
self.fn = fn
173+
self.args = args
174+
self.kwargs = kwargs
175+
176+
def run(self):
177+
try:
178+
res = self.fn(*self.args, **self.kwargs)
179+
self.done.emit(res, None)
180+
except Exception as e:
181+
self.done.emit(None, e)

je_editor/git/git_cli.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import logging
2+
import subprocess
3+
from pathlib import Path
4+
from typing import List, Dict
5+
6+
log = logging.getLogger(__name__)
7+
8+
9+
class GitCLI:
10+
def __init__(self, repo_path: Path):
11+
self.repo_path = Path(repo_path)
12+
13+
def is_git_repo(self) -> bool:
14+
return (self.repo_path / ".git").exists()
15+
16+
def _run(self, args: List[str]) -> str:
17+
log.debug("git %s", " ".join(args))
18+
res = subprocess.run(
19+
["git"] + args,
20+
cwd=self.repo_path,
21+
stdout=subprocess.PIPE,
22+
stderr=subprocess.PIPE,
23+
text=True,
24+
encoding="utf-8",
25+
)
26+
if res.returncode != 0:
27+
log.error("Git failed: %s", res.stderr.strip())
28+
raise RuntimeError(res.stderr.strip())
29+
return res.stdout
30+
31+
def get_all_refs(self) -> Dict[str, str]:
32+
# returns refname -> commit hash
33+
out = self._run(["show-ref", "--heads", "--tags"])
34+
refs = {}
35+
for line in out.splitlines():
36+
if not line.strip():
37+
continue
38+
sha, ref = line.split(" ", 1)
39+
refs[ref.strip()] = sha.strip()
40+
return refs
41+
42+
def get_commits(self, max_count: int = 500) -> List[Dict]:
43+
"""
44+
Return recent commits across all refs, with parents.
45+
"""
46+
fmt = "%H%x01%P%x01%an%x01%ad%x01%s"
47+
out = self._run([
48+
"log", "--date=short", f"--format={fmt}", "--all",
49+
f"--max-count={max_count}", "--topo-order"
50+
])
51+
commits = []
52+
for line in out.splitlines():
53+
if not line.strip():
54+
continue
55+
parts = line.split("\x01")
56+
if len(parts) != 5:
57+
continue
58+
sha, parents, author, date, msg = parts
59+
commits.append({
60+
"sha": sha,
61+
"parents": [p for p in parents.strip().split() if p],
62+
"author": author,
63+
"date": date,
64+
"message": msg,
65+
})
66+
return commits

je_editor/git/github.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import datetime
2+
import traceback
3+
4+
from git import Repo, GitCommandError, InvalidGitRepositoryError, NoSuchPathError
5+
6+
7+
class GitCloneHandler:
8+
"""
9+
Handles cloning of remote Git repositories with audit logging.
10+
Can be reused in UI or CLI contexts.
11+
"""
12+
13+
def __init__(self, audit_log_path: str = "git_clone_audit.log"):
14+
self.audit_log_path = audit_log_path
15+
16+
def clone_repo(self, remote_url: str, local_path: str) -> str:
17+
"""
18+
Clone a remote repository to a local path.
19+
20+
:param remote_url: The Git repository URL (e.g., https://github.com/user/repo.git)
21+
:param local_path: The local directory to clone into
22+
:return: The path to the cloned repository
23+
:raises: Exception if cloning fails
24+
"""
25+
try:
26+
self._log_audit(f"Cloning started: {remote_url} -> {local_path}")
27+
Repo.clone_from(remote_url, local_path)
28+
self._log_audit(f"Cloning completed: {remote_url} -> {local_path}")
29+
return local_path
30+
except (GitCommandError, InvalidGitRepositoryError, NoSuchPathError) as e:
31+
self._log_audit(
32+
f"ERROR: Git operation failed: {remote_url} -> {local_path}\n{str(e)}\nTraceback:\n{traceback.format_exc()}")
33+
raise RuntimeError(f"Git operation failed: {str(e)}") from e
34+
except Exception as e:
35+
self._log_audit(
36+
f"ERROR: Unexpected error during clone: {remote_url} -> {local_path}\n{str(e)}\nTraceback:\n{traceback.format_exc()}")
37+
raise RuntimeError(f"Unexpected error during clone: {str(e)}") from e
38+
39+
def _log_audit(self, message: str):
40+
"""
41+
Append an audit log entry with timestamp.
42+
"""
43+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
44+
log_entry = f"[{timestamp}] {message}\n"
45+
try:
46+
with open(self.audit_log_path, "a", encoding="utf-8") as f:
47+
f.write(log_entry)
48+
except Exception:
49+
# Never let audit logging failure break the flow
50+
pass

0 commit comments

Comments
 (0)