What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
262 lines
9.1 KiB
Python
262 lines
9.1 KiB
Python
"""Git on the build VM (D4, L13): here, a throwaway sshd on this machine."""
|
|
|
|
import shlex
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nisse_core.execution import SSHHost
|
|
from nisse_dev.git import GitError, GitRepo, HTTPSToken, SSHKey
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(ssh_host: SSHHost, tmp_path: Path) -> GitRepo:
|
|
r = GitRepo(ssh_host, tmp_path / "work")
|
|
r.init()
|
|
r.configure_identity("nisse-dev-testing", "nisse@example.test")
|
|
return r
|
|
|
|
|
|
def bare_remote(path: Path) -> str:
|
|
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(path)], check=True)
|
|
return str(path)
|
|
|
|
|
|
def test_init_creates_repo_on_default_branch(repo: GitRepo) -> None:
|
|
assert repo.current_branch() == "main"
|
|
|
|
|
|
def test_commit_all_returns_sha_and_branch_switching(repo: GitRepo) -> None:
|
|
(repo.root / "a.txt").write_text("a")
|
|
sha = repo.commit_all("first")
|
|
assert sha is not None
|
|
assert sha == repo.head_sha()
|
|
|
|
repo.checkout_new_branch("milestone-1")
|
|
assert repo.current_branch() == "milestone-1"
|
|
|
|
|
|
def test_commit_with_nothing_staged_returns_none(repo: GitRepo) -> None:
|
|
(repo.root / "a.txt").write_text("a")
|
|
repo.commit_all("first")
|
|
# An empty milestone is a fact to report, not an error to raise.
|
|
assert repo.commit_all("nothing changed") is None
|
|
|
|
|
|
def test_has_changes_tracks_the_worktree(repo: GitRepo) -> None:
|
|
assert not repo.has_changes()
|
|
(repo.root / "a.txt").write_text("a")
|
|
assert repo.has_changes()
|
|
|
|
|
|
def test_git_error_on_bad_command(ssh_host: SSHHost, tmp_path: Path) -> None:
|
|
repo = GitRepo(ssh_host, tmp_path / "not-a-repo")
|
|
repo.root.mkdir()
|
|
with pytest.raises(GitError):
|
|
repo.head_sha()
|
|
|
|
|
|
def test_commits_since_and_remote_sha_against_a_local_remote(
|
|
repo: GitRepo, tmp_path: Path
|
|
) -> None:
|
|
remote = bare_remote(tmp_path / "remote.git")
|
|
(repo.root / "a.txt").write_text("a")
|
|
base = repo.commit_all("base")
|
|
repo.push(remote, "main")
|
|
repo._git("update-ref", "refs/remotes/origin/main", "HEAD")
|
|
assert repo.remote_sha(remote, "main") == base
|
|
assert repo.remote_sha(remote, "absent") is None
|
|
(repo.root / "b.txt").write_text("b")
|
|
repo.commit_all("ahead")
|
|
assert repo.commits_since("origin/main") == 1
|
|
|
|
|
|
# --- the push key (#89) ------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def ssh_remote(ssh_host: SSHHost, tmp_path: Path) -> tuple[str, SSHKey]:
|
|
"""A bare repository reached by git over SSH, and the key that may push
|
|
to it: the forge, as the build VM sees it."""
|
|
remote = bare_remote(tmp_path / "forge.git")
|
|
url = f"ssh://{ssh_host.user}@127.0.0.1:{ssh_host.port}{remote}"
|
|
return url, SSHKey(ssh_host._key.read_text())
|
|
|
|
|
|
def test_clone_and_push_with_the_key_store_no_credential(
|
|
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
|
) -> None:
|
|
url, key = ssh_remote
|
|
seed = GitRepo(ssh_host, tmp_path / "seed", known_hosts=str(tmp_path / "kh"))
|
|
seed.init()
|
|
seed.configure_identity("t", "t@example.test")
|
|
(seed.root / "a.txt").write_text("a")
|
|
seed.commit_all("first")
|
|
seed.push(url, "main", credential=key)
|
|
|
|
clone = GitRepo(ssh_host, tmp_path / "clone", known_hosts=str(tmp_path / "kh"))
|
|
clone.clone(url, credential=key)
|
|
assert (clone.root / "a.txt").read_text() == "a"
|
|
config = (clone.root / ".git" / "config").read_text()
|
|
assert "PRIVATE KEY" not in config
|
|
# The URL recorded is the credential-free one we gave.
|
|
assert url in config
|
|
|
|
|
|
def test_the_key_is_gone_when_the_command_ends(
|
|
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
|
) -> None:
|
|
url, key = ssh_remote
|
|
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
|
repo.init()
|
|
repo.configure_identity("t", "t@example.test")
|
|
(repo.root / "a.txt").write_text("a")
|
|
repo.commit_all("first")
|
|
before = _agents()
|
|
repo.push(url, "main", credential=key)
|
|
assert _agents() == before # the throwaway ssh-agent was killed
|
|
|
|
|
|
def test_a_failed_push_still_kills_its_agent(
|
|
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
|
) -> None:
|
|
_, key = ssh_remote
|
|
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
|
repo.init()
|
|
before = _agents()
|
|
with pytest.raises(GitError):
|
|
repo.push(
|
|
f"ssh://nobody@127.0.0.1:{ssh_host.port}/nowhere.git",
|
|
"main",
|
|
credential=key,
|
|
)
|
|
assert _agents() == before
|
|
|
|
|
|
def _agents() -> set[str]:
|
|
"""Live ssh-agents. A killed one lingers as a zombie in a CI container,
|
|
where nothing reaps orphans, so zombies don't count."""
|
|
out = subprocess.run(
|
|
["pgrep", "-x", "ssh-agent"], capture_output=True, text=True, check=False
|
|
).stdout
|
|
live = set()
|
|
for pid in out.split():
|
|
try:
|
|
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
except FileNotFoundError:
|
|
continue
|
|
if stat.rsplit(")", 1)[1].split()[0] != "Z":
|
|
live.add(pid)
|
|
return live
|
|
|
|
|
|
def test_credentials_never_appear_in_a_repr() -> None:
|
|
assert "s3cr3t" not in repr(HTTPSToken("pusher", "s3cr3t"))
|
|
assert "PRIVATE" not in repr(SSHKey("-----BEGIN OPENSSH PRIVATE KEY-----"))
|
|
|
|
|
|
def test_a_remote_url_is_quoted_not_interpreted(repo: GitRepo, tmp_path: Path) -> None:
|
|
marker = tmp_path / "pwned"
|
|
with pytest.raises(GitError):
|
|
repo.push(f"x; touch {shlex.quote(str(marker))}", "main")
|
|
assert not marker.exists()
|
|
|
|
|
|
class RecordingHost:
|
|
"""Captures what would be sent to the build VM."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
def execute(self, command: str, **kwargs):
|
|
from nisse_core.execution import Result
|
|
|
|
self.calls.append({"command": command, **kwargs})
|
|
return Result(0, "", "")
|
|
|
|
|
|
def test_an_https_token_travels_only_as_a_secret(tmp_path: Path) -> None:
|
|
host = RecordingHost()
|
|
repo = GitRepo(host, tmp_path) # type: ignore[arg-type]
|
|
token = "tok-" + "9f3a" * 6
|
|
repo.push(
|
|
"https://gitea.test/o/r.git", "m1", credential=HTTPSToken("pusher", token)
|
|
)
|
|
[call] = host.calls
|
|
assert token not in call["command"] # never on a command line
|
|
assert "pusher" not in call["command"]
|
|
assert call["secrets"] == {"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": token}
|
|
# Git asks a helper that reads the environment, and any helper configured
|
|
# on the VM is cleared first.
|
|
assert "credential.helper= " in call["command"]
|
|
assert "$NISSE_PUSH_TOKEN" in call["command"]
|
|
assert "GIT_TERMINAL_PROMPT=0" in call["command"]
|
|
|
|
|
|
def test_an_https_token_answers_git_through_the_helper(
|
|
ssh_host: SSHHost, tmp_path: Path
|
|
) -> None:
|
|
# The helper as git runs it, on the build host: asked to "get", it
|
|
# answers from the environment; asked anything else, nothing.
|
|
from nisse_dev.git import _HELPER
|
|
|
|
body = _HELPER.removeprefix("!")
|
|
asked = ssh_host.execute(
|
|
f"{body} get",
|
|
secrets={"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": "tok-1234567"},
|
|
)
|
|
# Both travel as secrets, so both are masked in what comes back.
|
|
assert asked.stdout == "username=[redacted]\npassword=[redacted]\n"
|
|
stored = ssh_host.execute(f"{body} store", secrets={"NISSE_PUSH_TOKEN": "x"})
|
|
assert stored.stdout == ""
|
|
|
|
|
|
# --- build artifacts are never the work (#80, D4) ----------------------------
|
|
|
|
|
|
def test_artifacts_are_left_out_even_when_the_project_tracks_them(
|
|
repo: GitRepo,
|
|
) -> None:
|
|
# The sandbox's main tracks .pyc files (an earlier run published them).
|
|
cache = repo.root / "pkg" / "__pycache__"
|
|
cache.mkdir(parents=True)
|
|
(repo.root / "pkg" / "m.py").write_text("x = 1\n")
|
|
(cache / "m.cpython-312.pyc").write_bytes(b"old")
|
|
repo._git("add", "-A")
|
|
repo._git("commit", "-qm", "a project that tracks its caches")
|
|
|
|
# A test run: the tracked .pyc changes, new caches appear, and the work.
|
|
(cache / "m.cpython-312.pyc").write_bytes(b"new")
|
|
(cache / "m.cpython-310.pyc").write_bytes(b"x")
|
|
(repo.root / ".pytest_cache").mkdir()
|
|
(repo.root / ".pytest_cache" / "v").write_text("x")
|
|
(repo.root / "pkg" / "m.py").write_text("x = 2\n")
|
|
|
|
assert repo.commit_all("the work") is not None
|
|
committed = repo._git("show", "--name-only", "--format=", "HEAD").split()
|
|
assert committed == ["pkg/m.py"]
|
|
assert repo.left_out() == [
|
|
".pytest_cache/v",
|
|
"pkg/__pycache__/m.cpython-310.pyc",
|
|
"pkg/__pycache__/m.cpython-312.pyc",
|
|
]
|
|
|
|
|
|
def test_only_artifacts_changed_means_nothing_to_commit(repo: GitRepo) -> None:
|
|
(repo.root / "__pycache__").mkdir()
|
|
(repo.root / "__pycache__" / "a.pyc").write_bytes(b"x")
|
|
assert repo.commit_all("nothing real") is None
|
|
|
|
|
|
def test_excludes_hide_new_artifacts_and_install_once(repo: GitRepo) -> None:
|
|
repo.install_excludes()
|
|
repo.install_excludes()
|
|
exclude = (repo.root / ".git" / "info" / "exclude").read_text()
|
|
assert exclude.count("__pycache__/") == 1
|
|
(repo.root / "node_modules").mkdir()
|
|
(repo.root / "node_modules" / "x.js").write_text("x")
|
|
(repo.root / "real.py").write_text("x")
|
|
# Even the model's own `git add -A` now skips them.
|
|
assert repo._git("status", "--porcelain", "--untracked-files=all") == "?? real.py"
|