Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions src/hive/cli/cmd_swarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ def _register_agents(count: int, prefix: str | None) -> list[dict]:
return agents


def _ensure_gitignore_entry(gitignore_path: Path, entry: str):
if gitignore_path.exists():
content = gitignore_path.read_text()
if entry in content.splitlines():
return
if not content.endswith("\n"):
content += "\n"
else:
content = ""
gitignore_path.write_text(content + entry + "\n")


def _clone_one(task_id: str, agent: dict, base_dir: Path) -> dict:
token = agent["token"]
agent_id = agent["id"]
Expand All @@ -84,7 +96,7 @@ def _clone_one(task_id: str, agent: dict, base_dir: Path) -> dict:
key_dir = Path.home() / ".hive" / "keys"
key_dir.mkdir(parents=True, exist_ok=True)
fork_name = ssh_url.split("/")[-1].replace(".git", "")
key_path = key_dir / fork_name
key_path = key_dir / f"{fork_name}--{agent_id}"
if private_key:
key_path.write_text(private_key)
key_path.chmod(0o600)
Expand All @@ -103,33 +115,46 @@ def _clone_one(task_id: str, agent: dict, base_dir: Path) -> dict:
subprocess.run(["git", "-C", str(work_dir), "config", "core.sshCommand", ssh_cmd],
capture_output=True, text=True)

# Write .hive metadata
hive_dir = work_dir / ".hive"
hive_dir.mkdir(exist_ok=True)
(hive_dir / "task").write_text(task_id)
(hive_dir / "agent").write_text(agent_id)

if mode == "branch":
# Branch mode: checkout initial branch, no upstream remote
# Branch mode: checkout initial branch
default_branch = resp.get("default_branch", "")
if default_branch:
subprocess.run(["git", "-C", str(work_dir), "checkout", default_branch],
capture_output=True, text=True)
checkout = subprocess.run(
["git", "-C", str(work_dir), "checkout", default_branch],
capture_output=True, text=True)
if checkout.returncode != 0:
subprocess.run(
["git", "-C", str(work_dir), "checkout", "-b", default_branch],
capture_output=True, text=True)

# Write .hive metadata AFTER checkout so it can't be overwritten
hive_dir = work_dir / ".hive"
hive_dir.mkdir(exist_ok=True)
(hive_dir / "task").write_text(task_id)
(hive_dir / "agent").write_text(agent_id)
(hive_dir / "fork.json").write_text(json.dumps({
"mode": "branch",
"branch_prefix": resp.get("branch_prefix", ""),
"key_path": str(key_path),
}, indent=2))
_ensure_gitignore_entry(work_dir / ".gitignore", ".hive/")
else:
# Fork mode: add upstream remote
upstream_url = resp.get("upstream_url", "")
if upstream_url:
subprocess.run(["git", "-C", str(work_dir), "remote", "add", "upstream", upstream_url],
capture_output=True, text=True)

# Write .hive metadata
hive_dir = work_dir / ".hive"
hive_dir.mkdir(exist_ok=True)
(hive_dir / "task").write_text(task_id)
(hive_dir / "agent").write_text(agent_id)
(hive_dir / "fork.json").write_text(json.dumps({
"mode": "fork",
"fork_url": resp.get("fork_url", ""), "key_path": str(key_path),
}, indent=2))
_ensure_gitignore_entry(work_dir / ".gitignore", ".hive/")

return {"agent_id": agent_id, "work_dir": str(work_dir), "key_path": str(key_path)}

Expand Down
206 changes: 206 additions & 0 deletions tests/cli/test_cmd_swarm.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import json
import subprocess
from pathlib import Path

import click.testing

from hive.cli.hive import hive
from hive.cli.cmd_swarm import _clone_one


class TestSwarmHelp:
Expand Down Expand Up @@ -40,3 +45,204 @@ def test_unknown_task(self, tmp_path, monkeypatch):
runner = click.testing.CliRunner()
result = runner.invoke(hive, ["swarm", "down", "no-such-task"])
assert result.exit_code != 0


def _make_branch_response(agent_id: str):
return {
"mode": "branch",
"ssh_url": "git@github.com:owner/task--foo.git",
"private_key": f"PRIVATE_KEY_FOR_{agent_id}",
"default_branch": f"hive/{agent_id}/initial",
"branch_prefix": f"hive/{agent_id}/",
}


def _mock_subprocess(monkeypatch, *, checkout_fails=False):
calls = []

def fake_run(cmd, **kwargs):
calls.append(cmd)
if cmd[0] == "git" and cmd[1] == "clone":
Path(cmd[3]).mkdir(parents=True, exist_ok=True)
(Path(cmd[3]) / ".git").mkdir(exist_ok=True)
return subprocess.CompletedProcess(cmd, 0, "", "")
if cmd[0] == "git" and "checkout" in cmd and checkout_fails and "-b" not in cmd:
return subprocess.CompletedProcess(cmd, 1, "", "error: pathspec did not match")
return subprocess.CompletedProcess(cmd, 0, "", "")

monkeypatch.setattr("subprocess.run", fake_run)
return calls


class TestCloneOneBugs:

def test_bug1_deploy_key_collision(self, tmp_path, monkeypatch):
"""Two agents with same ssh_url must get separate key files."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))

responses = iter([_make_branch_response("agent-1"),
_make_branch_response("agent-2")])
monkeypatch.setattr("hive.cli.cmd_swarm._api",
lambda *a, **kw: next(responses))
_mock_subprocess(monkeypatch)

base = tmp_path / "work"
base.mkdir()
_clone_one("test-task", {"id": "agent-1", "token": "t1"}, base)
_clone_one("test-task", {"id": "agent-2", "token": "t2"}, base)

key_dir = fake_home / ".hive" / "keys"
key_files = sorted(p.name for p in key_dir.iterdir())
assert len(key_files) == 2, f"Expected 2 key files, got {key_files}"
keys = {p.name: p.read_text() for p in key_dir.iterdir()}
assert len(set(keys.values())) == 2, "Keys have identical content"

def test_bug2_metadata_survives_checkout(self, tmp_path, monkeypatch):
""".hive/agent must contain correct agent_id after checkout."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setattr("hive.cli.cmd_swarm._api",
lambda *a, **kw: _make_branch_response("agent-1"))

def fake_run(cmd, **kwargs):
if cmd[0] == "git" and cmd[1] == "clone":
Path(cmd[3]).mkdir(parents=True, exist_ok=True)
(Path(cmd[3]) / ".git").mkdir(exist_ok=True)
return subprocess.CompletedProcess(cmd, 0, "", "")
if cmd[0] == "git" and "checkout" in cmd and "-b" not in cmd:
work_dir = Path(cmd[cmd.index("-C") + 1])
hive_dir = work_dir / ".hive"
if hive_dir.exists():
(hive_dir / "agent").write_text("stale-agent")
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.CompletedProcess(cmd, 0, "", "")

monkeypatch.setattr("subprocess.run", fake_run)

base = tmp_path / "work"
base.mkdir()
result = _clone_one("test-task", {"id": "agent-1", "token": "t1"}, base)

agent_file = Path(result["work_dir"]) / ".hive" / "agent"
assert agent_file.read_text() == "agent-1"

def test_bug2_gitignore_added(self, tmp_path, monkeypatch):
""".hive/ should be in workspace .gitignore."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setattr("hive.cli.cmd_swarm._api",
lambda *a, **kw: _make_branch_response("agent-1"))
_mock_subprocess(monkeypatch)

base = tmp_path / "work"
base.mkdir()
result = _clone_one("test-task", {"id": "agent-1", "token": "t1"}, base)

gitignore = Path(result["work_dir"]) / ".gitignore"
assert gitignore.exists(), ".gitignore not created"
assert ".hive/" in gitignore.read_text()

def test_bug3_checkout_fallback_on_missing_branch(self, tmp_path, monkeypatch):
"""If branch doesn't exist, fall back to checkout -b."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setattr("hive.cli.cmd_swarm._api",
lambda *a, **kw: _make_branch_response("agent-1"))
calls = _mock_subprocess(monkeypatch, checkout_fails=True)

base = tmp_path / "work"
base.mkdir()
_clone_one("test-task", {"id": "agent-1", "token": "t1"}, base)

checkout_cmds = [c for c in calls if c[0] == "git" and "checkout" in c]
assert len(checkout_cmds) >= 2, f"Expected checkout fallback, got {checkout_cmds}"
assert "-b" in checkout_cmds[1]


def _init_bare_repo(path):
"""Create a bare git repo with one commit (simulates a GitHub remote)."""
subprocess.run(["git", "init", "--bare", str(path)], capture_output=True, check=True)
# Clone it, add a commit, push back
tmp_clone = path.parent / "tmp-clone"
subprocess.run(["git", "clone", str(path), str(tmp_clone)], capture_output=True, check=True)
(tmp_clone / "README.md").write_text("hello")
subprocess.run(["git", "-C", str(tmp_clone), "add", "."], capture_output=True, check=True)
subprocess.run(["git", "-C", str(tmp_clone), "commit", "-m", "init"],
capture_output=True, check=True,
env={**__import__("os").environ,
"GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t"})
subprocess.run(["git", "-C", str(tmp_clone), "push"], capture_output=True, check=True)
import shutil
shutil.rmtree(tmp_clone)


class TestCloneOneIntegration:
"""End-to-end test using real git repos (no subprocess mocking)."""

def test_two_agents_private_task(self, tmp_path, monkeypatch):
"""Simulate two agents cloning the same private-task repo in branch mode."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))

# Create a bare repo to act as the "remote"
remote = tmp_path / "remote.git"
_init_bare_repo(remote)

call_count = 0
agents = ["agent-alpha", "agent-beta"]

def fake_api(*args, **kwargs):
nonlocal call_count
aid = agents[call_count]
call_count += 1
return {
"mode": "branch",
"ssh_url": str(remote),
"private_key": f"KEY_{aid}",
"default_branch": f"hive/{aid}/initial",
"branch_prefix": f"hive/{aid}/",
}

monkeypatch.setattr("hive.cli.cmd_swarm._api", fake_api)

base = tmp_path / "workdirs"
base.mkdir()
r1 = _clone_one("my-task", {"id": "agent-alpha", "token": "t1"}, base)
r2 = _clone_one("my-task", {"id": "agent-beta", "token": "t2"}, base)

# Bug 1: each agent has its own deploy key
key_dir = fake_home / ".hive" / "keys"
key_files = sorted(p.name for p in key_dir.iterdir())
assert len(key_files) == 2
assert "agent-alpha" in key_files[0]
assert "agent-beta" in key_files[1]

# Bug 2: .hive/agent is correct in each workspace
assert (Path(r1["work_dir"]) / ".hive" / "agent").read_text() == "agent-alpha"
assert (Path(r2["work_dir"]) / ".hive" / "agent").read_text() == "agent-beta"

# Bug 2: .gitignore contains .hive/
assert ".hive/" in (Path(r1["work_dir"]) / ".gitignore").read_text()
assert ".hive/" in (Path(r2["work_dir"]) / ".gitignore").read_text()

# Bug 2: fork.json has correct per-agent branch_prefix
f1 = json.loads((Path(r1["work_dir"]) / ".hive" / "fork.json").read_text())
f2 = json.loads((Path(r2["work_dir"]) / ".hive" / "fork.json").read_text())
assert f1["branch_prefix"] == "hive/agent-alpha/"
assert f2["branch_prefix"] == "hive/agent-beta/"

# Bug 3: each agent is on its own branch (created via -b fallback)
def current_branch(work_dir):
r = subprocess.run(["git", "-C", work_dir, "branch", "--show-current"],
capture_output=True, text=True)
return r.stdout.strip()

assert current_branch(r1["work_dir"]) == "hive/agent-alpha/initial"
assert current_branch(r2["work_dir"]) == "hive/agent-beta/initial"