Files
ai-agent/backend/editsdiff.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

448 lines
19 KiB
Python

"""Uncommitted-changes diff, reconstructed from a conversation's transcript.
The **fallback** half of the "current changes, not committed yet" view: when the
conversation's worktree still exists it is diffed for real (``worktreediff``),
and this module takes over once that worktree is gone — or for edits made
outside it. It rebuilds the view purely from the transcript: every
Write/Edit/MultiEdit ``toolUseResult`` carries
the target path, the full new content (writes), the exact replacement strings
(edits) and a ``structuredPatch``; ``rm`` commands in Bash calls mark deletes.
Replaying those in timestamp order yields each file's before/after content. The
conversation's commits (per repo, from ``gitdiff.conversation_commits``) set a
cutoff: events at or before a repo's last commit count as committed, and the
baseline for the diff is the file's replayed content at that cutoff — so the
result approximates ``git diff HEAD`` in the (possibly gone) working tree.
When a file's full content can't be reconstructed (its first sighting is an
Edit without ``originalFile``), we fall back to showing its post-cutoff
``structuredPatch`` hunks verbatim — approximate, but honest per-edit diffs.
Only paths inside a known checkout are considered — the homelab repo, a
``~/projects/<slug>`` repo, or a worktree of either (``~/worktrees/<dir>``), all
normalised onto the repo-relative paths their git diffs use. Edits under the
untracked root ``data/`` tree, ``node_modules``, ``.env``, ``/tmp`` or the
user's home dot-dirs can never be committed, so they'd pollute the view forever.
"""
from __future__ import annotations
import difflib
import json
import os.path
import re
from datetime import datetime
from pathlib import Path
from gitdiff import _parse_unified_diff
from projects import PROJECTS_DIR
# A path inside the homelab checkout or any of its worktrees/clones
# (`/home/<u>/homelab/…`, `/home/<u>/homelab-<wt>/…`) → repo-relative tail.
_REPO_PATH_RE = re.compile(r"^/home/[^/]+/homelab[^/]*/(.+)$")
# A path inside a project checkout (`/home/<u>/projects/<slug>/…`) — projects
# live outside the superproject but are addressed as `projects/<slug>/…`.
_PROJECT_PATH_RE = re.compile(r"^/home/[^/]+/projects/([^/]+)/(.+)$")
# A path inside a worktree spun up by `new-worktree.sh`
# (`/home/<u>/worktrees/<dir>/…`, where <dir> is `homelab-<name>` for a
# superproject worktree or `<project>-<name>` for a project one).
_WORKTREE_PATH_RE = re.compile(r"^/home/[^/]+/worktrees/([^/]+)/(.+)$")
_PROJECT_RE = re.compile(r"^projects/([A-Za-z0-9._][\w.-]*)(?:/|$)")
_SERVICE_RE = re.compile(r"^services/([A-Za-z0-9._][\w.-]*)(?:/|$)")
# Repo-relative paths that are never committed (untracked/generated). The
# superproject's untracked `data/` tree is handled separately, in `_skip`.
# `~/…` shows up because an unexpanded tilde in an `rm` gets joined onto the
# command's cwd — it never named a file in the repo.
_SKIP_REL_RE = re.compile(
r"(?:^|/)\.env|(?:^|/)node_modules/|(?:^|/)__pycache__(?:/|$)|^~/")
# `rm …` at a command position (start, or right after a shell separator) — the
# same anchoring trick conversations.py uses for new-worktree.sh, so prose or
# grep patterns *about* rm don't register as deletes.
_RM_RE = re.compile(r"(?:^|[\n;&|(]|\b(?:sudo|command)[ \t])[ \t]*rm[ \t]+([^\n|&;<>]*)")
_GLOBBY = set("*?[]{}$`\"'\\")
def _ts(iso: str | None) -> datetime | None:
if not iso:
return None
try:
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
except ValueError:
return None
_SLUGS: list[str] | None = None
def _project_slugs() -> list[str]:
"""Project directory names, longest first — a worktree dir is
``<slug>-<name>``, and slugs can themselves contain dashes, so the longest
matching prefix wins (`ai-agent-hold-fix` → `ai-agent`)."""
global _SLUGS
if _SLUGS is None:
try:
_SLUGS = sorted((p.name for p in PROJECTS_DIR.iterdir() if p.is_dir()),
key=len, reverse=True)
except OSError:
_SLUGS = []
return _SLUGS
def _worktree_slug(wt_dir: str) -> str | None:
"""The project a worktree directory belongs to (``ai-agent-hold-fix`` →
``ai-agent``), or None for a superproject worktree / an unknown repo."""
if wt_dir == "homelab" or wt_dir.startswith("homelab-"):
return None
for slug in _project_slugs():
if wt_dir == slug or wt_dir.startswith(slug + "-"):
return slug
return None
def _in_project(slug: str, tail: str) -> tuple[str, str]:
"""A project-relative path → the (path, token) pair ``gitdiff.resolve_repo``
speaks: project-relative when the project has its own ``.git``, else
superproject-relative (``projects/<slug>/…``), matching how that token's git
commands are scoped."""
own_git = (PROJECTS_DIR / slug / ".git").exists()
return (tail if own_git else f"projects/{slug}/{tail}"), f"project:{slug}"
def _rel_and_token(path: str) -> tuple[str, str] | None:
"""Map an absolute edited path → (repo-relative path, repo token), or None
when it lives outside the homelab checkout / is never-committed noise.
Handles the main checkout, a ``~/projects/<slug>`` checkout and either
flavour of worktree (`~/worktrees/homelab-<name>`, `~/worktrees/<proj>-<name>`)
— all normalised onto the paths the git-side diffs use for that repo token.
"""
rel = None
if (m := _WORKTREE_PATH_RE.match(path or "")):
slug = _worktree_slug(m.group(1))
if slug:
return _skip(*_in_project(slug, m.group(2)))
# a superproject worktree only when its dir says so — an unknown repo
# would otherwise map onto homelab paths that don't exist there.
rel = m.group(2) if m.group(1).startswith("homelab") else None
elif (m := _PROJECT_PATH_RE.match(path or "")):
return _skip(*_in_project(m.group(1), m.group(2)))
elif (m := _REPO_PATH_RE.match(path or "")):
rel = m.group(1)
if not rel:
return None
if (pm := _PROJECT_RE.match(rel)): # legacy in-repo projects/ layout
return _skip(*_in_project(pm.group(1), rel[len(pm.group(0)):]))
if (sm := _SERVICE_RE.match(rel)):
return _skip(rel, f"service:{sm.group(1)}")
return _skip(rel, "super:_")
def _skip(rel: str, token: str) -> tuple[str, str] | None:
"""Drop never-committed noise (``node_modules``, ``.env``) from a resolved
pair. ``data/`` only counts as noise at the superproject root, where it is
the untracked scratch tree."""
if _SKIP_REL_RE.search(rel) or (token == "super:_" and rel.startswith("data/")):
return None
return rel, token
# --------------------------------------------------------------------------- #
# transcript → chronological edit events
# --------------------------------------------------------------------------- #
def _rm_paths(command: str, cwd: str | None) -> list[str]:
"""Absolute paths an `rm` in a Bash command plausibly deleted."""
out: list[str] = []
for m in _RM_RE.finditer(command or ""):
for tok in m.group(1).split():
if tok.startswith("-"):
continue
if any(c in _GLOBBY for c in tok):
continue
if not tok.startswith("/"):
if not cwd:
continue
tok = os.path.join(cwd, tok)
out.append(os.path.normpath(tok))
return out
def _events_from_file(path: Path) -> list[dict]:
"""File-mutation events (edit/write/delete) mined from one JSONL transcript."""
events: list[dict] = []
last_ts = ""
try:
fh = path.open(encoding="utf-8", errors="replace")
except OSError:
return events
with fh:
for line in fh:
has_patch = '"structuredPatch"' in line
has_rm = '"Bash"' in line and "rm " in line
if not has_patch and not has_rm:
continue
try:
o = json.loads(line)
except (json.JSONDecodeError, UnicodeDecodeError):
continue
ts = o.get("timestamp") or last_ts
last_ts = ts
tr = o.get("toolUseResult")
if has_patch and isinstance(tr, dict) and tr.get("filePath"):
ev = {"ts": ts, "file": str(tr["filePath"]),
"patch": tr.get("structuredPatch") or [],
"orig": tr.get("originalFile")}
if "oldString" in tr:
ev.update(kind="edit",
edits=[(tr.get("oldString") or "",
tr.get("newString") or "",
bool(tr.get("replaceAll")))])
elif isinstance(tr.get("edits"), list): # MultiEdit
ev.update(kind="edit", edits=[
(e.get("oldString") or e.get("old_string") or "",
e.get("newString") or e.get("new_string") or "",
bool(e.get("replaceAll") or e.get("replace_all")))
for e in tr["edits"] if isinstance(e, dict)])
elif tr.get("type") in ("create", "update"):
ev.update(kind="write", content=tr.get("content"),
create=tr.get("type") == "create")
else:
ev.update(kind="patch")
events.append(ev)
continue
if has_rm:
msg = o.get("message") or {}
content = msg.get("content")
if not isinstance(content, list):
continue
for blk in content:
if not (isinstance(blk, dict) and blk.get("type") == "tool_use"
and blk.get("name") == "Bash"):
continue
cmd = (blk.get("input") or {}).get("command") or ""
for p in _rm_paths(cmd, o.get("cwd")):
events.append({"ts": ts, "file": p, "kind": "delete"})
return events
def _all_events(transcript: Path) -> list[dict]:
"""The conversation's events plus its subagents', merged chronologically."""
events = _events_from_file(transcript)
subdir = transcript.parent / transcript.stem / "subagents"
if subdir.is_dir():
for sp in sorted(subdir.glob("agent-*.jsonl")):
events += _events_from_file(sp)
events.sort(key=lambda e: e["ts"] or "")
return events
# --------------------------------------------------------------------------- #
# replay → per-file before/after
# --------------------------------------------------------------------------- #
def _apply_edits(content: str, edits: list[tuple[str, str, bool]]) -> str | None:
"""Apply Edit-tool replacements; None when an oldString no longer matches
(external/user modification — full content is no longer trustworthy)."""
for old, new, replace_all in edits:
if not old:
return None
if old not in content:
return None
content = content.replace(old, new) if replace_all \
else content.replace(old, new, 1)
return content
def _replay_file(events: list[dict], cutoff: datetime | None) -> dict | None:
"""Replay one file's events; snapshot the baseline at the commit cutoff.
Returns ``{baseline: (known, content, exists), final: (…), patches: […]}``
or None when every event predates the cutoff (i.e. it's all committed).
"""
known, content, exists = False, None, None # exists=None → never seen yet
baseline = None
patches: list = []
for e in events:
et = _ts(e["ts"])
is_post = cutoff is None or et is None or et > cutoff
if is_post and baseline is None:
b_known, b_content, b_exists = known, content, exists
if b_exists is None: # first sighting is post-cutoff → infer prior
b_exists = not (e["kind"] == "write" and e.get("create"))
orig = e.get("orig")
if not b_exists:
b_known, b_content = True, None
elif isinstance(orig, str):
b_known, b_content = True, orig
baseline = (b_known, b_content, b_exists)
kind = e["kind"]
if kind == "delete":
known, content, exists = True, None, False
continue
if kind == "write":
body = e.get("content")
known = isinstance(body, str)
content = body if known else None
exists = True
elif kind == "edit":
base = content if known and content is not None else (
e["orig"] if isinstance(e.get("orig"), str) else None)
content = _apply_edits(base, e["edits"]) if base is not None else None
known = content is not None
exists = True
else: # bare patch (NotebookEdit & co) — content can't be replayed
known, content, exists = False, None, True
if is_post and e.get("patch"):
patches.append(e["patch"])
if baseline is None:
return None
return {"baseline": baseline, "final": (known, content, exists),
"patches": patches}
# --------------------------------------------------------------------------- #
# before/after → the DiffResult file model
# --------------------------------------------------------------------------- #
def _unified_chunk(rel: str, status: str, a: str | None, b: str | None) -> str | None:
a_lines = a.splitlines() if a is not None else []
b_lines = b.splitlines() if b is not None else []
body = list(difflib.unified_diff(
a_lines, b_lines,
fromfile="/dev/null" if a is None else f"a/{rel}",
tofile="/dev/null" if b is None else f"b/{rel}",
lineterm=""))
if not body:
return None
head = [f"diff --git a/{rel} b/{rel}"]
if status == "added":
head.append("new file mode 100644")
elif status == "deleted":
head.append("deleted file mode 100644")
return "\n".join(head + body)
def _patch_file(rel: str, status: str, patches: list) -> dict | None:
"""Fallback file model: the raw post-cutoff structuredPatch hunks, verbatim.
Successive hunks describe successive intermediate versions, not one net
diff — the whole response is flagged ``approx`` for this reason."""
hunks: list[dict] = []
add = dele = 0
for sp in patches:
for h in sp if isinstance(sp, list) else []:
try:
old_no, new_no = int(h["oldStart"]), int(h["newStart"])
header = (f"@@ -{h['oldStart']},{h['oldLines']}"
f" +{h['newStart']},{h['newLines']} @@")
raw_lines = h.get("lines") or []
except (KeyError, TypeError, ValueError):
continue
lines: list[dict] = []
hs, hn = old_no, new_no
for raw in raw_lines:
raw = raw if isinstance(raw, str) else str(raw)
tag, body = raw[:1], raw[1:]
if tag == "+":
lines.append({"type": "add", "oldNo": None, "newNo": hn, "text": body})
hn += 1
add += 1
elif tag == "-":
lines.append({"type": "del", "oldNo": hs, "newNo": None, "text": body})
hs += 1
dele += 1
else:
lines.append({"type": "ctx", "oldNo": hs, "newNo": hn, "text": body})
hs += 1
hn += 1
hunks.append({"header": header,
"oldStart": old_no, "oldLines": int(h["oldLines"]),
"newStart": new_no, "newLines": int(h["newLines"]),
"lines": lines})
if not hunks:
return None
return {"path": rel, "oldPath": None, "status": status,
"additions": add, "deletions": dele, "binary": False,
"truncated": False, "hunks": hunks}
def uncommitted_diff(transcript: Path, title: str, commits: list[dict]) -> dict:
"""The conversation's net not-yet-committed diff, as a DiffResult dict."""
# Per-repo cutoff = its newest conversation commit; edits after it are the
# uncommitted tail. A repo with no commits has no cutoff (all uncommitted).
cutoffs: dict[str, datetime] = {}
for c in commits or []:
dt = _ts(c.get("date"))
if dt and (c.get("repo") not in cutoffs or dt > cutoffs[c["repo"]]):
cutoffs[c["repo"]] = dt
by_file: dict[str, tuple[str, list[dict]]] = {}
for e in _all_events(transcript):
if e["kind"] == "delete":
# `rm dir` (or a worktree teardown) may cover several tracked files.
hit = False
for path, (rel, evs) in by_file.items():
if path == e["file"] or path.startswith(e["file"] + "/"):
evs.append(dict(e, file=path))
hit = True
if hit:
continue
rt = _rel_and_token(e["file"])
if not rt:
continue
by_file.setdefault(e["file"], (rt[0], []))[1].append(e)
unified_chunks: list[str] = []
chunk_tokens: list[str] = []
patch_files: list[dict] = []
for path, (rel, evs) in sorted(by_file.items(), key=lambda kv: kv[1][0]):
token = _rel_and_token(path)[1] # type: ignore[index]
st = _replay_file(evs, cutoffs.get(token))
if st is None:
continue # fully committed
(b_known, b_content, b_exists) = st["baseline"]
(f_known, f_content, f_exists) = st["final"]
if f_exists is False: # deleted at the end
if b_exists is False:
continue # created and deleted after the cutoff — net nothing
if b_known and b_content is not None:
chunk = _unified_chunk(rel, "deleted", b_content, None)
if chunk:
unified_chunks.append(chunk)
chunk_tokens.append(token)
else:
patch_files.append({
"path": rel, "oldPath": None, "status": "deleted",
"additions": 0, "deletions": 0, "binary": False,
"truncated": False, "hunks": [], "repo": token})
continue
status = "added" if b_exists is False else "modified"
if b_known and f_known:
if b_exists is not False and (b_content or "") == (f_content or ""):
continue # touched, but net-identical to the committed state
chunk = _unified_chunk(
rel, status,
None if b_exists is False else (b_content or ""),
f_content or "")
if chunk:
unified_chunks.append(chunk)
chunk_tokens.append(token)
continue
pf = _patch_file(rel, status, st["patches"])
if pf:
pf["repo"] = token
patch_files.append(pf)
files = _parse_unified_diff("\n".join(unified_chunks))
for f, token in zip(files, chunk_tokens):
f["repo"] = token
files += patch_files
files.sort(key=lambda f: 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 "Uncommitted changes",
"subtitle": (f"uncommitted · {n} file{'s' if n != 1 else ''} · "
"replayed from the transcript's edits"),
"files": files, "additions": add, "deletions": dele,
"commits": None, "approx": True,
}