Files
Agent-System-Dev/scripts/workspace/git.py
T
weichen b450e51c40 Add safe component editing and explicit legacy workspace transition
Why: Preserve a single Dev entry and independent repository ownership while allowing active feature branches to migrate without losing source or Git state.

Evidence: SELF 29 real Git/Go workspace tests and ordinary component evidence/lock tests passed; detailed RED/GREEN runs are recorded by make evidence in the task card.

Impact: Add fixed cloning, selected-version baselines, development overlays, npm source resolution, and independently preserved legacy snapshots. No other active worktree is modified.

Refs: Versioned dependencies U2/U5; R15; feature/dependency-workspace
2026-10-13 15:58:23 +00:00

71 lines
3.8 KiB
Python

from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from .common import WorkspaceError, run
def display_origin(value):
parsed = urlsplit(value)
netloc = parsed.netloc
if parsed.username and (parsed.scheme != "ssh" or parsed.username != "git" or parsed.password):
netloc = "<redacted>@" + netloc.rsplit("@", 1)[-1]
return urlunsplit((parsed.scheme, netloc, parsed.path,
"<redacted>" if parsed.query else "", "<redacted>" if parsed.fragment else ""))
class Git:
def __init__(self, env):
self.env = env
def call(self, path, *args, check=True):
return run(["git", "-c", "gc.auto=0", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", *args], path, self.env, check)
def text(self, path, *args, check=True):
return self.call(path, *args, check=check).stdout.strip()
def inspect(self, path):
if not (path / ".git").exists():
raise WorkspaceError(f"existing path is not an independent Git checkout: {path}")
top = Path(self.text(path, "rev-parse", "--show-toplevel"))
if top.resolve() != path.resolve():
raise WorkspaceError("component path is not a Git root")
gitdir = Path(self.text(path, "rev-parse", "--absolute-git-dir"))
common = Path(self.text(path, "rev-parse", "--path-format=absolute", "--git-common-dir"))
locks = sorted(str(file) for directory in {gitdir, common} for file in directory.rglob("*.lock"))
operations = [name for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG")
if (gitdir / name).exists()]
changes = self.text(path, "status", "--porcelain=v1", "--untracked-files=all")
ignored = self.text(path, "ls-files", "--others", "--ignored", "--exclude-standard")
head = self.text(path, "rev-parse", "--verify", "HEAD", check=False)
branch = self.text(path, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
worktrees = self.text(path, "worktree", "list", "--porcelain")
return {"head": head or None, "branch": branch or None,
"origin": display_origin(self.text(path, "remote", "get-url", "origin", check=False)),
"dirty": bool(changes or ignored), "changes": changes.splitlines(), "ignored": ignored.splitlines(),
"locks": locks, "operations": operations,
"worktrees": [line[9:] for line in worktrees.splitlines() if line.startswith("worktree ")],
"git_dir": str(gitdir), "common_dir": str(common)}
def safe(self, path, origin=None, clean=True, independent=True):
actual = self.inspect(path)
if origin is not None and actual["origin"] != origin:
raise WorkspaceError(f"origin mismatch at {path}")
if actual["locks"] or actual["operations"]:
raise WorkspaceError(f"Git operation/lock in progress at {path}")
if independent and (actual["git_dir"] != actual["common_dir"] or len(actual["worktrees"]) != 1):
raise WorkspaceError(f"shared Git worktree ownership at {path}; owner must resolve it")
if independent and ((path / ".git").is_symlink() or not (path / ".git").is_dir()
or Path(actual["git_dir"]).resolve() != (path / ".git").resolve()):
raise WorkspaceError(f"component Git metadata is owned outside its checkout: {path}")
if clean and actual["dirty"]:
raise WorkspaceError(f"dirty or ignored unique files at {path}; owner must save them")
return actual
def gitlink(self, root, legacy):
if not legacy:
return None
result = self.text(root, "ls-tree", "HEAD", "--", legacy, check=False)
if result.startswith("160000 commit "):
return result.split()[2]
return None