Files
ai-agent/backend/worktreediff.py
Gabriel Vidal f52a7d41b9 feat(diff): real git diff of a conversation's live worktree
The conversation viewer's 'uncommitted changes' widget only ever saw work done
in a main checkout: the transcript-replay path (editsdiff) rejected every path
under ~/worktrees/<dir> and ~/projects/<slug>, so a conversation working in a
worktree — the normal case — showed nothing until its branch merged.

- backend/worktreediff.py: diff the worktree for real. A worktree's .git file
  points at a host path, so it maps the host repo root onto the mounted one
  (PROJECTS_DIR/<slug> or REPO_DIR) and drives git with an explicit
  --git-dir/--work-tree pair; returns 'git diff HEAD' plus untracked files,
  with a 1.5s TTL cache so an SSE ping costs one round of git calls.
- /api/conversation-uncommitted-diff prefers it, keeping only the replayed
  files that fall outside the worktree's repo; the replay stays the fallback
  once a worktree is torn down.
- editsdiff: normalise ~/worktrees/<dir>/… and ~/projects/<slug>/… onto the
  repo-relative paths each repo token's git commands use, and drop __pycache__
  and unexpanded-tilde noise.
- UI: the Changes row shows the branch, DiffView a branch chip; diff-blob
  serves image blobs from the worktree copy.
2026-08-09 23:11:24 +02:00

339 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Real ``git diff`` of a conversation's live git worktree(s).
Conversations do their work in an isolated worktree (``scripts/new-worktree.sh``
→ ``~/worktrees/<repo>-<name>``), so until that branch merges its changes are
invisible to the canonical checkouts the container mounts — which is why
``editsdiff`` reconstructs the "not committed yet" view from the transcript.
Mounting the worktrees root (``WORKTREES_DIR``, read-only) lets us diff the real
thing instead, and a real diff beats the replay on every axis: it sees edits made
by shell tools (``sed``, ``npm``, a generator script), tracks renames, counts
untracked files, and is exact rather than approximate.
The translation trick: a worktree's ``.git`` is a *file* pointing at a **host**
path (``/home/<u>/projects/<slug>/.git/worktrees/<dir>``), which does not exist
inside the container. We parse it, map the host repo root onto its mounted
equivalent (``PROJECTS_DIR/<slug>`` or ``REPO_DIR``), and drive git with an
explicit ``--git-dir`` / ``--work-tree`` pair. The superproject's ``.git`` is
mounted read-only, hence ``--no-optional-locks`` everywhere.
The transcript replay stays as the fallback for conversations whose worktree has
already been torn down (see ``editsdiff``).
"""
from __future__ import annotations
import os
import re
import subprocess
import threading
import time
from pathlib import Path, PurePosixPath
from editsdiff import _unified_chunk
from gitdiff import _parse_unified_diff
from projects import PROJECTS_DIR, REPO_DIR
WORKTREES_DIR = Path(os.environ.get("WORKTREES_DIR", "/worktrees"))
# `.git` in a linked worktree is a file: `gitdir: <abs path>`.
_GITDIR_RE = re.compile(r"^gitdir:\s*(\S.*?)\s*$", re.M)
_WT_MARKER = "/.git/worktrees/"
# An untracked file bigger than this is listed without content (a whole new
# 40 MB asset has no business being inlined into a diff response).
_MAX_NEW_FILE_BYTES = 512 * 1024
# Cap a blob served out of a worktree, mirroring gitdiff._MAX_BLOB_BYTES.
_MAX_BLOB_BYTES = 12 * 1024 * 1024
# --------------------------------------------------------------------------- #
# resolve a worktree directory → git handles
# --------------------------------------------------------------------------- #
def _container_gitdir(host_gitdir: str) -> tuple[Path, str] | None:
"""Map a worktree's host ``gitdir`` path → (container gitdir, repo token).
``/home/u/projects/ai-agent/.git/worktrees/ai-agent-x`` → the mounted
``PROJECTS_DIR/ai-agent/.git/worktrees/ai-agent-x`` + ``project:ai-agent``;
anything else is assumed to be the superproject (``REPO_DIR``).
"""
idx = host_gitdir.find(_WT_MARKER)
if idx < 0:
return None
host_root = PurePosixPath(host_gitdir[:idx])
wt_name = host_gitdir[idx + len(_WT_MARKER):].strip("/")
if not wt_name or "/" in wt_name:
return None
if host_root.parent.name == PROJECTS_DIR.name and host_root.name:
root, token = PROJECTS_DIR / host_root.name, f"project:{host_root.name}"
else:
root, token = REPO_DIR, "super:_"
gitdir = root / ".git" / "worktrees" / wt_name
return (gitdir, token) if gitdir.is_dir() else None
def resolve(dir_name: str) -> dict | None:
"""A live worktree directory basename → its git handles, or None.
None means "not usable": the directory is gone (torn down), isn't a linked
worktree, or its owning repo isn't mounted — all cases where the caller
should fall back to the transcript replay.
"""
safe = Path(dir_name or "").name
if not safe or safe.startswith("."):
return None
work_tree = WORKTREES_DIR / safe
dotgit = work_tree / ".git"
if not dotgit.is_file():
return None
try:
m = _GITDIR_RE.search(dotgit.read_text(encoding="utf-8", errors="replace")[:4096])
except OSError:
return None
if not m:
return None
resolved = _container_gitdir(m.group(1))
if not resolved:
return None
gitdir, token = resolved
wt = {"dir": safe, "workTree": work_tree, "gitDir": gitdir, "repo": token,
"branch": None}
wt["branch"] = (_git(wt, ["rev-parse", "--abbrev-ref", "HEAD"]) or "").strip() or None
return wt
def _git(wt: dict, args: list[str], timeout: int = 20) -> str | None:
cmd = ["git", "-c", "safe.directory=*", "--no-optional-locks",
f"--git-dir={wt['gitDir']}", f"--work-tree={wt['workTree']}", *args]
try:
out = subprocess.run(cmd, capture_output=True, text=True,
cwd=str(wt["workTree"]), timeout=timeout)
except (OSError, subprocess.SubprocessError):
return None
return out.stdout if out.returncode == 0 else None
def live_worktrees(summary: dict, meta: dict) -> list[dict]:
"""Every still-usable worktree a conversation created (newest first).
Worktrees the conversation explicitly removed are skipped, as are ones whose
directory no longer resolves — both mean "diff it from the transcript".
"""
seen: set[str] = set()
out: list[dict] = []
entries = (summary.get("worktreesAuto") or []) + (meta.get("worktrees") or [])
for e in entries:
name = (e or {}).get("dir") or (e or {}).get("name")
if not name or name in seen:
continue
seen.add(name)
if (e or {}).get("removedAt"):
continue
wt = resolve(name)
if wt:
out.append(wt)
return out
# --------------------------------------------------------------------------- #
# worktree → DiffResult
# --------------------------------------------------------------------------- #
def _untracked(wt: dict) -> list[str]:
out = _git(wt, ["ls-files", "--others", "--exclude-standard", "-z"])
return [p for p in (out or "").split("\0") if p]
def _new_file_entry(wt: dict, rel: str, *, stats: bool) -> tuple[str | None, dict | None]:
"""An untracked file as either a unified-diff chunk or a bare file model.
Returns ``(chunk, file)`` — exactly one is set (or neither, when the path is
unreadable). Binary / oversized / stats-only files skip the content read and
come back as a file model with no hunks.
"""
path = wt["workTree"] / rel
try:
if path.is_symlink() or not path.is_file():
return None, None
size = path.stat().st_size
data = b"" if size > _MAX_NEW_FILE_BYTES else path.read_bytes()
except OSError:
return None, None
binary = b"\0" in data[:8192]
bare = {"path": rel, "oldPath": None, "status": "added",
"additions": 0, "deletions": 0, "binary": binary,
"truncated": size > _MAX_NEW_FILE_BYTES, "hunks": []}
if binary or size > _MAX_NEW_FILE_BYTES:
return None, bare
text = data.decode("utf-8", errors="replace")
lines = len(text.splitlines())
if stats:
return None, {**bare, "additions": lines}
chunk = _unified_chunk(rel, "added", None, text)
if not chunk:
return None, {**bare, "additions": lines}
return chunk, None
def diff_one(wt: dict, *, stats: bool = False) -> list[dict]:
"""One worktree's tracked-changes-vs-HEAD + untracked files, as file models.
``stats`` skips the hunk bodies (the conversation panel only needs paths and
+/ counts, and it refetches on every SSE ping).
"""
key = (wt["dir"], stats)
now = time.monotonic()
with _cache_lock:
hit = _cache.get(key)
if hit and now - hit[0] < _CACHE_TTL:
return hit[1]
files = _diff_one(wt, stats=stats)
with _cache_lock:
_cache[key] = (now, files)
while len(_cache) > _CACHE_CAP:
_cache.pop(next(iter(_cache)))
return files
# One SSE ping invalidates both the stats and the full query, so the panel and
# the diff page ask within milliseconds of each other — a short TTL collapses
# that into one round of git calls per worktree.
_CACHE_TTL = 1.5
_CACHE_CAP = 16
_cache: dict[tuple[str, bool], tuple[float, list[dict]]] = {}
_cache_lock = threading.Lock()
def _diff_one(wt: dict, *, stats: bool) -> list[dict]:
files: list[dict] = []
if stats:
raw = _git(wt, ["diff", "HEAD", "--numstat", "-M", "-z"]) or ""
status = _status_map(wt)
for path, add, dele in _parse_numstat_z(raw):
files.append({"path": path, "oldPath": None,
"status": status.get(path, "modified"),
"additions": add, "deletions": dele, "binary": False,
"truncated": False, "hunks": []})
else:
patch = _git(wt, ["diff", "HEAD", "--no-color", "--no-ext-diff",
"-U3", "-M"]) or ""
files += _parse_unified_diff(patch)
chunks: list[str] = []
for rel in _untracked(wt):
chunk, bare = _new_file_entry(wt, rel, stats=stats)
if chunk:
chunks.append(chunk)
elif bare:
files.append(bare)
files += _parse_unified_diff("\n".join(chunks))
for f in files:
f["repo"] = wt["repo"]
f["wt"] = wt["dir"]
return files
_STATUS_LETTERS = {"A": "added", "D": "deleted", "R": "renamed", "C": "added"}
def _status_map(wt: dict) -> dict[str, str]:
"""path → diff status, so the hunk-less ``stats`` payload isn't all
"modified". ``--name-status -z`` emits ``<letter>\\0<path>`` (renames carry
a second path field, which is the one we key on)."""
fields = [f for f in (_git(wt, ["diff", "HEAD", "--name-status", "-M", "-z"]) or "").split("\0") if f != ""]
out: dict[str, str] = {}
i = 0
while i + 1 < len(fields):
letter = fields[i][:1]
take = 3 if letter in ("R", "C") else 2
path = fields[i + take - 1]
out[path] = _STATUS_LETTERS.get(letter, "modified")
i += take
return out
def _parse_numstat_z(raw: str) -> list[tuple[str, int, int]]:
"""``git diff --numstat -z`` → (path, additions, deletions) triples.
With ``-z`` a rename emits its two paths as separate NUL-terminated fields
after the counts, so the record length varies — hence the manual walk.
"""
fields = [f for f in raw.split("\0")]
out: list[tuple[str, int, int]] = []
i = 0
while i < len(fields):
rec = fields[i]
i += 1
if not rec:
continue
parts = rec.split("\t")
if len(parts) < 3:
continue
add, dele, path = parts[0], parts[1], parts[2]
if not path: # rename: the old + new paths are the next two fields
if i + 1 >= len(fields):
break
path = fields[i + 1]
i += 2
out.append((path, int(add) if add.isdigit() else 0,
int(dele) if dele.isdigit() else 0))
return out
def subtitle(wts: list[dict], n: int, extra: int = 0) -> str:
"""The DiffResult subtitle: which worktree(s), how many files, how they were
read. ``extra`` counts files merged in from the transcript replay because
they live outside the worktree(s)."""
where = ", ".join(w["branch"] or w["dir"] for w in wts)
tail = f" (+{extra} outside it, replayed)" if extra else ""
return (f"worktree {where} · {n} file{'s' if n != 1 else ''} · "
f"uncommitted, read from the working tree{tail}")
def worktree_diff(summary: dict, meta: dict, title: str, *,
stats: bool = False) -> dict | None:
"""The conversation's uncommitted work, read from its live worktree(s).
None when no worktree resolves — the caller then falls back to the
transcript replay. An empty (but non-None) result is meaningful: it means the
worktree really is clean.
"""
wts = live_worktrees(summary, meta)
if not wts:
return None
files: list[dict] = []
for wt in wts:
files += diff_one(wt, stats=stats)
files.sort(key=lambda f: (f.get("wt") or "", f["path"]))
add = sum(f["additions"] for f in files)
dele = sum(f["deletions"] for f in files)
n = len(files)
return {
"title": title or "Worktree changes",
"subtitle": subtitle(wts, n),
"files": files, "additions": add, "deletions": dele,
"commits": None, "approx": False, "source": "worktree",
"worktrees": [{"dir": w["dir"], "branch": w["branch"], "repo": w["repo"]}
for w in wts],
}
def blob_bytes(dir_name: str, path: str) -> bytes | None:
"""Raw bytes of a working-tree file inside a worktree (the diff's image side)."""
wt = resolve(dir_name)
if not wt:
return None
rel = Path(path)
if not path or rel.is_absolute() or ".." in rel.parts:
return None
root = wt["workTree"].resolve()
target = (root / rel).resolve()
try:
target.relative_to(root)
except ValueError:
return None
if not target.is_file():
return None
try:
data = target.read_bytes()
except OSError:
return None
return data if len(data) <= _MAX_BLOB_BYTES else None