steerdev 1.0.60__tar.gz → 1.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {steerdev-1.0.60 → steerdev-1.1.2}/PKG-INFO +1 -1
  2. {steerdev-1.0.60 → steerdev-1.1.2}/pyproject.toml +1 -1
  3. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workspace/preparation.py +133 -11
  4. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_preparation.py +309 -3
  5. {steerdev-1.0.60 → steerdev-1.1.2}/.github/workflows/pre-commit.yml +0 -0
  6. {steerdev-1.0.60 → steerdev-1.1.2}/.github/workflows/publish.yml +0 -0
  7. {steerdev-1.0.60 → steerdev-1.1.2}/.gitignore +0 -0
  8. {steerdev-1.0.60 → steerdev-1.1.2}/.pre-commit-config.yaml +0 -0
  9. {steerdev-1.0.60 → steerdev-1.1.2}/AGENTS.md +0 -0
  10. {steerdev-1.0.60 → steerdev-1.1.2}/CLAUDE.md +0 -0
  11. {steerdev-1.0.60 → steerdev-1.1.2}/README.md +0 -0
  12. {steerdev-1.0.60 → steerdev-1.1.2}/scripts/pre-commit-version-bump.sh +0 -0
  13. {steerdev-1.0.60 → steerdev-1.1.2}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  14. {steerdev-1.0.60 → steerdev-1.1.2}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  15. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/__init__.py +0 -0
  16. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/agent_loop.py +0 -0
  17. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/__init__.py +0 -0
  18. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/activity.py +0 -0
  19. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/agents.py +0 -0
  20. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/canals.py +0 -0
  21. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/client.py +0 -0
  22. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/commands.py +0 -0
  23. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/configs.py +0 -0
  24. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/context.py +0 -0
  25. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/events.py +0 -0
  26. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/hooks.py +0 -0
  27. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/implementation_plan.py +0 -0
  28. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/merger.py +0 -0
  29. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/messages.py +0 -0
  30. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/prd.py +0 -0
  31. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/reports.py +0 -0
  32. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/runs.py +0 -0
  33. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/sessions.py +0 -0
  34. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/specs.py +0 -0
  35. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/tasks.py +0 -0
  36. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/workflow_runs.py +0 -0
  37. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/api/workflows.py +0 -0
  38. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/cli.py +0 -0
  39. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/config/__init__.py +0 -0
  40. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/config/models.py +0 -0
  41. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/config/platform.py +0 -0
  42. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/config/settings.py +0 -0
  43. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/evidence.py +0 -0
  44. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/evidence_assets.py +0 -0
  45. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/executor/__init__.py +0 -0
  46. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/executor/base.py +0 -0
  47. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/executor/claude.py +0 -0
  48. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/executor/stream.py +0 -0
  49. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/handlers/__init__.py +0 -0
  50. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/handlers/prd.py +0 -0
  51. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/integration.py +0 -0
  52. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/prompt/__init__.py +0 -0
  53. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/prompt/builder.py +0 -0
  54. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/prompt/templates.py +0 -0
  55. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  56. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/py.typed +0 -0
  57. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/retry.py +0 -0
  58. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/runner.py +0 -0
  59. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/__init__.py +0 -0
  60. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/claude_setup.py +0 -0
  61. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/repo_setup.py +0 -0
  62. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  63. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  64. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/settings.json +0 -0
  65. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  66. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  67. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  68. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  69. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  70. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  71. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  72. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  73. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  74. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  75. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  76. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  77. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/update_check.py +0 -0
  78. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/version.py +0 -0
  79. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workflow/__init__.py +0 -0
  80. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workflow/context.py +0 -0
  81. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workflow/executor.py +0 -0
  82. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workflow/memory.py +0 -0
  83. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workspace/__init__.py +0 -0
  84. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workspace/project_manager.py +0 -0
  85. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  86. {steerdev-1.0.60 → steerdev-1.1.2}/src/steerdev_agent/worktree.py +0 -0
  87. {steerdev-1.0.60 → steerdev-1.1.2}/tests/__init__.py +0 -0
  88. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_agent_loop.py +0 -0
  89. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_agent_loop_extended.py +0 -0
  90. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_agents_api.py +0 -0
  91. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_api_client.py +0 -0
  92. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_claude_executor.py +0 -0
  93. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_claude_setup.py +0 -0
  94. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_client_methods.py +0 -0
  95. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_commands_api.py +0 -0
  96. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_config.py +0 -0
  97. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_config_extended.py +0 -0
  98. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_conflict_mitigation.py +0 -0
  99. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_context_search.py +0 -0
  100. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_executor.py +0 -0
  101. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_platform_config.py +0 -0
  102. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_prompt.py +0 -0
  103. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_reports_client.py +0 -0
  104. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_retry.py +0 -0
  105. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_runner_merge_modes.py +0 -0
  106. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_runner_worktrees.py +0 -0
  107. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_stream_parser.py +0 -0
  108. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_tasks.py +0 -0
  109. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_version.py +0 -0
  110. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_workflow_context.py +0 -0
  111. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_workflow_memory.py +0 -0
  112. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_workflow_prompt_template.py +0 -0
  113. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_workflow_runs_api.py +0 -0
  114. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_workspace.py +0 -0
  115. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_workspace_extended.py +0 -0
  116. {steerdev-1.0.60 → steerdev-1.1.2}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.0.60
3
+ Version: 1.1.2
4
4
  Summary: Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting
5
5
  Project-URL: Homepage, https://github.com/pentoai/steerdev-agent
6
6
  Project-URL: Repository, https://github.com/pentoai/steerdev-agent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerdev"
3
- version = "1.0.60"
3
+ version = "1.1.2"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -35,6 +35,8 @@ class PreparationResult:
35
35
  is_worktree: bool
36
36
  error: str | None = None
37
37
  warnings: list[str] = field(default_factory=list)
38
+ repo_directories: list[Path] = field(default_factory=list)
39
+ """For multi-repo workspaces, the list of prepared git repositories."""
38
40
 
39
41
 
40
42
  async def _run_git(
@@ -145,13 +147,23 @@ class WorkspacePreparation:
145
147
  default_branch: str,
146
148
  wave_context: WaveContext | None,
147
149
  ) -> PreparationResult:
148
- """Prepare workspace in the main repository directory."""
150
+ """Prepare workspace in the main repository directory.
151
+
152
+ If the working directory is not itself a git repository, discovers
153
+ git repos in immediate subdirectories (multi-repo layout) and
154
+ prepares each one.
155
+ """
149
156
  cwd = self.working_directory
150
- warnings: list[str] = []
151
157
 
152
- # 1. Validate git repo
158
+ # 1. Check if working directory is a git repo
153
159
  rc, _, _ = await _run_git("rev-parse", "--is-inside-work-tree", cwd=cwd, check=False)
154
- if rc != 0:
160
+ if rc == 0:
161
+ # Single-repo layout: prepare in place
162
+ return await self._prepare_single_repo(cwd, target_branch, default_branch, wave_context)
163
+
164
+ # Not a git repo — check for multi-repo layout (subdirectories that are git repos)
165
+ repos = await self._discover_git_repos(cwd)
166
+ if not repos:
155
167
  return PreparationResult(
156
168
  success=False,
157
169
  branch_name=target_branch,
@@ -161,10 +173,120 @@ class WorkspacePreparation:
161
173
  error=f"Not a git repository: {cwd}",
162
174
  )
163
175
 
164
- # 2. Check current branch
176
+ # Multi-repo layout: prepare each discovered repo
177
+ return await self._prepare_multi_repo(repos, target_branch, default_branch, wave_context)
178
+
179
+ async def _discover_git_repos(self, directory: Path) -> list[Path]:
180
+ """Discover git repositories in immediate subdirectories.
181
+
182
+ Args:
183
+ directory: Parent directory to scan.
184
+
185
+ Returns:
186
+ Sorted list of paths that are git repositories.
187
+ """
188
+ repos: list[Path] = []
189
+ try:
190
+ for entry in sorted(directory.iterdir()):
191
+ if entry.is_dir() and not entry.name.startswith("."):
192
+ rc, _, _ = await _run_git(
193
+ "rev-parse", "--is-inside-work-tree", cwd=entry, check=False
194
+ )
195
+ if rc == 0:
196
+ repos.append(entry)
197
+ except OSError as exc:
198
+ logger.warning(f"Failed to scan directory {directory}: {exc}")
199
+ return repos
200
+
201
+ async def _prepare_multi_repo(
202
+ self,
203
+ repos: list[Path],
204
+ target_branch: str,
205
+ default_branch: str,
206
+ wave_context: WaveContext | None,
207
+ ) -> PreparationResult:
208
+ """Prepare multiple git repositories in a workspace directory.
209
+
210
+ Applies the same branch preparation to each discovered repo.
211
+ Returns success only if all repos are prepared successfully.
212
+
213
+ Args:
214
+ repos: List of git repository paths to prepare.
215
+ target_branch: Target branch name for the task.
216
+ default_branch: Default branch to sync from.
217
+ wave_context: Optional wave context.
218
+
219
+ Returns:
220
+ PreparationResult with the parent directory as working_directory.
221
+ """
222
+ parent_dir = self.working_directory
223
+ all_warnings: list[str] = []
224
+ prepared_repos: list[Path] = []
225
+
226
+ logger.info(
227
+ f"Multi-repo workspace detected at {parent_dir}: preparing {len(repos)} repositories"
228
+ )
229
+
230
+ for repo_path in repos:
231
+ repo_name = repo_path.name
232
+ logger.info(f"Preparing repository: {repo_name}")
233
+
234
+ result = await self._prepare_single_repo(
235
+ repo_path, target_branch, default_branch, wave_context
236
+ )
237
+
238
+ if not result.success:
239
+ return PreparationResult(
240
+ success=False,
241
+ branch_name=target_branch,
242
+ working_directory=parent_dir,
243
+ default_branch=default_branch,
244
+ is_worktree=False,
245
+ error=f"Failed to prepare repo {repo_name}: {result.error}",
246
+ repo_directories=prepared_repos,
247
+ )
248
+
249
+ prepared_repos.append(repo_path)
250
+ # Prefix per-repo warnings with the repo name for clarity
251
+ for warning in result.warnings:
252
+ all_warnings.append(f"[{repo_name}] {warning}")
253
+
254
+ logger.info(f"Multi-repo workspace ready: {len(prepared_repos)} repositories prepared")
255
+
256
+ return PreparationResult(
257
+ success=True,
258
+ branch_name=target_branch,
259
+ working_directory=parent_dir,
260
+ default_branch=default_branch,
261
+ is_worktree=False,
262
+ warnings=all_warnings,
263
+ repo_directories=prepared_repos,
264
+ )
265
+
266
+ async def _prepare_single_repo(
267
+ self,
268
+ cwd: Path,
269
+ target_branch: str,
270
+ default_branch: str,
271
+ wave_context: WaveContext | None,
272
+ ) -> PreparationResult:
273
+ """Prepare a single git repository directory.
274
+
275
+ Args:
276
+ cwd: Path to the git repository.
277
+ target_branch: Target branch name for the task.
278
+ default_branch: Default branch to sync from.
279
+ wave_context: Optional wave context.
280
+
281
+ Returns:
282
+ PreparationResult for this repository.
283
+ """
284
+ warnings: list[str] = []
285
+
286
+ # 1. Check current branch
165
287
  _, current_branch, _ = await _run_git("branch", "--show-current", cwd=cwd, check=False)
166
288
 
167
- # 3. If continuing a wave on the same branch, just fetch
289
+ # 2. If continuing a wave on the same branch, just fetch
168
290
  if wave_context and current_branch == target_branch:
169
291
  logger.info(f"Continuing wave on branch {target_branch}, fetching only")
170
292
  await _run_git("fetch", "origin", cwd=cwd, check=False)
@@ -176,17 +298,17 @@ class WorkspacePreparation:
176
298
  is_worktree=False,
177
299
  )
178
300
 
179
- # 4. Stash if dirty
301
+ # 3. Stash if dirty
180
302
  rc, status_output, _ = await _run_git("status", "--porcelain", cwd=cwd, check=False)
181
303
  if status_output:
182
304
  logger.info("Working directory dirty, stashing changes")
183
305
  await _run_git("stash", "push", "-m", "steerdev-preflight", cwd=cwd, check=False)
184
306
  warnings.append("Stashed uncommitted changes (steerdev-preflight)")
185
307
 
186
- # 5. Fetch from origin
308
+ # 4. Fetch from origin
187
309
  await _run_git("fetch", "origin", cwd=cwd, check=False)
188
310
 
189
- # 6. Sync to default branch
311
+ # 5. Sync to default branch
190
312
  rc, _, _ = await _run_git("checkout", default_branch, cwd=cwd, check=False)
191
313
  if rc != 0:
192
314
  return PreparationResult(
@@ -210,7 +332,7 @@ class WorkspacePreparation:
210
332
  f"Fast-forward failed, reset {default_branch} to origin/{default_branch}"
211
333
  )
212
334
 
213
- # 7. Create or checkout target branch
335
+ # 6. Create or checkout target branch
214
336
  rc, _, _ = await _run_git("rev-parse", "--verify", target_branch, cwd=cwd, check=False)
215
337
  branch_exists = rc == 0
216
338
 
@@ -221,7 +343,7 @@ class WorkspacePreparation:
221
343
  await _run_git("checkout", "-b", target_branch, cwd=cwd)
222
344
  logger.info(f"Created new branch: {target_branch}")
223
345
 
224
- # 8. Validate current branch
346
+ # 7. Validate current branch
225
347
  _, actual_branch, _ = await _run_git("branch", "--show-current", cwd=cwd, check=False)
226
348
  if actual_branch != target_branch:
227
349
  return PreparationResult(
@@ -290,10 +290,12 @@ class TestWorkspacePreparationInPlace:
290
290
  assert len(stash_cmds) > 0
291
291
 
292
292
  @pytest.mark.asyncio
293
- async def test_not_git_repo_fails(self, worktree_disabled, default_config, sample_task):
294
- """Non-git directory returns failure."""
293
+ async def test_not_git_repo_no_subrepos_fails(
294
+ self, worktree_disabled, default_config, sample_task, tmp_path
295
+ ):
296
+ """Non-git directory with no sub-repos returns failure."""
295
297
  prep = WorkspacePreparation(
296
- working_directory=Path("/tmp/not-a-repo"),
298
+ working_directory=tmp_path,
297
299
  worktree_config=worktree_disabled,
298
300
  branch_config=default_config,
299
301
  )
@@ -356,3 +358,307 @@ class TestWorkspacePreparationInPlace:
356
358
  reset_cmds = [c for c in calls if "reset" in " ".join(c)]
357
359
  assert len(reset_cmds) > 0
358
360
  assert any("fast-forward" in w.lower() or "reset" in w.lower() for w in result.warnings)
361
+
362
+
363
+ class TestWorkspacePreparationMultiRepo:
364
+ """Tests for multi-repo workspace preparation."""
365
+
366
+ @pytest.fixture
367
+ def worktree_disabled(self):
368
+ return WorktreeConfig(enabled=False)
369
+
370
+ @pytest.fixture
371
+ def default_config(self):
372
+ return BranchConfig(default_branch="main")
373
+
374
+ @pytest.fixture
375
+ def sample_task(self):
376
+ return {
377
+ "id": "abc12345-6789-0000-0000-000000000000",
378
+ "title": "Add user authentication",
379
+ "prompt": "Implement user auth",
380
+ "status": "started",
381
+ }
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_multi_repo_discovers_and_prepares_all(
385
+ self, worktree_disabled, default_config, sample_task, tmp_path
386
+ ):
387
+ """Multi-repo layout: discovers sub-repos and prepares each one."""
388
+ # Create subdirectories to simulate multi-repo layout
389
+ (tmp_path / "repo_a").mkdir()
390
+ (tmp_path / "repo_b").mkdir()
391
+ (tmp_path / ".hidden").mkdir() # should be skipped
392
+
393
+ prep = WorkspacePreparation(
394
+ working_directory=tmp_path,
395
+ worktree_config=worktree_disabled,
396
+ branch_config=default_config,
397
+ )
398
+
399
+ calls: list[tuple[tuple[str, ...], Path]] = []
400
+
401
+ async def mock_run_git(*args, cwd, check=True):
402
+ calls.append((args, Path(cwd)))
403
+ cmd = " ".join(args)
404
+ cwd_path = Path(cwd)
405
+
406
+ if "rev-parse --is-inside-work-tree" in cmd:
407
+ # Parent dir is NOT a repo; subdirs ARE repos
408
+ if cwd_path == tmp_path:
409
+ return (128, "", "fatal: not a git repository")
410
+ if cwd_path.name in ("repo_a", "repo_b"):
411
+ return (0, "true", "")
412
+ return (128, "", "fatal: not a git repository")
413
+ if "branch --show-current" in cmd:
414
+ # Track per-repo calls to return correct branch after checkout
415
+ show_current_calls = [
416
+ c
417
+ for c in calls
418
+ if "branch --show-current" in " ".join(c[0]) and c[1] == cwd_path
419
+ ]
420
+ if len(show_current_calls) <= 1:
421
+ return (0, "main", "")
422
+ return (0, "task/abc12345-add-user-authentication", "")
423
+ if "status --porcelain" in cmd:
424
+ return (0, "", "")
425
+ if "fetch origin" in cmd:
426
+ return (0, "", "")
427
+ if "checkout main" in cmd:
428
+ return (0, "", "")
429
+ if "merge --ff-only" in cmd:
430
+ return (0, "", "")
431
+ if "rev-parse --verify" in cmd:
432
+ return (1, "", "") # branch doesn't exist
433
+ if "checkout -b" in cmd:
434
+ return (0, "", "")
435
+ if "checkout" in cmd:
436
+ return (0, "", "")
437
+ return (0, "", "")
438
+
439
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
440
+ result = await prep.prepare(sample_task)
441
+
442
+ assert result.success is True
443
+ assert result.working_directory == tmp_path # parent dir preserved
444
+ assert len(result.repo_directories) == 2
445
+ assert tmp_path / "repo_a" in result.repo_directories
446
+ assert tmp_path / "repo_b" in result.repo_directories
447
+ # Verify git operations ran against both repos
448
+ repo_a_checkouts = [
449
+ c for c in calls if c[1] == tmp_path / "repo_a" and "checkout -b" in " ".join(c[0])
450
+ ]
451
+ repo_b_checkouts = [
452
+ c for c in calls if c[1] == tmp_path / "repo_b" and "checkout -b" in " ".join(c[0])
453
+ ]
454
+ assert len(repo_a_checkouts) == 1
455
+ assert len(repo_b_checkouts) == 1
456
+
457
+ @pytest.mark.asyncio
458
+ async def test_multi_repo_fails_if_one_repo_fails(
459
+ self, worktree_disabled, default_config, sample_task, tmp_path
460
+ ):
461
+ """Multi-repo: failure in one repo fails the whole preparation."""
462
+ (tmp_path / "repo_a").mkdir()
463
+ (tmp_path / "repo_b").mkdir()
464
+
465
+ prep = WorkspacePreparation(
466
+ working_directory=tmp_path,
467
+ worktree_config=worktree_disabled,
468
+ branch_config=default_config,
469
+ )
470
+
471
+ # Track per-repo branch --show-current calls
472
+ branch_calls_per_repo: dict[str, int] = {}
473
+
474
+ async def mock_run_git(*args, cwd, check=True):
475
+ cmd = " ".join(args)
476
+ cwd_path = Path(cwd)
477
+ repo_name = cwd_path.name
478
+
479
+ if "rev-parse --is-inside-work-tree" in cmd:
480
+ if cwd_path == tmp_path:
481
+ return (128, "", "fatal: not a git repository")
482
+ if repo_name in ("repo_a", "repo_b"):
483
+ return (0, "true", "")
484
+ return (128, "", "")
485
+ if "branch --show-current" in cmd:
486
+ branch_calls_per_repo[repo_name] = branch_calls_per_repo.get(repo_name, 0) + 1
487
+ if branch_calls_per_repo[repo_name] <= 1:
488
+ return (0, "main", "")
489
+ return (0, "task/abc12345-add-user-authentication", "")
490
+ if "status --porcelain" in cmd:
491
+ return (0, "", "")
492
+ if "fetch origin" in cmd:
493
+ return (0, "", "")
494
+ if "checkout main" in cmd:
495
+ # repo_b fails to checkout default branch
496
+ if repo_name == "repo_b":
497
+ return (1, "", "error: pathspec 'main' did not match")
498
+ return (0, "", "")
499
+ if "merge --ff-only" in cmd:
500
+ return (0, "", "")
501
+ if "rev-parse --verify" in cmd:
502
+ return (1, "", "")
503
+ if "checkout -b" in cmd:
504
+ return (0, "", "")
505
+ return (0, "", "")
506
+
507
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
508
+ result = await prep.prepare(sample_task)
509
+
510
+ assert result.success is False
511
+ assert "repo_b" in (result.error or "")
512
+ # repo_a was prepared before repo_b failed
513
+ assert len(result.repo_directories) == 1
514
+ assert tmp_path / "repo_a" in result.repo_directories
515
+
516
+ @pytest.mark.asyncio
517
+ async def test_multi_repo_collects_warnings_with_prefix(
518
+ self, worktree_disabled, default_config, sample_task, tmp_path
519
+ ):
520
+ """Multi-repo: warnings are prefixed with repo name."""
521
+ (tmp_path / "repo_a").mkdir()
522
+
523
+ prep = WorkspacePreparation(
524
+ working_directory=tmp_path,
525
+ worktree_config=worktree_disabled,
526
+ branch_config=default_config,
527
+ )
528
+
529
+ branch_call_count = [0]
530
+
531
+ async def mock_run_git(*args, cwd, check=True):
532
+ cmd = " ".join(args)
533
+ cwd_path = Path(cwd)
534
+
535
+ if "rev-parse --is-inside-work-tree" in cmd:
536
+ if cwd_path == tmp_path:
537
+ return (128, "", "fatal: not a git repository")
538
+ return (0, "true", "")
539
+ if "branch --show-current" in cmd:
540
+ # Return target branch on validation check
541
+ branch_call_count[0] += 1
542
+ if branch_call_count[0] > 1:
543
+ return (0, "task/abc12345-add-user-authentication", "")
544
+ return (0, "main", "")
545
+ if "status --porcelain" in cmd:
546
+ return (0, "M dirty.py", "") # dirty repo
547
+ if "stash push" in cmd:
548
+ return (0, "", "")
549
+ if "fetch origin" in cmd:
550
+ return (0, "", "")
551
+ if "checkout main" in cmd:
552
+ return (0, "", "")
553
+ if "merge --ff-only" in cmd:
554
+ return (0, "", "")
555
+ if "rev-parse --verify" in cmd:
556
+ return (1, "", "")
557
+ if "checkout -b" in cmd:
558
+ return (0, "", "")
559
+ return (0, "", "")
560
+
561
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
562
+ result = await prep.prepare(sample_task)
563
+
564
+ assert result.success is True
565
+ assert any("[repo_a]" in w for w in result.warnings)
566
+
567
+ @pytest.mark.asyncio
568
+ async def test_multi_repo_skips_hidden_and_non_repo_dirs(
569
+ self, worktree_disabled, default_config, sample_task, tmp_path
570
+ ):
571
+ """Multi-repo: hidden dirs and non-repo dirs are skipped."""
572
+ (tmp_path / ".hidden_repo").mkdir()
573
+ (tmp_path / "not_a_repo").mkdir()
574
+ (tmp_path / "actual_repo").mkdir()
575
+
576
+ prep = WorkspacePreparation(
577
+ working_directory=tmp_path,
578
+ worktree_config=worktree_disabled,
579
+ branch_config=default_config,
580
+ )
581
+
582
+ calls_by_cwd: dict[str, list[str]] = {}
583
+
584
+ async def mock_run_git(*args, cwd, check=True):
585
+ cmd = " ".join(args)
586
+ cwd_path = Path(cwd)
587
+ calls_by_cwd.setdefault(cwd_path.name, []).append(cmd)
588
+
589
+ if "rev-parse --is-inside-work-tree" in cmd:
590
+ if cwd_path == tmp_path:
591
+ return (128, "", "fatal: not a git repository")
592
+ if cwd_path.name == "actual_repo":
593
+ return (0, "true", "")
594
+ return (128, "", "fatal: not a git repository")
595
+ if "branch --show-current" in cmd:
596
+ branch_calls = [
597
+ c for c in calls_by_cwd.get(cwd_path.name, []) if "branch --show-current" in c
598
+ ]
599
+ if len(branch_calls) <= 1:
600
+ return (0, "main", "")
601
+ return (0, "task/abc12345-add-user-authentication", "")
602
+ if "status --porcelain" in cmd:
603
+ return (0, "", "")
604
+ if "fetch origin" in cmd:
605
+ return (0, "", "")
606
+ if "checkout main" in cmd:
607
+ return (0, "", "")
608
+ if "merge --ff-only" in cmd:
609
+ return (0, "", "")
610
+ if "rev-parse --verify" in cmd:
611
+ return (1, "", "")
612
+ if "checkout -b" in cmd:
613
+ return (0, "", "")
614
+ return (0, "", "")
615
+
616
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
617
+ result = await prep.prepare(sample_task)
618
+
619
+ assert result.success is True
620
+ assert len(result.repo_directories) == 1
621
+ assert tmp_path / "actual_repo" in result.repo_directories
622
+ # Hidden dir should never have been checked
623
+ assert ".hidden_repo" not in calls_by_cwd
624
+
625
+ @pytest.mark.asyncio
626
+ async def test_single_repo_still_works(self, worktree_disabled, default_config, sample_task):
627
+ """Single-repo layout still works as before (no regressions)."""
628
+ prep = WorkspacePreparation(
629
+ working_directory=Path("/tmp/repo"),
630
+ worktree_config=worktree_disabled,
631
+ branch_config=default_config,
632
+ )
633
+
634
+ calls = []
635
+
636
+ async def mock_run_git(*args, cwd, check=True):
637
+ calls.append(args)
638
+ cmd = " ".join(args)
639
+ if "rev-parse --is-inside-work-tree" in cmd:
640
+ return (0, "true", "")
641
+ if "branch --show-current" in cmd:
642
+ if len([c for c in calls if "branch --show-current" in " ".join(c)]) <= 1:
643
+ return (0, "main", "")
644
+ return (0, "task/abc12345-add-user-authentication", "")
645
+ if "status --porcelain" in cmd:
646
+ return (0, "", "")
647
+ if "fetch origin" in cmd:
648
+ return (0, "", "")
649
+ if "checkout main" in cmd:
650
+ return (0, "", "")
651
+ if "merge --ff-only" in cmd:
652
+ return (0, "", "")
653
+ if "rev-parse --verify" in cmd:
654
+ return (1, "", "")
655
+ if "checkout -b" in cmd:
656
+ return (0, "", "")
657
+ return (0, "", "")
658
+
659
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
660
+ result = await prep.prepare(sample_task)
661
+
662
+ assert result.success is True
663
+ assert result.repo_directories == [] # single-repo: no repo_directories
664
+ assert result.working_directory == Path("/tmp/repo")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes