Files
nanobot/nanobot/utils/gitstore.py
T
92361cbeac fix(gitstore): return real git object ids instead of hex-of-hex
`porcelain.commit()` and `repo.refs[...]` hand back object ids as a
40-character hex string that is already encoded to bytes. Calling `.hex()`
on that encodes the ASCII a second time, so every id GitStore produced or
displayed was double-encoded:

    auto_commit()          -> '62623234'
    git log --abbrev=8     -> 'bb244606'

The module is self-consistently wrong, so `/dream-log` and `/dream-restore`
work as long as the id came from nanobot itself. What does not work is
crossing the boundary: ids in logs and commit output match nothing in
`git log`, and an id copied from `git log` cannot be resolved:

    _resolve_sha(own id)      -> b'bb244606d780...'
    _resolve_sha(real git id) -> None

Use `.decode()` at the four sites that consume dulwich object ids. Nothing
persists an id — callers either display it or resolve it live — so there is
no stored state in the old format.

Adds two regression tests: the id returned by `auto_commit` must equal
`git log --abbrev=8`, and a real git id must resolve through `_resolve_sha`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 18:25:56 +08:00

586 lines
22 KiB
Python

"""Git-backed version control for memory files, using dulwich."""
from __future__ import annotations
import io
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, cast
from loguru import logger
if TYPE_CHECKING:
from dulwich.objects import Blob, Commit, ObjectID, Tree, TreeEntry
from dulwich.refs import Ref
from dulwich.repo import Repo
# Cap on the unified-diff block embedded in Dream commit messages. Memory files
# are tiny in practice, but a pathological rewrite must not blow up the audit
# record. The structured per-file summary is always emitted in full regardless.
_WORKING_TREE_DIFF_MAX_CHARS = 6000
class GitStoreError(RuntimeError):
"""Raised when the memory Git repository cannot complete an operation."""
@dataclass
class CommitInfo:
sha: str # Short SHA (8 chars)
message: str
timestamp: str # Formatted datetime
def subject(self) -> str:
"""First line of the commit message, or a placeholder if empty."""
lines = self.message.splitlines()
return lines[0] if lines else "(no message)"
def format(self, diff: str = "") -> str:
"""Format this commit for display, optionally with a diff."""
header = f"## {self.subject()}\n`{self.sha}` — {self.timestamp}\n"
if diff:
return f"{header}\n```diff\n{diff}\n```"
return f"{header}\n(no file changes)"
@dataclass
class LineAge:
"""Age of a single line based on git blame."""
age_days: int # days since last modification
def _compute_line_ages(
annotated: Iterable[tuple[tuple["Commit", "TreeEntry"], bytes]],
) -> list[LineAge]:
"""Convert annotate results to per-line ages."""
now = datetime.now(tz=timezone.utc).date()
ages: list[LineAge] = []
for (commit, _tree_entry), _line_bytes in annotated:
dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date()
ages.append(LineAge(age_days=(now - dt).days))
return ages
class GitStore:
"""Git-backed version control for memory files."""
def __init__(self, workspace: Path, tracked_files: list[str]):
self._workspace = workspace
self._tracked_files = tracked_files
def is_initialized(self) -> bool:
"""Check if the git repo has been initialized."""
return (self._workspace / ".git").is_dir()
# -- init ------------------------------------------------------------------
def init(self) -> bool:
"""Initialize a git repo if not already initialized.
Creates .gitignore and makes an initial commit.
Returns True if a new repo was created, False if already exists.
"""
if self.is_initialized():
return False
if self._is_inside_git_repo():
logger.warning(
"Workspace {} is already inside a git repo; "
"skipping nested repo initialization",
self._workspace,
)
return False
try:
from dulwich import porcelain
porcelain.init(str(self._workspace))
# Write .gitignore (merge with existing if present)
gitignore = self._workspace / ".gitignore"
dream_entries = self._build_gitignore()
if gitignore.exists():
existing = gitignore.read_text(encoding="utf-8")
existing_lines = set(existing.splitlines())
new_lines = [
line
for line in dream_entries.splitlines()
if line not in existing_lines
]
if new_lines:
merged = existing.rstrip("\n") + "\n" + "\n".join(new_lines) + "\n"
gitignore.write_text(merged, encoding="utf-8")
else:
gitignore.write_text(dream_entries, encoding="utf-8")
# Ensure tracked files exist (touch them if missing) so the initial
# commit has something to track.
for rel in self._tracked_files:
p = self._workspace / rel
p.parent.mkdir(parents=True, exist_ok=True)
if not p.exists():
p.write_text("", encoding="utf-8")
# Initial commit
porcelain.add(
str(self._workspace),
paths=self._staging_paths(".gitignore", *self._tracked_files),
)
porcelain.commit(
str(self._workspace),
message=b"init: nanobot memory store",
author=b"nanobot <nanobot@dream>",
committer=b"nanobot <nanobot@dream>",
)
logger.info("Git store initialized at {}", self._workspace)
return True
except Exception as exc:
raise GitStoreError(f"Git store init failed for {self._workspace}") from exc
# -- daily operations ------------------------------------------------------
def auto_commit(self, message: str) -> str | None:
"""Stage tracked memory files and commit if there are changes.
Returns the short commit SHA, or None if nothing to commit.
"""
if not self.is_initialized():
return None
try:
from dulwich import porcelain
# .gitignore excludes everything except tracked files,
# so any staged/unstaged change must be in our files.
st = porcelain.status(str(self._workspace))
unstaged = cast(list[object], st.unstaged)
staged = cast(dict[object, list[object]], st.staged)
if not unstaged and not any(staged.values()):
return None
message_value = cast(object, message)
msg_bytes = (
message_value.encode("utf-8")
if isinstance(message_value, str)
else cast(bytes, message_value)
)
porcelain.add(str(self._workspace), paths=self._staging_paths(*self._tracked_files))
sha_bytes = porcelain.commit(
str(self._workspace),
message=msg_bytes,
author=b"nanobot <nanobot@dream>",
committer=b"nanobot <nanobot@dream>",
)
if cast(object, sha_bytes) is None:
return None
# porcelain.commit returns the id as a 40-char hex string that is
# already encoded to bytes; .hex() would encode those ASCII bytes
# again and produce an id no git command can resolve.
sha = sha_bytes.decode()[:8]
logger.debug("Git auto-commit: {} ({})", sha, message)
return sha
except Exception as exc:
raise GitStoreError(f"Git auto-commit failed: {message}") from exc
# -- internal helpers ------------------------------------------------------
def _staging_paths(self, *paths: str) -> list[str]:
"""Return absolute paths without resolving tracked-file symlinks."""
return [str((self._workspace / path).absolute()) for path in paths]
def _resolve_sha(self, short_sha: str) -> bytes | None:
"""Resolve a short SHA prefix to the full SHA bytes."""
try:
from dulwich.repo import Repo
with Repo(str(self._workspace)) as repo:
try:
sha: ObjectID | None = repo.refs[cast("Ref", b"HEAD")]
except KeyError:
return None
while sha:
if sha.decode().startswith(short_sha):
return sha
commit_obj = repo[sha]
if commit_obj.type_name != b"commit":
break
commit = cast("Commit", commit_obj)
sha = commit.parents[0] if commit.parents else None
return None
except Exception as exc:
raise GitStoreError(f"Git SHA resolution failed: {short_sha}") from exc
def _is_inside_git_repo(self) -> bool:
"""Check if self._workspace is already inside a git repository.
Walks up from self._workspace to the filesystem root, returning True
if any parent directory contains a .git entry.
Git worktrees and submodules can use a ``.git`` file instead of a
directory, so we must treat either form as "already inside a repo".
"""
current = self._workspace.resolve()
while current != current.parent:
if (current / ".git").exists():
return True
current = current.parent
return False
def _build_gitignore(self) -> str:
"""Generate .gitignore content from tracked files."""
dirs: set[str] = set()
for f in self._tracked_files:
parent = str(Path(f).parent)
if parent != ".":
dirs.add(parent)
lines = ["/*"]
for d in sorted(dirs):
lines.append(f"!{d}/")
for f in self._tracked_files:
lines.append(f"!{f}")
lines.append("!.gitignore")
return "\n".join(lines) + "\n"
# -- query -----------------------------------------------------------------
def log(
self,
max_entries: int = 20,
message_prefix: str | None = None,
) -> list[CommitInfo]:
"""Return simplified commit log, optionally filtered by message prefix.
When filtering, *max_entries* counts matching commits rather than every
commit traversed in the repository.
"""
if not self.is_initialized():
return []
try:
from dulwich.repo import Repo
entries: list[CommitInfo] = []
with Repo(str(self._workspace)) as repo:
try:
head = repo.refs[cast("Ref", b"HEAD")]
except KeyError:
return []
sha: ObjectID | None = head
while sha and len(entries) < max_entries:
commit_obj = repo[sha]
if commit_obj.type_name != b"commit":
break
commit = cast("Commit", commit_obj)
ts = time.strftime(
"%Y-%m-%d %H:%M",
time.localtime(commit.commit_time),
)
msg = commit.message.decode("utf-8", errors="replace").strip()
if message_prefix is None or msg.startswith(message_prefix):
entries.append(CommitInfo(
sha=sha.decode()[:8],
message=msg,
timestamp=ts,
))
sha = commit.parents[0] if commit.parents else None
return entries
except Exception as exc:
raise GitStoreError("Git log failed") from exc
def line_ages(self, file_path: str) -> list[LineAge]:
"""Compute the age of each line in a tracked file via git blame.
Returns one LineAge per line, in order.
Returns an empty list if the repo is not initialized or the file is
empty. Annotation failures raise :class:`GitStoreError`.
"""
if not self.is_initialized():
return []
target = self._workspace / file_path
if not target.exists() or target.stat().st_size == 0:
return []
try:
from dulwich import porcelain
annotated = porcelain.annotate(str(self._workspace), file_path)
except Exception as exc:
raise GitStoreError(f"Git line annotation failed for {file_path}") from exc
if not annotated:
return []
return _compute_line_ages(annotated)
def diff_commits(self, sha1: str, sha2: str) -> str:
"""Show diff between two commits."""
if not self.is_initialized():
return ""
try:
from dulwich import porcelain
full1 = self._resolve_sha(sha1)
full2 = self._resolve_sha(sha2)
if not full1 or not full2:
return ""
out = io.BytesIO()
porcelain.diff(
str(self._workspace),
commit=full1,
commit2=full2,
outstream=out,
)
return out.getvalue().decode("utf-8", errors="replace")
except Exception as exc:
raise GitStoreError(f"Git diff failed for {sha1}..{sha2}") from exc
def summarize_working_tree(self, paths: list[str]) -> str:
"""Structured summary of working-tree changes vs HEAD for *paths*.
Pure filesystem/git ground truth — never LLM narrative — suitable as a
truthful audit record. Returns "" when the repo is not initialized or
none of *paths* differ from HEAD.
Format::
SOUL.md: +3 -1
memory/MEMORY.md: +12 -8
2 files changed, 15 insertions(+), 9 deletions(-)
```diff
--- SOUL.md
+++ SOUL.md
@@ ...
- old
+ new
```
"""
if not self.is_initialized():
return ""
try:
import difflib
from dulwich.repo import Repo
except ImportError as exc:
raise GitStoreError("Git working-tree summary dependencies are unavailable") from exc
summary_lines: list[str] = []
diff_lines: list[str] = []
total_added = 0
total_removed = 0
changed = 0
try:
with Repo(str(self._workspace)) as repo:
head_tree = self._head_tree(repo)
for path in paths:
head_text = (
self._read_blob_from_tree(repo, head_tree, path)
if head_tree is not None
else None
)
if head_text is None:
head_text = ""
wt_path = self._workspace / path
try:
wt_text = (
wt_path.read_bytes().decode("utf-8")
if wt_path.exists()
else ""
)
except UnicodeDecodeError:
# Non-UTF-8 (binary/corrupt) working-tree file: record
# the change without a unified diff, which would
# otherwise be polluted with replacement characters and
# misrepresent the audit record.
changed += 1
summary_lines.append(f"{path}: binary or non-UTF-8 file changed")
continue
# Treat CRLF and LF as equivalent without hiding other
# newline changes, such as a missing final newline.
if head_text.replace("\r\n", "\n") == wt_text.replace("\r\n", "\n"):
continue
head_lines = head_text.splitlines()
wt_lines = wt_text.splitlines()
changed += 1
hunks = list(difflib.unified_diff(
head_lines,
wt_lines,
fromfile=path,
tofile=path,
lineterm="",
))
added = sum(1 for line in hunks if line.startswith("+") and not line.startswith("+++"))
removed = sum(1 for line in hunks if line.startswith("-") and not line.startswith("---"))
total_added += added
total_removed += removed
summary_lines.append(f"{path}: +{added} -{removed}")
diff_lines.extend(hunks)
except Exception as exc:
raise GitStoreError("Git working-tree summary failed") from exc
if changed == 0:
return ""
diff_text = "\n".join(diff_lines)
if len(diff_text) > _WORKING_TREE_DIFF_MAX_CHARS:
diff_text = diff_text[:_WORKING_TREE_DIFF_MAX_CHARS] + "\n...[diff truncated]"
body = "\n".join(summary_lines)
body += (
f"\n{changed} file{'s' if changed != 1 else ''} changed, "
f"{total_added} insertion{'s' if total_added != 1 else ''}(+), "
f"{total_removed} deletion{'s' if total_removed != 1 else ''}(-)"
)
if diff_lines:
body += f"\n\n```diff\n{diff_text}\n```"
return body
@staticmethod
def _head_tree(repo: "Repo") -> "Tree | None":
"""Return the tree object at HEAD, or None if there are no commits."""
try:
head = repo.refs[cast("Ref", b"HEAD")]
except KeyError:
return None
commit_obj = repo[head]
if commit_obj.type_name != b"commit":
return None
commit = cast("Commit", commit_obj)
return cast("Tree", repo[commit.tree])
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
"""Find a commit by short SHA prefix match."""
for c in self.log(max_entries=max_entries):
if c.sha.startswith(short_sha):
return c
return None
def show_commit_diff(
self,
short_sha: str,
max_entries: int = 20,
message_prefix: str | None = None,
) -> tuple[CommitInfo, str] | None:
"""Find a commit and return it with its diff vs its actual parent."""
try:
from dulwich.repo import Repo
commits = self.log(max_entries=max_entries, message_prefix=message_prefix)
for c in commits:
if c.sha.startswith(short_sha):
full_sha = self._resolve_sha(c.sha)
if not full_sha:
return None
with Repo(str(self._workspace)) as repo:
commit = cast("Commit", repo[full_sha])
parent = commit.parents[0] if commit.parents else None
diff = self.diff_commits(parent.decode()[:8], c.sha) if parent else ""
return c, diff
return None
except Exception as exc:
raise GitStoreError(f"Git commit display failed for {short_sha}") from exc
# -- restore ---------------------------------------------------------------
def revert(self, commit: str, *, message_prefix: str | None = None) -> str | None:
"""Revert (undo) the changes introduced by the given commit.
Restores all tracked memory files to the state at the commit's parent,
then creates a new commit recording the revert. When *message_prefix*
is provided, commits outside that history are rejected before any files
are changed.
Returns the new commit SHA, or ``None`` when the commit cannot be reverted.
Repository and filesystem failures raise :class:`GitStoreError`.
"""
if not self.is_initialized():
return None
try:
from dulwich.repo import Repo
full_sha = self._resolve_sha(commit)
if not full_sha:
logger.warning("Git revert: SHA not found: {}", commit)
return None
with Repo(str(self._workspace)) as repo:
commit_obj = repo[full_sha]
if commit_obj.type_name != b"commit":
return None
typed_commit = cast("Commit", commit_obj)
commit_message = typed_commit.message.decode(
"utf-8",
errors="replace",
).strip()
if message_prefix is not None and not commit_message.startswith(message_prefix):
logger.warning(
"Git revert: commit {} does not match message prefix {!r}",
commit,
message_prefix,
)
return None
if not typed_commit.parents:
logger.warning("Git revert: cannot revert root commit {}", commit)
return None
# Use the parent's tree — this undoes the commit's changes
parent_obj = cast("Commit", repo[typed_commit.parents[0]])
tree = cast("Tree", repo[parent_obj.tree])
restored: list[str] = []
for filepath in self._tracked_files:
content = self._read_blob_from_tree(repo, tree, filepath)
if content is not None:
dest = self._workspace / filepath
dest.write_text(content, encoding="utf-8")
restored.append(filepath)
if not restored:
return None
# Commit the restored state
msg = f"revert: undo {commit}"
return self.auto_commit(msg)
except Exception as exc:
raise GitStoreError(f"Git revert failed for {commit}") from exc
@staticmethod
def _read_blob_from_tree(
repo: "Repo",
tree: "Tree",
filepath: str,
) -> str | None:
"""Read a blob's content from a tree object by walking path parts."""
parts = Path(filepath).parts
current = tree
for part in parts:
try:
entry = current[part.encode()]
except KeyError:
return None
obj = repo[entry[1]]
if obj.type_name == b"blob":
blob = cast("Blob", obj)
return blob.data.decode("utf-8", errors="replace")
if obj.type_name == b"tree":
current = cast("Tree", obj)
else:
return None
return None