Files
clide/legacy/clide/services/git_service.py
T
jpmschweitzerandClaude Opus 4.7 a355751437 move python clide to legacy/
Clide is being rebuilt as a Flutter desktop app. The Python Textual
implementation moves wholesale into legacy/ rather than being deleted:
its pane model, panel set, git skills, and panel communication design
are real thought that should remain readable next to the new code
while the rebuild finds its shape. Git's rename tracking preserves
history, so `git log -- legacy/` still works.

The Flutter rebuild lives at the repo root alongside a Go sidecar
(the architecture claudian was heading toward, which folds into
clide as a core component rather than a separate plugin project).
Bootstrap of the new shape lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:30:51 +02:00

274 lines
8.3 KiB
Python

"""Git operations service."""
from pathlib import Path
from clide.models.git import (
ChangeStatus,
GitBranch,
GitChange,
GitCommit,
GitStatus,
)
from clide.services.process_service import ProcessService
class GitService:
"""Service for git operations."""
def __init__(self, repo_path: Path) -> None:
self.repo_path = repo_path
self._process = ProcessService(cwd=repo_path)
async def get_status(self) -> GitStatus:
"""Get current git status.
Returns:
GitStatus with staged/unstaged changes
"""
# Get porcelain status
result = await self._process.run("git", "status", "--porcelain", "-z")
staged: list[GitChange] = []
unstaged: list[GitChange] = []
untracked: list[str] = []
if result.success and result.stdout:
entries = result.stdout.split("\0")
for entry in entries:
if not entry or len(entry) < 3:
continue
index_status = entry[0]
worktree_status = entry[1]
path = entry[3:]
# Parse status
if index_status == "?":
# Untracked files go in both untracked list and unstaged
untracked.append(path)
unstaged.append(
GitChange(
path=path,
status=ChangeStatus.UNTRACKED,
staged=False,
)
)
else:
if index_status != " ":
staged.append(
GitChange(
path=path,
status=self._parse_status(index_status),
staged=True,
)
)
if worktree_status != " ":
unstaged.append(
GitChange(
path=path,
status=self._parse_status(worktree_status),
staged=False,
)
)
# Get current branch
branch_result = await self._process.run("git", "branch", "--show-current")
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
# Get ahead/behind
ahead, behind = await self._get_ahead_behind(branch)
return GitStatus(
branch=branch,
ahead=ahead,
behind=behind,
staged=tuple(staged),
unstaged=tuple(unstaged),
untracked=tuple(untracked),
)
async def _get_ahead_behind(self, branch: str) -> tuple[int, int]:
"""Get commits ahead/behind upstream."""
result = await self._process.run(
"git", "rev-list", "--left-right", "--count", f"{branch}...@{{upstream}}"
)
if result.success:
parts = result.stdout.strip().split()
if len(parts) == 2:
return int(parts[0]), int(parts[1])
return 0, 0
def _parse_status(self, char: str) -> ChangeStatus:
"""Parse git status character to ChangeStatus."""
mapping = {
"A": ChangeStatus.ADDED,
"M": ChangeStatus.MODIFIED,
"D": ChangeStatus.DELETED,
"R": ChangeStatus.RENAMED,
"C": ChangeStatus.COPIED,
"?": ChangeStatus.UNTRACKED,
"!": ChangeStatus.IGNORED,
"U": ChangeStatus.UNMERGED,
}
return mapping.get(char, ChangeStatus.MODIFIED)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
result = await self._process.run("git", "add", path)
return result.success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
result = await self._process.run("git", "restore", "--staged", path)
return result.success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path to discard
Returns:
True if successful
"""
result = await self._process.run("git", "restore", path)
return result.success
async def get_branches(self) -> list[GitBranch]:
"""Get list of branches.
Returns:
List of GitBranch objects
"""
result = await self._process.run(
"git",
"branch",
"-a",
"--format",
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)",
)
branches: list[GitBranch] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
is_current = line.startswith("*")
parts = line[1:].split("|")
if len(parts) >= 4:
name = parts[0].strip()
branches.append(
GitBranch(
name=name,
is_current=is_current,
is_remote=name.startswith("remotes/"),
tracking=parts[1] or None,
commit_hash=parts[2],
commit_message=parts[3],
)
)
return branches
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name to checkout
Returns:
True if successful
"""
result = await self._process.run("git", "checkout", branch)
return result.success
async def create_branch(self, name: str, start_point: str | None = None) -> bool:
"""Create a new branch.
Args:
name: New branch name
start_point: Optional starting commit/branch
Returns:
True if successful
"""
args = ["git", "checkout", "-b", name]
if start_point:
args.append(start_point)
result = await self._process.run(*args)
return result.success
async def get_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff output string
"""
args = ["git", "diff"]
if staged:
args.append("--cached")
args.append("--")
args.append(path)
result = await self._process.run(*args)
return result.stdout if result.success else ""
async def get_log(self, max_count: int = 50) -> list[GitCommit]:
"""Get commit log.
Args:
max_count: Maximum number of commits to return
Returns:
List of GitCommit objects
"""
result = await self._process.run(
"git",
"log",
f"--max-count={max_count}",
"--format=%H|%h|%s|%an|%ar|%P|%D",
"--all",
)
commits: list[GitCommit] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|")
if len(parts) >= 7:
parents = tuple(parts[5].split()) if parts[5] else ()
refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else ()
commits.append(
GitCommit(
hash=parts[0],
short_hash=parts[1],
message=parts[2],
author=parts[3],
date=parts[4],
is_merge=len(parents) > 1,
parents=parents,
refs=refs,
)
)
return commits