@@ -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