@@ -996,3 +996,208 @@ def test_finish_body_file_dash_empty_stdin_rejected(configured_git_app: Path):
996996 )
997997 assert result .exit_code == 1
998998 assert "empty" in result .output .lower ()
999+
1000+
1001+ def test_finish_shared_git_root_creates_one_pr_records_on_all (configured_git_app : Path ):
1002+ """Two repos sharing git_root: one gh pr create call, both get the URL."""
1003+ from mship .cli import container as cli_container
1004+
1005+ # Extend the workspace with a shared-git_root pair.
1006+ cfg_path = configured_git_app / "mothership.yaml"
1007+ cfg_path .write_text (cfg_path .read_text () + """
1008+ infra:
1009+ path: .
1010+ git_root: shared
1011+ type: service
1012+ """ )
1013+
1014+ runner .invoke (app , ["spawn" , "group prs" , "--repos" , "shared,infra" , "--skip-setup" ])
1015+
1016+ create_pr_call_count = 0
1017+
1018+ def mock_run (cmd , cwd , env = None ):
1019+ nonlocal create_pr_call_count
1020+ if "gh auth status" in cmd :
1021+ return ShellResult (returncode = 0 , stdout = "Logged in" , stderr = "" )
1022+ if "git push" in cmd :
1023+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1024+ if "rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd :
1025+ # Pretend upstream is already set after push.
1026+ return ShellResult (returncode = 0 , stdout = "origin/feat/group-prs" , stderr = "" )
1027+ if "gh pr list --head" in cmd :
1028+ # No existing PR on first call.
1029+ return ShellResult (returncode = 0 , stdout = "\n " , stderr = "" )
1030+ if "gh pr create" in cmd :
1031+ create_pr_call_count += 1
1032+ return ShellResult (returncode = 0 , stdout = "https://github.com/org/shared/pull/42\n " , stderr = "" )
1033+ if "gh pr view" in cmd :
1034+ return ShellResult (returncode = 0 , stdout = "body text" , stderr = "" )
1035+ if "gh pr edit" in cmd :
1036+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1037+ if "git log --format=%s" in cmd :
1038+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1039+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1040+
1041+ mock_shell = MagicMock (spec = ShellRunner )
1042+ mock_shell .run .side_effect = mock_run
1043+ mock_shell .run_task .return_value = ShellResult (returncode = 0 , stdout = "ok" , stderr = "" )
1044+ cli_container .shell .override (mock_shell )
1045+
1046+ result = runner .invoke (app , ["finish" , "--task" , "group-prs" ])
1047+ assert result .exit_code == 0 , result .output
1048+ assert create_pr_call_count == 1 , f"Expected 1 gh pr create call, got { create_pr_call_count } "
1049+
1050+ from mship .core .state import StateManager
1051+ mgr = StateManager (configured_git_app / ".mothership" )
1052+ state = mgr .load ()
1053+ pr_urls = state .tasks ["group-prs" ].pr_urls
1054+ assert pr_urls .get ("shared" ) == "https://github.com/org/shared/pull/42"
1055+ assert pr_urls .get ("infra" ) == "https://github.com/org/shared/pull/42"
1056+
1057+ cli_container .shell .reset_override ()
1058+
1059+
1060+ def test_finish_harvests_existing_pr_instead_of_creating (configured_git_app : Path ):
1061+ """If a PR for the branch already exists (manual or prior mship run),
1062+ finish harvests it via `gh pr list --head` without calling `gh pr create`."""
1063+ from mship .cli import container as cli_container
1064+
1065+ runner .invoke (app , ["spawn" , "reuse pr" , "--repos" , "shared" , "--skip-setup" ])
1066+
1067+ create_pr_called = False
1068+
1069+ def mock_run (cmd , cwd , env = None ):
1070+ nonlocal create_pr_called
1071+ if "gh auth status" in cmd :
1072+ return ShellResult (returncode = 0 , stdout = "Logged in" , stderr = "" )
1073+ if "git push" in cmd :
1074+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1075+ if "rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd :
1076+ return ShellResult (returncode = 0 , stdout = "origin/feat/reuse-pr" , stderr = "" )
1077+ if "gh pr list --head" in cmd :
1078+ return ShellResult (returncode = 0 , stdout = "https://github.com/org/shared/pull/88\n " , stderr = "" )
1079+ if "gh pr create" in cmd :
1080+ create_pr_called = True
1081+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1082+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1083+
1084+ mock_shell = MagicMock (spec = ShellRunner )
1085+ mock_shell .run .side_effect = mock_run
1086+ mock_shell .run_task .return_value = ShellResult (returncode = 0 , stdout = "ok" , stderr = "" )
1087+ cli_container .shell .override (mock_shell )
1088+
1089+ result = runner .invoke (app , ["finish" , "--task" , "reuse-pr" ])
1090+ assert result .exit_code == 0 , result .output
1091+ assert create_pr_called is False , "gh pr create should not be called when PR already exists"
1092+
1093+ from mship .core .state import StateManager
1094+ mgr = StateManager (configured_git_app / ".mothership" )
1095+ state = mgr .load ()
1096+ assert state .tasks ["reuse-pr" ].pr_urls .get ("shared" ) == "https://github.com/org/shared/pull/88"
1097+
1098+ cli_container .shell .reset_override ()
1099+
1100+
1101+ def test_finish_harvests_on_create_pr_duplicate_stderr (configured_git_app : Path ):
1102+ """When `gh pr list` returns empty but `gh pr create` then errors with
1103+ 'already exists' (race), finish harvests via a second list call."""
1104+ from mship .cli import container as cli_container
1105+
1106+ runner .invoke (app , ["spawn" , "race pr" , "--repos" , "shared" , "--skip-setup" ])
1107+
1108+ list_call_count = 0
1109+
1110+ def mock_run (cmd , cwd , env = None ):
1111+ nonlocal list_call_count
1112+ if "gh auth status" in cmd :
1113+ return ShellResult (returncode = 0 , stdout = "Logged in" , stderr = "" )
1114+ if "git push" in cmd :
1115+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1116+ if "rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd :
1117+ return ShellResult (returncode = 0 , stdout = "origin/feat/race-pr" , stderr = "" )
1118+ if "gh pr list --head" in cmd :
1119+ list_call_count += 1
1120+ if list_call_count == 1 :
1121+ # First call (pre-create check): no PR yet.
1122+ return ShellResult (returncode = 0 , stdout = "\n " , stderr = "" )
1123+ else :
1124+ # Second call (fallback after create failed): PR exists now.
1125+ return ShellResult (
1126+ returncode = 0 ,
1127+ stdout = "https://github.com/org/shared/pull/99\n " ,
1128+ stderr = "" ,
1129+ )
1130+ if "gh pr create" in cmd :
1131+ return ShellResult (
1132+ returncode = 1 , stdout = "" ,
1133+ stderr = "a pull request for branch \" feat/race-pr\" into branch \" main\" already exists" ,
1134+ )
1135+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1136+
1137+ mock_shell = MagicMock (spec = ShellRunner )
1138+ mock_shell .run .side_effect = mock_run
1139+ mock_shell .run_task .return_value = ShellResult (returncode = 0 , stdout = "ok" , stderr = "" )
1140+ cli_container .shell .override (mock_shell )
1141+
1142+ result = runner .invoke (app , ["finish" , "--task" , "race-pr" ])
1143+ assert result .exit_code == 0 , result .output
1144+ assert list_call_count == 2 , "Expected pre-check + fallback list calls"
1145+
1146+ from mship .core .state import StateManager
1147+ mgr = StateManager (configured_git_app / ".mothership" )
1148+ state = mgr .load ()
1149+ assert state .tasks ["race-pr" ].pr_urls .get ("shared" ) == "https://github.com/org/shared/pull/99"
1150+
1151+ cli_container .shell .reset_override ()
1152+
1153+
1154+ def test_finish_calls_ensure_upstream_after_push (configured_git_app : Path ):
1155+ """ensure_upstream fires after push; if @{u} fails, set-upstream-to runs."""
1156+ from mship .cli import container as cli_container
1157+
1158+ runner .invoke (app , ["spawn" , "upstream check" , "--repos" , "shared" , "--skip-setup" ])
1159+
1160+ set_upstream_called = False
1161+ ensure_upstream_probe_count = 0
1162+
1163+ def mock_run (cmd , cwd , env = None ):
1164+ nonlocal set_upstream_called , ensure_upstream_probe_count
1165+ if "gh auth status" in cmd :
1166+ return ShellResult (returncode = 0 , stdout = "Logged in" , stderr = "" )
1167+ if "symbolic-ref" in cmd and "HEAD" in cmd :
1168+ return ShellResult (returncode = 0 , stdout = "main\n " , stderr = "" )
1169+ if "fetch" in cmd :
1170+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1171+ if "rev-list --count" in cmd :
1172+ return ShellResult (returncode = 0 , stdout = "0\n " , stderr = "" )
1173+ if "status --porcelain" in cmd :
1174+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1175+ if "git push" in cmd :
1176+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1177+ if "rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd :
1178+ ensure_upstream_probe_count += 1
1179+ # First call: audit checks upstream (before push) - pass
1180+ # Subsequent calls: ensure_upstream checks after push - fail to trigger fallback
1181+ if ensure_upstream_probe_count == 1 :
1182+ return ShellResult (returncode = 0 , stdout = "origin/main\n " , stderr = "" )
1183+ else :
1184+ return ShellResult (returncode = 1 , stdout = "" , stderr = "fatal: no upstream" )
1185+ if "--set-upstream-to=origin/" in cmd :
1186+ set_upstream_called = True
1187+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1188+ if "gh pr list --head" in cmd :
1189+ return ShellResult (returncode = 0 , stdout = "\n " , stderr = "" )
1190+ if "gh pr create" in cmd :
1191+ return ShellResult (returncode = 0 , stdout = "https://github.com/org/shared/pull/1\n " , stderr = "" )
1192+ return ShellResult (returncode = 0 , stdout = "" , stderr = "" )
1193+
1194+ mock_shell = MagicMock (spec = ShellRunner )
1195+ mock_shell .run .side_effect = mock_run
1196+ mock_shell .run_task .return_value = ShellResult (returncode = 0 , stdout = "ok" , stderr = "" )
1197+ cli_container .shell .override (mock_shell )
1198+
1199+ result = runner .invoke (app , ["finish" , "--task" , "upstream-check" ])
1200+ assert result .exit_code == 0 , result .output
1201+ assert set_upstream_called , "ensure_upstream should have run set-upstream-to"
1202+
1203+ cli_container .shell .reset_override ()
0 commit comments