Skip to content

Commit ddd2e07

Browse files
committed
feat(lpb-config): workspace sync prepares repos (clone, branch-align, ff)
sync previously ran a blind 'git pull' per repo, which failed on pi's pinned-tag detached checkouts and always exited 0. Now: - fetch -> dirty-check -> branch align -> fast-forward per repo - clones missing repos (extension clones + real workspace repos) - safe symlink handling: only replaces real clones of the same remote - detached HEADs reported/readable ('detached @ <tag>') in status/ensure - exit 1 when the workspace is not fully prepared - env overrides: LPB_WORKSPACE_ROOT, LPB_AGENT_GIT, LPB_STACK_REMOTE_BASE Adds 6 workspace-sync tests (test_localpibox.py): 69/69 passing.
1 parent df11bbb commit ddd2e07

2 files changed

Lines changed: 333 additions & 57 deletions

File tree

scripts/test_localpibox.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,172 @@ def test_lpb_config_merge_uptodate(tmpdir):
535535
assert lc.cmd_merge(tmpdir / "nope", str(remote), "main", _quiet_console()) == 1
536536

537537

538+
# ─── lpb_config workspace sync ─────────────────────────────────────────────
539+
540+
def _bare_remote(tmpdir, name, branch="dev"):
541+
"""Bare remote with one commit + tag 0.0.1 on *branch*; returns (remote, src_work)."""
542+
remote = tmpdir / "remotes" / f"{name}.git"
543+
remote.parent.mkdir(parents=True, exist_ok=True)
544+
subprocess.run(["git", "init", "-q", "--bare", str(remote)], check=True)
545+
work = tmpdir / "src" / name
546+
work.parent.mkdir(parents=True, exist_ok=True)
547+
subprocess.run(["git", "clone", "-q", str(remote), str(work)], check=True)
548+
subprocess.run(["git", "-C", str(work), "config", "user.email", "t@t"], check=True)
549+
subprocess.run(["git", "-C", str(work), "config", "user.name", "t"], check=True)
550+
subprocess.run(["git", "-C", str(work), "checkout", "-q", "-b", branch], check=True)
551+
(work / "f").write_text("one")
552+
subprocess.run(["git", "-C", str(work), "add", "."], check=True)
553+
subprocess.run(["git", "-C", str(work), "commit", "-qm", "one"], check=True)
554+
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
555+
# Point the remote HEAD at the pushed branch so plain clones check it out
556+
subprocess.run(["git", "-C", str(remote), "symbolic-ref", "HEAD", f"refs/heads/{branch}"], check=True)
557+
subprocess.run(["git", "-C", str(work), "tag", "0.0.1"], check=True)
558+
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", "0.0.1"], check=True)
559+
return remote, work
560+
561+
562+
def _push_branch(work, branch, content="two"):
563+
"""Add a commit on *branch* of src work tree and push it."""
564+
subprocess.run(["git", "-C", str(work), "checkout", "-q", branch], check=True)
565+
(work / "f").write_text(content)
566+
subprocess.run(["git", "-C", str(work), "commit", "-qam", "next"], check=True)
567+
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
568+
569+
570+
def _workspace_patch(tmpdir, repos, config_branch="dev", config_repo=True):
571+
"""Point lpb-config workspace constants at *tmpdir* (context manager).
572+
573+
Installs a clean config repo on *config_branch* at the agent dir unless
574+
config_repo is False. Mirrors the real layout: the agent dir is a git
575+
repo whose worktree contains the extension clones under git/ (ignored).
576+
"""
577+
agent = tmpdir / "agent"
578+
agent.mkdir(parents=True, exist_ok=True)
579+
if config_repo:
580+
cfg_remote, _ = _bare_remote(tmpdir, "config", config_branch)
581+
subprocess.run(["git", "-C", str(agent), "init", "-q"], check=True)
582+
subprocess.run(["git", "-C", str(agent), "config", "user.email", "t@t"], check=True)
583+
subprocess.run(["git", "-C", str(agent), "config", "user.name", "t"], check=True)
584+
subprocess.run(["git", "-C", str(agent), "remote", "add", "origin", str(cfg_remote)], check=True)
585+
subprocess.run(["git", "-C", str(agent), "fetch", "-q", "origin", config_branch], check=True)
586+
subprocess.run(["git", "-C", str(agent), "checkout", "-q", "-b", config_branch, "FETCH_HEAD"], check=True)
587+
(agent / ".gitignore").write_text("git/\n")
588+
subprocess.run(["git", "-C", str(agent), "add", ".gitignore"], check=True)
589+
subprocess.run(["git", "-C", str(agent), "commit", "-qm", "gitignore"], check=True)
590+
subprocess.run(["git", "-C", str(agent), "push", "-q", "origin", config_branch], check=True)
591+
return mock.patch.multiple(
592+
lc,
593+
WORKSPACE_REPOS=repos,
594+
WORKSPACE_ROOT=tmpdir / "workspace",
595+
AGENT_GIT=agent / "git" / "github.com" / "lpb-stack",
596+
DEFAULT_AGENT_DIR=str(agent),
597+
)
598+
599+
600+
def _branch(p):
601+
return subprocess.run(
602+
["git", "-C", str(p), "branch", "--show-current"],
603+
check=True, capture_output=True, text=True,
604+
).stdout.strip()
605+
606+
607+
def test_lpb_config_sync_detached_head(tmpdir):
608+
"""Clone detached at a tag (pi's pinned-tag checkout) → ends on branch @ tip."""
609+
remote, src = _bare_remote(tmpdir, "repo-a", "dev")
610+
ag = tmpdir / "agent" / "git" / "github.com" / "lpb-stack"
611+
clone = ag / "repo-a"
612+
subprocess.run(["git", "clone", "-q", str(remote), str(clone)], check=True)
613+
subprocess.run(["git", "-C", str(clone), "checkout", "-q", "0.0.1"], check=True)
614+
_push_branch(src, "dev")
615+
with _workspace_patch(tmpdir, [("repo-a", True, True, "dev", "main")]):
616+
code = lc.cmd_workspace_sync("dev", _quiet_console())
617+
ws = tmpdir / "workspace"
618+
assert code == 0
619+
assert (ws / "repo-a").is_symlink()
620+
assert (ws / "repo-a").resolve() == clone.resolve()
621+
assert _branch(clone) == "dev"
622+
assert (clone / "f").read_text() == "two" # fast-forwarded
623+
624+
625+
def test_lpb_config_sync_clones_missing(tmpdir):
626+
"""Missing extension clone + missing real repo → both cloned and linked."""
627+
repos = [("repo-a", True, True, "dev", "main"), ("repo-b", False, False, "dev", "main")]
628+
_bare_remote(tmpdir, "repo-a", "dev")
629+
_bare_remote(tmpdir, "repo-b", "dev")
630+
with mock.patch.dict(os.environ, {"LPB_STACK_REMOTE_BASE": str(tmpdir / "remotes")}), \
631+
_workspace_patch(tmpdir, repos):
632+
code = lc.cmd_workspace_sync("dev", _quiet_console())
633+
ws = tmpdir / "workspace"
634+
ag = tmpdir / "agent" / "git" / "github.com" / "lpb-stack"
635+
assert code == 0
636+
assert (ag / "repo-a" / ".git").exists()
637+
assert (ws / "repo-a").is_symlink()
638+
assert (ws / "repo-b" / ".git").exists() # real clone in workspace
639+
assert _branch(ws / "repo-b") == "dev"
640+
641+
642+
def test_lpb_config_sync_dirty_skipped(tmpdir):
643+
"""Dirty worktree → left untouched, reported, non-zero exit."""
644+
remote, src = _bare_remote(tmpdir, "repo-a", "dev")
645+
clone = tmpdir / "agent" / "git" / "github.com" / "lpb-stack" / "repo-a"
646+
subprocess.run(["git", "clone", "-q", str(remote), str(clone)], check=True)
647+
(clone / "f").write_text("local edit")
648+
_push_branch(src, "dev")
649+
out, err = io.StringIO(), io.StringIO()
650+
with _workspace_patch(tmpdir, [("repo-a", True, True, "dev", "main")]):
651+
cons = log_mod.Console(color=False, out=out, err=err)
652+
code = lc.cmd_workspace_sync("dev", cons)
653+
assert code == 1
654+
assert _branch(clone) == "dev"
655+
assert (clone / "f").read_text() == "local edit" # untouched
656+
assert "uncommitted changes" in (out.getvalue() + err.getvalue())
657+
658+
659+
def test_lpb_config_sync_wrong_branch(tmpdir):
660+
"""Clean repo on a feature branch → switched to pipeline branch @ tip."""
661+
remote, src = _bare_remote(tmpdir, "repo-a", "dev")
662+
subprocess.run(["git", "-C", str(src), "checkout", "-q", "-b", "feature"], check=True)
663+
subprocess.run(["git", "-C", str(src), "push", "-q", "origin", "feature"], check=True)
664+
clone = tmpdir / "agent" / "git" / "github.com" / "lpb-stack" / "repo-a"
665+
subprocess.run(["git", "clone", "-q", str(remote), str(clone)], check=True)
666+
subprocess.run(["git", "-C", str(clone), "checkout", "-q", "feature"], check=True)
667+
_push_branch(src, "dev")
668+
with _workspace_patch(tmpdir, [("repo-a", True, True, "dev", "main")]):
669+
code = lc.cmd_workspace_sync("dev", _quiet_console())
670+
assert code == 0
671+
assert _branch(clone) == "dev"
672+
assert (clone / "f").read_text() == "two"
673+
674+
675+
def test_lpb_config_sync_missing_config(tmpdir):
676+
"""No config repo at agent dir → warning + non-zero exit."""
677+
remote, src = _bare_remote(tmpdir, "repo-a", "dev")
678+
clone = tmpdir / "agent" / "git" / "github.com" / "lpb-stack" / "repo-a"
679+
subprocess.run(["git", "clone", "-q", str(remote), str(clone)], check=True)
680+
out, err = io.StringIO(), io.StringIO()
681+
with _workspace_patch(tmpdir, [("repo-a", True, True, "dev", "main")], config_repo=False):
682+
cons = log_mod.Console(color=False, out=out, err=err)
683+
code = lc.cmd_workspace_sync("dev", cons)
684+
assert code == 1
685+
assert "config" in (out.getvalue() + err.getvalue())
686+
687+
688+
def test_lpb_config_sync_main_pipeline(tmpdir):
689+
"""main pipeline → main branches selected for repo + config."""
690+
remote, src = _bare_remote(tmpdir, "repo-a", "dev")
691+
subprocess.run(["git", "-C", str(src), "checkout", "-q", "-b", "main"], check=True)
692+
(src / "f").write_text("stable")
693+
subprocess.run(["git", "-C", str(src), "commit", "-qam", "stable"], check=True)
694+
subprocess.run(["git", "-C", str(src), "push", "-q", "origin", "main"], check=True)
695+
clone = tmpdir / "agent" / "git" / "github.com" / "lpb-stack" / "repo-a"
696+
subprocess.run(["git", "clone", "-q", "--branch", "main", str(remote), str(clone)], check=True)
697+
with _workspace_patch(tmpdir, [("repo-a", True, True, "dev", "main")], config_branch="main"):
698+
code = lc.cmd_workspace_sync("main", _quiet_console())
699+
assert code == 0
700+
assert _branch(clone) == "main"
701+
assert (clone / "f").read_text() == "stable"
702+
703+
538704
# ═══════════════════════════════════════════════════════════════════════════
539705
# validate
540706
# ═══════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)