evo-cli 0.18.0__tar.gz → 0.20.0__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 (119) hide show
  1. {evo_cli-0.18.0 → evo_cli-0.20.0}/PKG-INFO +17 -1
  2. {evo_cli-0.18.0 → evo_cli-0.20.0}/README.md +16 -0
  3. evo_cli-0.20.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/download.py +51 -6
  5. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/__init__.py +3 -1
  6. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/_paths.py +4 -0
  7. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/_render.py +10 -0
  8. evo_cli-0.20.0/evo_cli/commands/harness/clone.py +123 -0
  9. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/pull.py +3 -7
  10. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/views.py +4 -0
  11. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli.egg-info/PKG-INFO +17 -1
  12. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli.egg-info/SOURCES.txt +2 -0
  13. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_download.py +58 -0
  14. evo_cli-0.20.0/tests/test_harness_clone.py +270 -0
  15. evo_cli-0.18.0/evo_cli/VERSION +0 -1
  16. {evo_cli-0.18.0 → evo_cli-0.20.0}/Containerfile +0 -0
  17. {evo_cli-0.18.0 → evo_cli-0.20.0}/HISTORY.md +0 -0
  18. {evo_cli-0.18.0 → evo_cli-0.20.0}/LICENSE +0 -0
  19. {evo_cli-0.18.0 → evo_cli-0.20.0}/MANIFEST.in +0 -0
  20. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/__init__.py +0 -0
  21. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/__main__.py +0 -0
  22. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/base.py +0 -0
  23. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/cli.py +0 -0
  24. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/__init__.py +0 -0
  25. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/agent_toy.py +0 -0
  26. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/claude_code.py +0 -0
  27. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/cloudflare.py +0 -0
  28. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/cred.py +0 -0
  29. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/fix_claude.py +0 -0
  30. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/gdrive.py +0 -0
  31. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/gh.py +0 -0
  32. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/_dag.py +0 -0
  33. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/_git.py +0 -0
  34. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/_model.py +0 -0
  35. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/_mutate.py +0 -0
  36. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/_server.py +0 -0
  37. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/check.py +0 -0
  38. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/edit.py +0 -0
  39. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/serve.py +0 -0
  40. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/show.py +0 -0
  41. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/index-CsALFg16.css +0 -0
  42. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/index-DwEXL5pV.js +0 -0
  43. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  44. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  45. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  46. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  47. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  48. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  49. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  50. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  51. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  52. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  53. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  54. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  55. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/harness/web/index.html +0 -0
  56. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/hwid.py +0 -0
  57. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/hwid_reset.py +0 -0
  58. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/localproxy.py +0 -0
  59. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/mcp.py +0 -0
  60. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/miniconda.py +0 -0
  61. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/netcheck.py +0 -0
  62. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/opencode.py +0 -0
  63. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/plantuml.py +0 -0
  64. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/serp.py +0 -0
  65. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/site2s.py +0 -0
  66. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/ssh.py +0 -0
  67. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/sysmon.py +0 -0
  68. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/tts.py +0 -0
  69. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/update.py +0 -0
  70. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/commands/wifi.py +0 -0
  71. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/console.py +0 -0
  72. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/__init__.py +0 -0
  73. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/doctor.py +0 -0
  74. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/google_oauth.py +0 -0
  75. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/migrate.py +0 -0
  76. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/oauth_flow.py +0 -0
  77. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/registry.py +0 -0
  78. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/store.py +0 -0
  79. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/credentials/sync.py +0 -0
  80. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/mcp_registry.py +0 -0
  81. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/serp/__init__.py +0 -0
  82. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/serp/client.py +0 -0
  83. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/serp/creds.py +0 -0
  84. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/serp/errors.py +0 -0
  85. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/serp/install.py +0 -0
  86. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/serp/render.py +0 -0
  87. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/__init__.py +0 -0
  88. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/chunking.py +0 -0
  89. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/core.py +0 -0
  90. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/creds.py +0 -0
  91. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/errors.py +0 -0
  92. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/http.py +0 -0
  93. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/openai.py +0 -0
  94. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/player.py +0 -0
  95. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli/tts/vbee.py +0 -0
  96. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  97. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli.egg-info/entry_points.txt +0 -0
  98. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli.egg-info/requires.txt +0 -0
  99. {evo_cli-0.18.0 → evo_cli-0.20.0}/evo_cli.egg-info/top_level.txt +0 -0
  100. {evo_cli-0.18.0 → evo_cli-0.20.0}/pyproject.toml +0 -0
  101. {evo_cli-0.18.0 → evo_cli-0.20.0}/setup.cfg +0 -0
  102. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/__init__.py +0 -0
  103. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_agent_toy.py +0 -0
  104. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_claude_code.py +0 -0
  105. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_cli.py +0 -0
  106. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_console.py +0 -0
  107. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_cred.py +0 -0
  108. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_fix_claude.py +0 -0
  109. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_gh.py +0 -0
  110. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_harness.py +0 -0
  111. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_harness_dag.py +0 -0
  112. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_mcp.py +0 -0
  113. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_opencode.py +0 -0
  114. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_plantuml.py +0 -0
  115. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_serp.py +0 -0
  116. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_sysmon.py +0 -0
  117. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_tts.py +0 -0
  118. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_update.py +0 -0
  119. {evo_cli-0.18.0 → evo_cli-0.20.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.18.0
3
+ Version: 0.20.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -159,6 +159,21 @@ else has the flat file open.
159
159
 
160
160
  #### Harness Repositories
161
161
 
162
+ Clone every repository a harness manifest declares but this machine does not have yet:
163
+
164
+ ```bash
165
+ evo harness clone
166
+ evo harness clone --dry-run # show what lands where, change nothing
167
+ evo harness clone --repo backend --depth 1
168
+ evo harness clone --all # include repos marked present: false
169
+ ```
170
+
171
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh machine
172
+ ends up with the layout the manifest, the contract seams and the exec-plans already assume. A repo
173
+ already on disk is left alone; if its `origin` is not the remote the manifest names, the summary
174
+ says so instead of touching it. When the manifest names a `branch`, the clone moves onto it, and a
175
+ branch nobody has pushed yet is reported rather than treated as a failure.
176
+
162
177
  Fast-forward every available repository declared in a harness manifest:
163
178
 
164
179
  ```bash
@@ -299,6 +314,7 @@ evo harness plans # progress across every exec-plan
299
314
  evo harness show <plan>
300
315
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
301
316
  evo harness graph <plan>:steps # the same DAG as an adjacency list
317
+ evo harness clone # clone every declared repo into its declared path
302
318
  evo harness pull # fast-forward every repo
303
319
  ```
304
320
 
@@ -131,6 +131,21 @@ else has the flat file open.
131
131
 
132
132
  #### Harness Repositories
133
133
 
134
+ Clone every repository a harness manifest declares but this machine does not have yet:
135
+
136
+ ```bash
137
+ evo harness clone
138
+ evo harness clone --dry-run # show what lands where, change nothing
139
+ evo harness clone --repo backend --depth 1
140
+ evo harness clone --all # include repos marked present: false
141
+ ```
142
+
143
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh machine
144
+ ends up with the layout the manifest, the contract seams and the exec-plans already assume. A repo
145
+ already on disk is left alone; if its `origin` is not the remote the manifest names, the summary
146
+ says so instead of touching it. When the manifest names a `branch`, the clone moves onto it, and a
147
+ branch nobody has pushed yet is reported rather than treated as a failure.
148
+
134
149
  Fast-forward every available repository declared in a harness manifest:
135
150
 
136
151
  ```bash
@@ -271,6 +286,7 @@ evo harness plans # progress across every exec-plan
271
286
  evo harness show <plan>
272
287
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
273
288
  evo harness graph <plan>:steps # the same DAG as an adjacency list
289
+ evo harness clone # clone every declared repo into its declared path
274
290
  evo harness pull # fast-forward every repo
275
291
  ```
276
292
 
@@ -0,0 +1 @@
1
+ 0.20.0
@@ -1,3 +1,4 @@
1
+ import datetime
1
2
  import importlib.util
2
3
  import os
3
4
  import shutil
@@ -26,6 +27,7 @@ AUDIO_FORMATS = ["mp3", "m4a", "opus", "flac", "wav", "aac", "vorbis", "best"]
26
27
  CONTAINERS = ["mp4", "mkv", "webm", "auto"]
27
28
  BROWSERS = ["chrome", "edge", "firefox", "brave", "chromium", "opera", "vivaldi", "safari"]
28
29
  JS_RUNTIMES = ["deno", "bun", "node"]
30
+ YTDLP_STALE_DAYS = 60
29
31
 
30
32
  DIRECT_EXTS = {
31
33
  ".7z",
@@ -137,6 +139,38 @@ def ytdlp_version():
137
139
  return (result.stdout or "").strip() or None
138
140
 
139
141
 
142
+ def ytdlp_stale_age(version=None):
143
+ """Days since the installed build was released, or None if it is recent enough.
144
+
145
+ Sites like YouTube change their streaming setup every few months, so an old
146
+ yt-dlp fails with 403s that look like a bug in this command.
147
+ """
148
+ if version is None:
149
+ version = ytdlp_version()
150
+ if not version:
151
+ return None
152
+ parts = version.split(".")[:3]
153
+ if len(parts) < 3:
154
+ return None
155
+ try:
156
+ released = datetime.date(int(parts[0]), int(parts[1]), int(parts[2]))
157
+ except ValueError:
158
+ return None
159
+ age = (datetime.date.today() - released).days
160
+ return age if age >= YTDLP_STALE_DAYS else None
161
+
162
+
163
+ def warn_if_stale(version=None):
164
+ if version is None:
165
+ version = ytdlp_version()
166
+ age = ytdlp_stale_age(version)
167
+ if age is None:
168
+ return False
169
+ warning(f"yt-dlp {version} is {age} days old: sites change often and old builds start failing with 403s.")
170
+ info("Update it with `evo download install`.")
171
+ return True
172
+
173
+
140
174
  def find_ffmpeg():
141
175
  return shutil.which("ffmpeg")
142
176
 
@@ -197,6 +231,7 @@ def install_ffmpeg(assume_yes):
197
231
  def ensure_ytdlp(assume_yes=False):
198
232
  cmd = ytdlp_command()
199
233
  if cmd:
234
+ warn_if_stale()
200
235
  return cmd
201
236
  warning("yt-dlp is not installed.")
202
237
  if not assume_yes and not click.confirm("Install yt-dlp now with pip?", default=True):
@@ -295,11 +330,15 @@ def report_environment():
295
330
  table.add_column("Component", style="info", no_wrap=True)
296
331
  table.add_column("Status", no_wrap=True)
297
332
  table.add_column("Detail", style="dim")
298
- table.add_row(
299
- "yt-dlp",
300
- "[success]ok[/success]" if version else "[error]missing[/error]",
301
- version or "run `evo download install`",
302
- )
333
+ age = ytdlp_stale_age(version)
334
+ if not version:
335
+ ytdlp_status, ytdlp_detail = "[error]missing[/error]", "run `evo download install`"
336
+ elif age is not None:
337
+ ytdlp_status = "[warning]stale[/warning]"
338
+ ytdlp_detail = f"{version} ({age} days old) - run `evo download install`"
339
+ else:
340
+ ytdlp_status, ytdlp_detail = "[success]ok[/success]", version
341
+ table.add_row("yt-dlp", ytdlp_status, ytdlp_detail)
303
342
  table.add_row(
304
343
  "ffmpeg",
305
344
  "[success]ok[/success]" if ffmpeg else "[error]missing[/error]",
@@ -356,6 +395,10 @@ def do_get(urls, opts, assume_yes):
356
395
 
357
396
  result = run_command(cmd, check=False)
358
397
  if result.returncode != 0:
398
+ if ytdlp_stale_age() is not None:
399
+ raise click.ClickException(
400
+ "yt-dlp failed and the installed build is stale. Run `evo download install`, then try again."
401
+ )
359
402
  raise click.ClickException("yt-dlp failed. Try `evo download formats <url>` to inspect the source.")
360
403
  if not opts["list_formats"]:
361
404
  success(f"Saved to [accent]{outdir}[/accent]")
@@ -675,8 +718,10 @@ def install_cmd(with_deps, assume_yes):
675
718
  def check_cmd():
676
719
  step("evo download check")
677
720
  report_environment()
678
- if not ytdlp_version():
721
+ version = ytdlp_version()
722
+ if not version:
679
723
  raise click.ClickException("yt-dlp is missing. Run `evo download install`.")
724
+ warn_if_stale(version)
680
725
  if not find_ffmpeg():
681
726
  warning("ffmpeg missing: merging and audio conversion will not work.")
682
727
  info("Fix it with `evo download install --with-deps`.")
@@ -1,6 +1,7 @@
1
1
  import rich_click as click
2
2
 
3
3
  from evo_cli.commands.harness.check import check
4
+ from evo_cli.commands.harness.clone import clone
4
5
  from evo_cli.commands.harness.edit import debt, question, repo, step
5
6
  from evo_cli.commands.harness.pull import pull
6
7
  from evo_cli.commands.harness.serve import serve
@@ -21,9 +22,10 @@ def harness_group():
21
22
  evo harness graph <plan> print a DAG as an adjacency list
22
23
  evo harness check <plan> check what the plan claims against real git
23
24
  evo harness step <plan> 3 done mark a step
25
+ evo harness clone clone every declared repo into its declared path
24
26
  evo harness pull fast-forward every repo
25
27
  """
26
28
 
27
29
 
28
- for command in (serve, repos, seams, plans, show, graph, check, step, debt, question, repo, pull):
30
+ for command in (serve, repos, seams, plans, show, graph, check, step, debt, question, repo, clone, pull):
29
31
  harness_group.add_command(command)
@@ -62,6 +62,10 @@ def load_repos(manifest_path):
62
62
  {
63
63
  "name": name,
64
64
  "path": path,
65
+ # Where the manifest says the repo belongs, whether or not it is there yet.
66
+ # `path` falls back to the workspace when the declared path is missing, so a
67
+ # command that creates the checkout has to read this one instead.
68
+ "declared_path": declared or fallback.resolve(),
65
69
  "present": bool(present) and path.is_dir(),
66
70
  "declared_present": bool(present),
67
71
  "role": str(entry.get("role") or ""),
@@ -85,6 +85,16 @@ def print_graph(title: str, graph: dict) -> None:
85
85
  print_warnings(graph["warnings"])
86
86
 
87
87
 
88
+ def print_summary(results: list) -> None:
89
+ """One `name status` line per repo, coloured by the verb the status starts with."""
90
+ click.echo()
91
+ click.secho("Summary", bold=True)
92
+ width = max((len(name) for name, _ in results), default=0)
93
+ for name, status in results:
94
+ color = "red" if status.startswith("failed") else "yellow" if status.startswith("skipped") else "green"
95
+ click.secho(f" {name:<{width}} {status}", fg=color)
96
+
97
+
88
98
  def fail_if(condition: bool) -> None:
89
99
  if condition:
90
100
  raise click.exceptions.Exit(1)
@@ -0,0 +1,123 @@
1
+ import re
2
+ import shutil
3
+
4
+ import rich_click as click
5
+
6
+ from evo_cli.commands.harness._paths import find_manifest, git, harness_option, load_repos
7
+ from evo_cli.commands.harness._render import print_summary
8
+
9
+ SCHEME_RE = re.compile(r"^\w+://")
10
+ USER_RE = re.compile(r"^[^@/]+@")
11
+
12
+
13
+ def normalize_remote(url):
14
+ """Reduce a remote URL to host + path so the ssh and https forms of one repo compare equal."""
15
+ text = str(url).strip().rstrip("/")
16
+ if text.endswith(".git"):
17
+ text = text[: -len(".git")]
18
+ text = USER_RE.sub("", SCHEME_RE.sub("", text))
19
+ return text.replace(":", "/", 1).lower()
20
+
21
+
22
+ def detail_of(result, fallback):
23
+ lines = ((result.stderr or "") + (result.stdout or "")).strip().splitlines()
24
+ return lines[-1] if lines else fallback
25
+
26
+
27
+ def already_cloned(entry, target):
28
+ """A checkout is already there. Say so, and say if it came from a different remote."""
29
+ declared = entry["origin"]
30
+ actual = git(target, "remote", "get-url", "origin").stdout.strip()
31
+ if declared and actual and normalize_remote(declared) != normalize_remote(actual):
32
+ return f"skipped (already cloned, but origin is {actual})"
33
+ return "skipped (already cloned)"
34
+
35
+
36
+ def checkout_declared_branch(entry, target):
37
+ """Move onto the branch the manifest names. A branch that is not on origin is not an error.
38
+
39
+ Cloning with `--branch` would fail the whole clone for a branch that has not been pushed yet,
40
+ which is a normal state for a plan in progress. Clone the default branch, then try to move.
41
+ """
42
+ branch = entry["branch"]
43
+ if not branch or branch.upper() == "TBD":
44
+ return ""
45
+ current = git(target, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
46
+ if current == branch:
47
+ return ""
48
+ if git(target, "checkout", branch, timeout=120).returncode != 0:
49
+ return f" (branch {branch!r} is not on origin, left on {current or 'HEAD'})"
50
+ return f" (on {branch})"
51
+
52
+
53
+ @click.command("clone", help="Clone the repositories a harness declares into their declared paths.")
54
+ @harness_option
55
+ @click.option("--repo", "repos", multiple=True, help="Clone only this repo name. Repeat for multiple repos.")
56
+ @click.option("--all", "include_absent", is_flag=True, help="Also clone repos the manifest marks present: false.")
57
+ @click.option("--depth", type=int, help="Shallow clone with this many commits of history.")
58
+ @click.option("--dry-run", is_flag=True, help="Show what would be cloned, and where.")
59
+ def clone(harness_path, repos, include_absent, depth, dry_run):
60
+ """Clone every repository the harness manifest declares but this machine does not have.
61
+
62
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh
63
+ machine ends up with the layout the manifest, the contract seams and the exec-plans already
64
+ assume. Repos already on disk are left alone.
65
+ """
66
+ if shutil.which("git") is None:
67
+ raise click.ClickException("git is not installed or is not on PATH")
68
+
69
+ manifest_path = find_manifest(harness_path)
70
+ manifest, entries = load_repos(manifest_path)
71
+ available = {entry["name"] for entry in entries}
72
+ unknown = sorted(set(repos) - available)
73
+ if unknown:
74
+ raise click.ClickException(f"Unknown repo name(s): {', '.join(unknown)}")
75
+ selected = [entry for entry in entries if not repos or entry["name"] in repos]
76
+
77
+ click.echo(f"Harness: {manifest.get('name') or manifest_path.parent.name}")
78
+ click.echo(f"Manifest: {manifest_path}")
79
+ results = []
80
+ failures = 0
81
+
82
+ for entry in selected:
83
+ name = entry["name"]
84
+ target = entry["declared_path"]
85
+
86
+ # `present: false` means the manifest knows about the repo and deliberately does not want
87
+ # it here. Naming it with --repo, or asking for --all, overrides that.
88
+ if not entry["declared_present"] and not include_absent and not repos:
89
+ results.append((name, "skipped (present: false)"))
90
+ continue
91
+ if (target / ".git").is_dir():
92
+ results.append((name, already_cloned(entry, target)))
93
+ continue
94
+ if entry["path"] != target and (entry["path"] / ".git").is_dir():
95
+ results.append((name, f"skipped (already cloned at {entry['path']}, manifest says {target})"))
96
+ continue
97
+ if target.exists() and (not target.is_dir() or any(target.iterdir())):
98
+ results.append((name, f"failed (path is taken and is not an empty directory: {target})"))
99
+ failures += 1
100
+ continue
101
+ if not entry["origin"]:
102
+ results.append((name, "failed (no origin declared in the manifest)"))
103
+ failures += 1
104
+ continue
105
+ if dry_run:
106
+ results.append((name, f"would clone {entry['origin']} into {target}"))
107
+ continue
108
+
109
+ target.parent.mkdir(parents=True, exist_ok=True)
110
+ args = ["clone"]
111
+ if depth:
112
+ args += ["--depth", str(depth)]
113
+ args += [entry["origin"], str(target)]
114
+ result = git(target.parent, *args, timeout=900)
115
+ if result.returncode != 0:
116
+ results.append((name, f"failed ({detail_of(result, 'git clone failed')})"))
117
+ failures += 1
118
+ continue
119
+ results.append((name, f"cloned into {target}{checkout_declared_branch(entry, target)}"))
120
+
121
+ print_summary(results)
122
+ if failures:
123
+ raise click.exceptions.Exit(1)
@@ -3,6 +3,7 @@ import shutil
3
3
  import rich_click as click
4
4
 
5
5
  from evo_cli.commands.harness._paths import find_manifest, git, harness_option, load_repos
6
+ from evo_cli.commands.harness._render import print_summary
6
7
 
7
8
 
8
9
  @click.command("pull", help="Fast-forward all available repositories in a harness.")
@@ -34,7 +35,7 @@ def pull(harness_path, repos, prune, dry_run):
34
35
  results.append((name, "skipped (present: false)"))
35
36
  continue
36
37
  if not path.is_dir():
37
- results.append((name, f"failed (missing: {path})"))
38
+ results.append((name, f"failed (missing: {path} - run `evo harness clone`)"))
38
39
  incomplete += 1
39
40
  continue
40
41
 
@@ -64,11 +65,6 @@ def pull(harness_path, repos, prune, dry_run):
64
65
  head = git(path, "log", "-1", "--oneline")
65
66
  results.append((name, head.stdout.strip() or "updated"))
66
67
 
67
- click.echo()
68
- click.secho("Summary", bold=True)
69
- width = max((len(name) for name, _ in results), default=0)
70
- for name, status in results:
71
- color = "red" if status.startswith("failed") else "yellow" if status.startswith("skipped") else "green"
72
- click.secho(f" {name:<{width}} {status}", fg=color)
68
+ print_summary(results)
73
69
  if incomplete:
74
70
  raise click.exceptions.Exit(1)
@@ -71,6 +71,10 @@ def repos(harness_path, with_git):
71
71
  grid.add_row(f"[bold]{entry['name']}[/]", entry["role"] or "-", branch, state, one_line(entry["note"], width))
72
72
  console.print(grid)
73
73
 
74
+ missing = [entry["name"] for entry in info["repos"] if not entry["present"]]
75
+ if missing:
76
+ console.print(f"\n[red]{len(missing)} repo(s) missing[/] - run [accent]evo harness clone[/] to fetch them")
77
+
74
78
  if info["proposals_pending"]:
75
79
  console.print(f"\n[yellow]{len(info['proposals_pending'])} pending proposal(s)[/] in proposals/_pending")
76
80
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.18.0
3
+ Version: 0.20.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -159,6 +159,21 @@ else has the flat file open.
159
159
 
160
160
  #### Harness Repositories
161
161
 
162
+ Clone every repository a harness manifest declares but this machine does not have yet:
163
+
164
+ ```bash
165
+ evo harness clone
166
+ evo harness clone --dry-run # show what lands where, change nothing
167
+ evo harness clone --repo backend --depth 1
168
+ evo harness clone --all # include repos marked present: false
169
+ ```
170
+
171
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh machine
172
+ ends up with the layout the manifest, the contract seams and the exec-plans already assume. A repo
173
+ already on disk is left alone; if its `origin` is not the remote the manifest names, the summary
174
+ says so instead of touching it. When the manifest names a `branch`, the clone moves onto it, and a
175
+ branch nobody has pushed yet is reported rather than treated as a failure.
176
+
162
177
  Fast-forward every available repository declared in a harness manifest:
163
178
 
164
179
  ```bash
@@ -299,6 +314,7 @@ evo harness plans # progress across every exec-plan
299
314
  evo harness show <plan>
300
315
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
301
316
  evo harness graph <plan>:steps # the same DAG as an adjacency list
317
+ evo harness clone # clone every declared repo into its declared path
302
318
  evo harness pull # fast-forward every repo
303
319
  ```
304
320
 
@@ -50,6 +50,7 @@ evo_cli/commands/harness/_paths.py
50
50
  evo_cli/commands/harness/_render.py
51
51
  evo_cli/commands/harness/_server.py
52
52
  evo_cli/commands/harness/check.py
53
+ evo_cli/commands/harness/clone.py
53
54
  evo_cli/commands/harness/edit.py
54
55
  evo_cli/commands/harness/pull.py
55
56
  evo_cli/commands/harness/serve.py
@@ -103,6 +104,7 @@ tests/test_download.py
103
104
  tests/test_fix_claude.py
104
105
  tests/test_gh.py
105
106
  tests/test_harness.py
107
+ tests/test_harness_clone.py
106
108
  tests/test_harness_dag.py
107
109
  tests/test_mcp.py
108
110
  tests/test_opencode.py
@@ -1,3 +1,4 @@
1
+ import datetime
1
2
  from pathlib import Path
2
3
 
3
4
  import pytest
@@ -231,3 +232,60 @@ def test_check_fails_without_ytdlp(monkeypatch):
231
232
  monkeypatch.setattr(dl, "ytdlp_version", lambda: None)
232
233
  result = CliRunner().invoke(cli, ["download", "check"])
233
234
  assert result.exit_code != 0
235
+
236
+
237
+ def version_released_days_ago(days):
238
+ released = datetime.date.today() - datetime.timedelta(days=days)
239
+ return f"{released.year}.{released.month:02d}.{released.day:02d}"
240
+
241
+
242
+ def test_recent_ytdlp_is_not_stale():
243
+ assert dl.ytdlp_stale_age(version_released_days_ago(3)) is None
244
+
245
+
246
+ def test_old_ytdlp_reports_its_age():
247
+ assert dl.ytdlp_stale_age(version_released_days_ago(200)) == 200
248
+
249
+
250
+ def test_nightly_ytdlp_build_still_parses():
251
+ assert dl.ytdlp_stale_age(version_released_days_ago(90) + ".232919") == 90
252
+
253
+
254
+ @pytest.mark.parametrize("version", [None, "", "2026", "2026.13.99", "unknown"])
255
+ def test_unparsable_ytdlp_version_is_not_stale(version):
256
+ assert dl.ytdlp_stale_age(version) is None
257
+
258
+
259
+ def test_check_warns_when_ytdlp_is_stale(monkeypatch):
260
+ monkeypatch.setattr(dl, "ytdlp_version", lambda: version_released_days_ago(200))
261
+ monkeypatch.setattr(dl, "find_ffmpeg", lambda: "/usr/bin/ffmpeg")
262
+ monkeypatch.setattr(dl, "find_js_runtime", lambda: ("node", "/usr/bin/node"))
263
+ result = CliRunner().invoke(cli, ["download", "check"])
264
+ assert result.exit_code == 0
265
+ assert "200 days old" in result.output.replace("\n", "")
266
+
267
+
268
+ def test_failure_on_stale_ytdlp_points_at_the_update(monkeypatch, tmp_path):
269
+ class Failed:
270
+ returncode = 1
271
+
272
+ monkeypatch.setattr(dl, "ensure_ytdlp", lambda assume_yes=False: ["yt-dlp"])
273
+ monkeypatch.setattr(dl, "find_ffmpeg", lambda: "/usr/bin/ffmpeg")
274
+ monkeypatch.setattr(dl, "ytdlp_version", lambda: version_released_days_ago(200))
275
+ monkeypatch.setattr(dl, "run_command", lambda *a, **kw: Failed())
276
+ result = CliRunner().invoke(cli, ["download", YOUTUBE, "-o", str(tmp_path)])
277
+ assert result.exit_code != 0
278
+ assert "evo download install" in result.output.replace("\n", "")
279
+
280
+
281
+ def test_failure_on_fresh_ytdlp_keeps_the_formats_hint(monkeypatch, tmp_path):
282
+ class Failed:
283
+ returncode = 1
284
+
285
+ monkeypatch.setattr(dl, "ensure_ytdlp", lambda assume_yes=False: ["yt-dlp"])
286
+ monkeypatch.setattr(dl, "find_ffmpeg", lambda: "/usr/bin/ffmpeg")
287
+ monkeypatch.setattr(dl, "ytdlp_version", lambda: version_released_days_ago(3))
288
+ monkeypatch.setattr(dl, "run_command", lambda *a, **kw: Failed())
289
+ result = CliRunner().invoke(cli, ["download", YOUTUBE, "-o", str(tmp_path)])
290
+ assert result.exit_code != 0
291
+ assert "evo download formats" in result.output.replace("\n", "")
@@ -0,0 +1,270 @@
1
+ import importlib
2
+ import subprocess
3
+
4
+ from click.testing import CliRunner
5
+
6
+ from evo_cli.cli import cli
7
+
8
+ # harness/__init__ binds the command object to the name `clone`, shadowing the submodule,
9
+ # so the module has to be fetched by path rather than imported by name.
10
+ clone_command = importlib.import_module("evo_cli.commands.harness.clone")
11
+
12
+
13
+ def _result(returncode=0, stdout="", stderr=""):
14
+ return subprocess.CompletedProcess([], returncode, stdout, stderr)
15
+
16
+
17
+ def _write_harness(root, workspace, extra=""):
18
+ """alpha declares a path outside <workspace>/<name>; beta relies on the fallback."""
19
+ root.mkdir()
20
+ (root / "harness.yaml").write_text(
21
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n"
22
+ f"- name: alpha\n path: {(workspace / 'nested' / 'alpha').as_posix()}\n"
23
+ " origin: https://github.com/acme/alpha.git\n"
24
+ "- name: beta\n origin: git@github.com:acme/beta.git\n" + extra,
25
+ encoding="utf-8",
26
+ )
27
+
28
+
29
+ def _checkout(path, origin="https://github.com/acme/alpha.git"):
30
+ (path / ".git").mkdir(parents=True)
31
+ return origin
32
+
33
+
34
+ def test_clone_command_is_registered():
35
+ assert "clone" in cli.commands["harness"].commands
36
+
37
+
38
+ def test_clone_dry_run_targets_the_declared_path_not_the_workspace_fallback(tmp_path, monkeypatch):
39
+ workspace = tmp_path / "repos"
40
+ workspace.mkdir()
41
+ root = tmp_path / "cluster"
42
+ _write_harness(root, workspace)
43
+ calls = []
44
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: calls.append((path, args)) or _result())
45
+
46
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run"])
47
+
48
+ assert result.exit_code == 0
49
+ assert f"would clone https://github.com/acme/alpha.git into {(workspace / 'nested' / 'alpha').resolve()}" in (
50
+ result.output
51
+ )
52
+ assert f"would clone git@github.com:acme/beta.git into {(workspace / 'beta').resolve()}" in result.output
53
+ assert calls == []
54
+
55
+
56
+ def test_clone_runs_git_clone_from_the_parent_directory(tmp_path, monkeypatch):
57
+ workspace = tmp_path / "repos"
58
+ workspace.mkdir()
59
+ root = tmp_path / "cluster"
60
+ _write_harness(root, workspace)
61
+ calls = []
62
+
63
+ def fake_git(path, *args, **kwargs):
64
+ calls.append((path, args))
65
+ return _result()
66
+
67
+ monkeypatch.setattr(clone_command, "git", fake_git)
68
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "beta", "--depth", "1"])
69
+
70
+ target = (workspace / "beta").resolve()
71
+ assert result.exit_code == 0
72
+ assert calls == [(target.parent, ("clone", "--depth", "1", "git@github.com:acme/beta.git", str(target)))]
73
+ assert f"cloned into {target}" in result.output
74
+
75
+
76
+ def test_clone_creates_the_parent_of_a_nested_declared_path(tmp_path, monkeypatch):
77
+ workspace = tmp_path / "repos"
78
+ workspace.mkdir()
79
+ root = tmp_path / "cluster"
80
+ _write_harness(root, workspace)
81
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
82
+
83
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
84
+
85
+ assert result.exit_code == 0
86
+ assert (workspace / "nested").is_dir()
87
+
88
+
89
+ def test_clone_leaves_an_existing_checkout_alone(tmp_path, monkeypatch):
90
+ workspace = tmp_path / "repos"
91
+ workspace.mkdir()
92
+ root = tmp_path / "cluster"
93
+ _write_harness(root, workspace)
94
+ _checkout(workspace / "nested" / "alpha")
95
+ # The ssh form of the same remote must not read as a different repository.
96
+ monkeypatch.setattr(
97
+ clone_command, "git", lambda path, *args, **kwargs: _result(stdout="git@github.com:acme/alpha.git\n")
98
+ )
99
+
100
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
101
+
102
+ assert result.exit_code == 0
103
+ assert "alpha skipped (already cloned)" in result.output
104
+
105
+
106
+ def test_clone_reports_a_checkout_whose_origin_is_a_different_repo(tmp_path, monkeypatch):
107
+ workspace = tmp_path / "repos"
108
+ workspace.mkdir()
109
+ root = tmp_path / "cluster"
110
+ _write_harness(root, workspace)
111
+ _checkout(workspace / "nested" / "alpha")
112
+ monkeypatch.setattr(
113
+ clone_command, "git", lambda path, *args, **kwargs: _result(stdout="https://github.com/other/fork.git\n")
114
+ )
115
+
116
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
117
+
118
+ assert result.exit_code == 0
119
+ assert "skipped (already cloned, but origin is https://github.com/other/fork.git)" in result.output
120
+
121
+
122
+ def test_clone_reports_a_checkout_sitting_somewhere_other_than_the_declared_path(tmp_path, monkeypatch):
123
+ workspace = tmp_path / "repos"
124
+ workspace.mkdir()
125
+ root = tmp_path / "cluster"
126
+ _write_harness(root, workspace)
127
+ _checkout(workspace / "alpha")
128
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
129
+
130
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
131
+
132
+ assert result.exit_code == 0
133
+ assert f"already cloned at {(workspace / 'alpha').resolve()}" in result.output
134
+ assert f"manifest says {(workspace / 'nested' / 'alpha').resolve()}" in result.output
135
+
136
+
137
+ def test_clone_refuses_to_write_into_an_occupied_path(tmp_path, monkeypatch):
138
+ workspace = tmp_path / "repos"
139
+ workspace.mkdir()
140
+ (workspace / "beta").mkdir()
141
+ (workspace / "beta" / "notes.txt").write_text("mine", encoding="utf-8")
142
+ root = tmp_path / "cluster"
143
+ _write_harness(root, workspace)
144
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
145
+
146
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "beta"])
147
+
148
+ assert result.exit_code == 1
149
+ assert "failed (path is taken and is not an empty directory" in result.output
150
+
151
+
152
+ def test_clone_reports_the_git_failure_and_exits_nonzero(tmp_path, monkeypatch):
153
+ workspace = tmp_path / "repos"
154
+ workspace.mkdir()
155
+ root = tmp_path / "cluster"
156
+ _write_harness(root, workspace)
157
+ monkeypatch.setattr(
158
+ clone_command,
159
+ "git",
160
+ lambda path, *args, **kwargs: _result(returncode=128, stderr="fatal: repository not found\n"),
161
+ )
162
+
163
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "beta"])
164
+
165
+ assert result.exit_code == 1
166
+ assert "failed (fatal: repository not found)" in result.output
167
+
168
+
169
+ def test_clone_skips_present_false_unless_asked(tmp_path, monkeypatch):
170
+ workspace = tmp_path / "repos"
171
+ workspace.mkdir()
172
+ root = tmp_path / "cluster"
173
+ _write_harness(root, workspace, extra="- name: gamma\n present: false\n origin: https://x.test/gamma.git\n")
174
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
175
+
176
+ skipped = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run"])
177
+ forced = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run", "--all"])
178
+ named = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run", "--repo", "gamma"])
179
+
180
+ assert "gamma skipped (present: false)" in skipped.output
181
+ assert f"would clone https://x.test/gamma.git into {(workspace / 'gamma').resolve()}" in forced.output
182
+ assert f"would clone https://x.test/gamma.git into {(workspace / 'gamma').resolve()}" in named.output
183
+
184
+
185
+ def test_clone_fails_a_repo_with_no_origin(tmp_path, monkeypatch):
186
+ workspace = tmp_path / "repos"
187
+ workspace.mkdir()
188
+ root = tmp_path / "cluster"
189
+ root.mkdir()
190
+ (root / "harness.yaml").write_text(
191
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n- name: alpha\n",
192
+ encoding="utf-8",
193
+ )
194
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
195
+
196
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root)])
197
+
198
+ assert result.exit_code == 1
199
+ assert "failed (no origin declared in the manifest)" in result.output
200
+
201
+
202
+ def test_clone_moves_onto_the_declared_branch_when_it_exists(tmp_path, monkeypatch):
203
+ workspace = tmp_path / "repos"
204
+ workspace.mkdir()
205
+ root = tmp_path / "cluster"
206
+ root.mkdir()
207
+ (root / "harness.yaml").write_text(
208
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n"
209
+ "- name: beta\n origin: git@github.com:acme/beta.git\n branch: feat/new-intent-flow\n",
210
+ encoding="utf-8",
211
+ )
212
+ calls = []
213
+
214
+ def fake_git(path, *args, **kwargs):
215
+ calls.append(args)
216
+ if args[0] == "rev-parse":
217
+ return _result(stdout="main\n")
218
+ return _result()
219
+
220
+ monkeypatch.setattr(clone_command, "git", fake_git)
221
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root)])
222
+
223
+ assert result.exit_code == 0
224
+ assert ("checkout", "feat/new-intent-flow") in calls
225
+ assert "(on feat/new-intent-flow)" in result.output
226
+
227
+
228
+ def test_clone_keeps_a_clone_whose_declared_branch_is_not_pushed_yet(tmp_path, monkeypatch):
229
+ workspace = tmp_path / "repos"
230
+ workspace.mkdir()
231
+ root = tmp_path / "cluster"
232
+ root.mkdir()
233
+ (root / "harness.yaml").write_text(
234
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n"
235
+ "- name: beta\n origin: git@github.com:acme/beta.git\n branch: feat/unpushed\n",
236
+ encoding="utf-8",
237
+ )
238
+
239
+ def fake_git(path, *args, **kwargs):
240
+ if args[0] == "rev-parse":
241
+ return _result(stdout="main\n")
242
+ if args[0] == "checkout":
243
+ return _result(returncode=1, stderr="error: pathspec did not match\n")
244
+ return _result()
245
+
246
+ monkeypatch.setattr(clone_command, "git", fake_git)
247
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root)])
248
+
249
+ assert result.exit_code == 0
250
+ assert "cloned into" in result.output
251
+ assert "branch 'feat/unpushed' is not on origin, left on main" in result.output
252
+
253
+
254
+ def test_clone_rejects_unknown_repo(tmp_path):
255
+ workspace = tmp_path / "repos"
256
+ workspace.mkdir()
257
+ root = tmp_path / "cluster"
258
+ _write_harness(root, workspace)
259
+
260
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "nope"])
261
+
262
+ assert result.exit_code == 1
263
+ assert "Unknown repo name(s): nope" in result.output
264
+
265
+
266
+ def test_normalize_remote_matches_ssh_and_https_forms():
267
+ assert clone_command.normalize_remote("git@github.com:acme/repo.git") == "github.com/acme/repo"
268
+ assert clone_command.normalize_remote("https://github.com/acme/repo.git") == "github.com/acme/repo"
269
+ assert clone_command.normalize_remote("ssh://git@github.com/acme/repo/") == "github.com/acme/repo"
270
+ assert clone_command.normalize_remote("https://github.com/acme/other") != "github.com/acme/repo"
@@ -1 +0,0 @@
1
- 0.18.0
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
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
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
File without changes
File without changes
File without changes
File without changes