1
0
This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/infrastructure/hands/git.py
Alexander Whitestone 9d78eb31d1 ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX

- Restructure desktop nav from 8+ flat links + overflow dropdown into
  5 grouped dropdowns (Core, Agents, Intel, System, More) matching
  the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
  notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
  notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
  showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
  Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
  disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

* fix(security): move auth-gate credentials to environment variables

Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

* refactor(tooling): migrate from black+isort+bandit to ruff

Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00

226 lines
7.4 KiB
Python

"""Git Hand — version-control operations for Timmy.
Provides git capabilities with:
- Safe defaults (no force-push without explicit override)
- Approval gates for destructive operations
- Structured result parsing
- Working-directory pinning to repo root
Follows project conventions:
- Config via ``from config import settings``
- Singleton pattern for module-level import
- Graceful degradation: log error, return fallback, never crash
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from config import settings
logger = logging.getLogger(__name__)
# Operations that require explicit confirmation before execution
DESTRUCTIVE_OPS = frozenset(
{
"push --force",
"push -f",
"reset --hard",
"clean -fd",
"clean -f",
"branch -D",
"checkout -- .",
"restore .",
}
)
@dataclass
class GitResult:
"""Result from a git operation."""
operation: str
success: bool
output: str = ""
error: str = ""
latency_ms: float = 0.0
requires_confirmation: bool = False
metadata: dict = field(default_factory=dict)
class GitHand:
"""Git operations hand for Timmy.
All methods degrade gracefully — if git is not available or the
command fails, the hand returns a ``GitResult(success=False)``
rather than raising.
"""
def __init__(self, repo_dir: str | None = None, timeout: int = 60) -> None:
self._repo_dir = repo_dir or settings.repo_root or None
self._timeout = timeout
logger.info("GitHand initialised — repo=%s", self._repo_dir)
def _is_destructive(self, args: str) -> bool:
"""Check if a git operation is destructive."""
for op in DESTRUCTIVE_OPS:
if op in args:
return True
return False
async def run(
self,
args: str,
timeout: int | None = None,
allow_destructive: bool = False,
) -> GitResult:
"""Execute a git command.
Args:
args: Git arguments (e.g. "status", "log --oneline -5").
timeout: Override default timeout (seconds).
allow_destructive: Must be True to run destructive ops.
Returns:
GitResult with output or error details.
"""
start = time.time()
# Gate destructive operations
if self._is_destructive(args) and not allow_destructive:
return GitResult(
operation=f"git {args}",
success=False,
error=(
f"Destructive operation blocked: 'git {args}'. "
"Set allow_destructive=True to override."
),
requires_confirmation=True,
latency_ms=(time.time() - start) * 1000,
)
effective_timeout = timeout or self._timeout
command = f"git {args}"
try:
proc = await asyncio.create_subprocess_exec(
"git",
*args.split(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._repo_dir,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=effective_timeout
)
except TimeoutError:
proc.kill()
await proc.wait()
latency = (time.time() - start) * 1000
logger.warning("Git command timed out after %ds: %s", effective_timeout, command)
return GitResult(
operation=command,
success=False,
error=f"Command timed out after {effective_timeout}s",
latency_ms=latency,
)
latency = (time.time() - start) * 1000
exit_code = proc.returncode or 0
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
return GitResult(
operation=command,
success=exit_code == 0,
output=stdout,
error=stderr if exit_code != 0 else "",
latency_ms=latency,
)
except FileNotFoundError:
latency = (time.time() - start) * 1000
logger.warning("git binary not found")
return GitResult(
operation=command,
success=False,
error="git binary not found on PATH",
latency_ms=latency,
)
except Exception as exc:
latency = (time.time() - start) * 1000
logger.warning("Git command failed: %s%s", command, exc)
return GitResult(
operation=command,
success=False,
error=str(exc),
latency_ms=latency,
)
# ── Convenience wrappers ─────────────────────────────────────────────────
async def status(self) -> GitResult:
"""Run ``git status --short``."""
return await self.run("status --short")
async def log(self, count: int = 10) -> GitResult:
"""Run ``git log --oneline``."""
return await self.run(f"log --oneline -{count}")
async def diff(self, staged: bool = False) -> GitResult:
"""Run ``git diff`` (or ``git diff --cached`` for staged)."""
args = "diff --cached" if staged else "diff"
return await self.run(args)
async def add(self, paths: str = ".") -> GitResult:
"""Stage files."""
return await self.run(f"add {paths}")
async def commit(self, message: str) -> GitResult:
"""Create a commit with the given message."""
# Use -- to prevent message from being interpreted as flags
return await self.run(f"commit -m {message!r}")
async def checkout_branch(self, branch: str, create: bool = False) -> GitResult:
"""Checkout (or create) a branch."""
flag = "-b" if create else ""
return await self.run(f"checkout {flag} {branch}".strip())
async def push(
self, remote: str = "origin", branch: str = "", force: bool = False
) -> GitResult:
"""Push to remote. Force-push requires explicit opt-in."""
args = f"push -u {remote} {branch}".strip()
if force:
args = f"push --force {remote} {branch}".strip()
return await self.run(args, allow_destructive=True)
return await self.run(args)
async def clone(self, url: str, dest: str = "") -> GitResult:
"""Clone a repository."""
args = f"clone {url}"
if dest:
args += f" {dest}"
return await self.run(args, timeout=120)
async def pull(self, remote: str = "origin", branch: str = "") -> GitResult:
"""Pull from remote."""
args = f"pull {remote} {branch}".strip()
return await self.run(args)
def info(self) -> dict:
"""Return a status summary for the dashboard."""
return {
"repo_dir": self._repo_dir,
"default_timeout": self._timeout,
}
# ── Module-level singleton ──────────────────────────────────────────────────
git_hand = GitHand()