dh-cli 0.12.2__tar.gz → 0.12.4__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. {dh_cli-0.12.2 → dh_cli-0.12.4}/PKG-INFO +2 -2
  2. {dh_cli-0.12.2 → dh_cli-0.12.4}/README.md +1 -1
  3. {dh_cli-0.12.2 → dh_cli-0.12.4}/pyproject.toml +1 -1
  4. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/engine_commands.py +94 -4
  5. dh_cli-0.12.4/src/dh_cli/engines_studios/retry.py +79 -0
  6. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/main.py +3 -5
  7. dh_cli-0.12.4/src/dh_cli/sweep.py +293 -0
  8. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/utility_commands.py +0 -32
  9. dh_cli-0.12.4/tests/engines_studios/test_engine_retry_commands.py +133 -0
  10. dh_cli-0.12.4/tests/engines_studios/test_retry.py +182 -0
  11. dh_cli-0.12.4/tests/github/__init__.py +0 -0
  12. dh_cli-0.12.4/tests/test_sweep.py +306 -0
  13. {dh_cli-0.12.2 → dh_cli-0.12.4}/.gitignore +0 -0
  14. {dh_cli-0.12.2 → dh_cli-0.12.4}/LICENSE +0 -0
  15. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/__init__.py +0 -0
  16. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/_identity.py +0 -0
  17. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/__init__.py +0 -0
  18. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/aws_batch.py +0 -0
  19. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/__init__.py +0 -0
  20. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/boltz.py +0 -0
  21. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/cancel.py +0 -0
  22. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/clean.py +0 -0
  23. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/disco.py +0 -0
  24. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  25. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/equilibrator.py +0 -0
  26. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/esmfold2.py +0 -0
  27. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/finalize.py +0 -0
  28. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  29. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/local.py +0 -0
  30. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/logs.py +0 -0
  31. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/orca.py +0 -0
  32. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  33. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  34. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/retry.py +0 -0
  35. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/rfd3.py +0 -0
  36. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/status.py +0 -0
  37. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/submit.py +0 -0
  38. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/commands/wait_for.py +0 -0
  39. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/fasta_utils.py +0 -0
  40. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/h5_utils.py +0 -0
  41. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/job_id.py +0 -0
  42. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/manifest.py +0 -0
  43. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/batch/s3_transport.py +0 -0
  44. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/__init__.py +0 -0
  45. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/commands.py +0 -0
  46. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/cost_report.py +0 -0
  47. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/bedrock/pricing.yaml +0 -0
  48. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/cloud_commands.py +0 -0
  49. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/codeartifact.py +0 -0
  50. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/__init__.py +0 -0
  51. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/api_client.py +0 -0
  52. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/auth.py +0 -0
  53. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/progress.py +0 -0
  54. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  55. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  56. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/github_commands.py +0 -0
  57. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/__init__.py +0 -0
  58. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/deploy.py +0 -0
  59. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/local.py +0 -0
  60. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/test.py +0 -0
  61. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/tf.py +0 -0
  62. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/hz/users.py +0 -0
  63. {dh_cli-0.12.2 → dh_cli-0.12.4}/src/dh_cli/warehouse.py +0 -0
  64. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/__init__.py +0 -0
  65. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_aws_batch_resources.py +0 -0
  66. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_image_override.py +0 -0
  67. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_no_train_command.py +0 -0
  68. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_cpu_only.py +0 -0
  69. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_image_validation.py +0 -0
  70. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_merge.py +0 -0
  71. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/batch/test_submit_mlflow_env.py +0 -0
  72. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/conftest.py +0 -0
  73. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  74. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  75. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/C_plain.json +0 -0
  76. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  77. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/E_service_role.json +0 -0
  78. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  79. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  80. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_build_report.py +0 -0
  81. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_classify_arn.py +0 -0
  82. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cli_exit_codes.py +0 -0
  83. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cost_calc.py +0 -0
  84. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cost_command.py +0 -0
  85. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_cur_reconciliation.py +0 -0
  86. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_key_command.py +0 -0
  87. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_render_formats.py +0 -0
  88. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_resolve_base_model.py +0 -0
  89. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_rotate.py +0 -0
  90. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/bedrock/test_s3_walker.py +0 -0
  91. {dh_cli-0.12.2/tests/github → dh_cli-0.12.4/tests/engines_studios}/__init__.py +0 -0
  92. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/conftest.py +0 -0
  93. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  94. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_identity.py +0 -0
  95. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_login.py +0 -0
  96. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_login_error_paths.py +0 -0
  97. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_login_security.py +0 -0
  98. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_logout.py +0 -0
  99. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_rotate.py +0 -0
  100. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/github/test_status.py +0 -0
  101. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/hz/test_init.py +0 -0
  102. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/hz/test_suites.py +0 -0
  103. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/hz/test_users.py +0 -0
  104. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_cloud_gcp.py +0 -0
  105. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_disco_command.py +0 -0
  106. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_disco_jobdef.py +0 -0
  107. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_equilibrator_command.py +0 -0
  108. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_esmfold2_cli.py +0 -0
  109. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_boltz_tar.py +0 -0
  110. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_disco.py +0 -0
  111. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_disco_dispatch.py +0 -0
  112. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_equilibrator.py +0 -0
  113. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_esmfold2.py +0 -0
  114. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_protmpnn.py +0 -0
  115. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_finalize_rfd3.py +0 -0
  116. {dh_cli-0.12.2 → dh_cli-0.12.4}/tests/test_rfd3_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.12.2
3
+ Version: 0.12.4
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -166,7 +166,7 @@ dh --version
166
166
  | `dh gh` | GitHub authentication |
167
167
  | `dh wget` | Download from data warehouse |
168
168
  | `dh wheel` | Build and publish packages |
169
- | `dh clean` | Delete local git branches |
169
+ | `dh sweep` | Clean up merged branches across all workspace repos |
170
170
 
171
171
  ## Requirements
172
172
 
@@ -27,7 +27,7 @@ dh --version
27
27
  | `dh gh` | GitHub authentication |
28
28
  | `dh wget` | Download from data warehouse |
29
29
  | `dh wheel` | Build and publish packages |
30
- | `dh clean` | Delete local git branches |
30
+ | `dh sweep` | Clean up merged branches across all workspace repos |
31
31
 
32
32
  ## Requirements
33
33
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.12.2"
7
+ version = "0.12.4"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -8,8 +8,65 @@ import click
8
8
  from .api_client import StudioManagerClient
9
9
  from .auth import check_aws_auth, detect_aws_environment, get_aws_username
10
10
  from .progress import format_idle_state, format_time_ago, wait_with_progress
11
+ from .retry import retry_on_capacity
11
12
  from .ssh_config import update_ssh_config_silent
12
13
 
14
+ # Default capacity-retry budget: transient InsufficientInstanceCapacity in our
15
+ # G-family AZs usually clears within a few minutes (see topic-quota-and-caps.md).
16
+ _DEFAULT_RETRY_TIMEOUT = 600 # seconds (~10 min)
17
+ _DEFAULT_RETRY_INTERVAL = 30 # seconds
18
+
19
+
20
+ def _retry_options(func):
21
+ """Shared --retry / --no-retry / interval options for launch and start."""
22
+ func = click.option(
23
+ "--no-retry",
24
+ is_flag=True,
25
+ help="Fail immediately on capacity errors instead of retrying.",
26
+ )(func)
27
+ func = click.option(
28
+ "--retry-timeout",
29
+ type=int,
30
+ default=_DEFAULT_RETRY_TIMEOUT,
31
+ show_default=True,
32
+ help="Max seconds to keep retrying transient capacity errors (0 disables).",
33
+ )(func)
34
+ func = click.option(
35
+ "--retry-interval",
36
+ type=int,
37
+ default=_DEFAULT_RETRY_INTERVAL,
38
+ show_default=True,
39
+ help="Seconds to wait between capacity-retry attempts.",
40
+ )(func)
41
+ return func
42
+
43
+
44
+ def _make_capacity_retry(engine_type: str, no_retry: bool, timeout: int, interval: int):
45
+ """Build a retry wrapper that echoes status between capacity retries.
46
+
47
+ Returns a callable taking a zero-arg `action` and running it with bounded
48
+ capacity-retry. With `no_retry` (or timeout 0) it runs the action once.
49
+ """
50
+ effective_timeout = 0 if no_retry else timeout
51
+
52
+ def on_retry(attempt: int, error: BaseException, wait_seconds: float) -> None:
53
+ click.echo(
54
+ f"⏳ Attempt {attempt} failed: no {engine_type} capacity available "
55
+ f"right now (AWS-side). Retrying in {int(wait_seconds)}s… "
56
+ f"(Ctrl+C to cancel)",
57
+ err=True,
58
+ )
59
+
60
+ def run(action):
61
+ return retry_on_capacity(
62
+ action,
63
+ timeout=effective_timeout,
64
+ interval=interval,
65
+ on_retry=on_retry,
66
+ )
67
+
68
+ return run
69
+
13
70
 
14
71
  @click.group()
15
72
  def engine_cli():
@@ -44,6 +101,7 @@ def engine_cli():
44
101
  default=None,
45
102
  help="Environment (dev, sand, prod) - auto-detected if not specified",
46
103
  )
104
+ @_retry_options
47
105
  def launch_engine(
48
106
  name: str,
49
107
  engine_type: str,
@@ -53,6 +111,9 @@ def launch_engine(
53
111
  no_wait: bool,
54
112
  skip_ssh_config: bool,
55
113
  env: Optional[str],
114
+ retry_interval: int,
115
+ retry_timeout: int,
116
+ no_retry: bool,
56
117
  ):
57
118
  """Launch a new engine for the current user (or specified user with --user flag)."""
58
119
 
@@ -83,8 +144,13 @@ def launch_engine(
83
144
  click.echo(f"🚀 Launching {engine_type} engine '{name}' for {user}...")
84
145
 
85
146
  try:
86
- # Launch the engine
87
- engine = client.launch_engine(name=name, user=user, engine_type=engine_type, boot_disk_size=boot_disk_size)
147
+ # Launch the engine, retrying transient AWS capacity errors by default.
148
+ retry = _make_capacity_retry(engine_type, no_retry, retry_timeout, retry_interval)
149
+ engine = retry(
150
+ lambda: client.launch_engine(
151
+ name=name, user=user, engine_type=engine_type, boot_disk_size=boot_disk_size
152
+ )
153
+ )
88
154
 
89
155
  engine_id = engine["instance_id"]
90
156
  click.echo(f"✓ EC2 instance launched: {engine_id}")
@@ -128,6 +194,10 @@ def launch_engine(
128
194
  if update_ssh_config_silent(client, env):
129
195
  click.echo("✓ SSH config updated")
130
196
 
197
+ except KeyboardInterrupt:
198
+ click.echo("\n✗ Cancelled while waiting for capacity.", err=True)
199
+ raise click.Abort()
200
+
131
201
  except Exception as e:
132
202
  error_msg = str(e)
133
203
 
@@ -197,7 +267,17 @@ def launch_engine(
197
267
  default=None,
198
268
  help="Environment (dev, sand, prod) - auto-detected if not specified",
199
269
  )
200
- def start_engine(name_or_id: str, no_wait: bool, skip_ssh_config: bool, yes: bool, env: Optional[str]):
270
+ @_retry_options
271
+ def start_engine(
272
+ name_or_id: str,
273
+ no_wait: bool,
274
+ skip_ssh_config: bool,
275
+ yes: bool,
276
+ env: Optional[str],
277
+ retry_interval: int,
278
+ retry_timeout: int,
279
+ no_retry: bool,
280
+ ):
201
281
  """Start a stopped engine."""
202
282
 
203
283
  # Check AWS auth first to provide clear error messages
@@ -225,7 +305,13 @@ def start_engine(name_or_id: str, no_wait: bool, skip_ssh_config: bool, yes: boo
225
305
  engine_id = engine["instance_id"]
226
306
  engine_name = engine.get("name", engine_id)
227
307
 
228
- result = client.start_engine(engine_id)
308
+ # Start the instance, retrying transient AWS capacity errors by default.
309
+ # Note: a stopped engine restarts in its ORIGINAL AZ, so retry here is a
310
+ # time-wait for that one AZ's capacity to return — it cannot fall to
311
+ # another AZ (unlike launch).
312
+ engine_type_label = engine.get("engine_type", "engine")
313
+ retry = _make_capacity_retry(engine_type_label, no_retry, retry_timeout, retry_interval)
314
+ result = retry(lambda: client.start_engine(engine_id))
229
315
 
230
316
  if "error" in result:
231
317
  click.echo(f"✗ Error: {result['error']}", err=True)
@@ -305,6 +391,10 @@ def start_engine(name_or_id: str, no_wait: bool, skip_ssh_config: bool, yes: boo
305
391
  if update_ssh_config_silent(client, env):
306
392
  click.echo("✓ SSH config updated")
307
393
 
394
+ except KeyboardInterrupt:
395
+ click.echo("\n✗ Cancelled while waiting for capacity.", err=True)
396
+ raise click.Abort()
397
+
308
398
  except Exception as e:
309
399
  click.echo(f"✗ Error: {e}", err=True)
310
400
  raise click.Abort()
@@ -0,0 +1,79 @@
1
+ """Bounded capacity-retry for engine launch/start.
2
+
3
+ AWS periodically has no on-demand G-family capacity in our AZs, surfacing as
4
+ `InsufficientInstanceCapacity` (and, when a subnet is IP-starved,
5
+ `InsufficientFreeAddressesInSubnet`). These are transient: capacity returns
6
+ within minutes. This helper retries such failures within a wall-clock budget
7
+ so users (and agents) don't have to re-run the command by hand.
8
+
9
+ Quota errors (`VcpuLimitExceeded`), instance-count limits
10
+ (`InstanceLimitExceeded`), and validation errors are NOT capacity problems and
11
+ are never retried — they will not resolve by waiting.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import Callable, Optional, TypeVar
18
+
19
+ T = TypeVar("T")
20
+
21
+ # Substrings that mark a transient, retryable AWS capacity/IP shortage.
22
+ _CAPACITY_MARKERS = (
23
+ "InsufficientInstanceCapacity",
24
+ "InsufficientFreeAddressesInSubnet",
25
+ )
26
+
27
+
28
+ def is_capacity_error(error: BaseException) -> bool:
29
+ """True if `error` is a transient AWS capacity/IP shortage worth retrying."""
30
+ message = str(error)
31
+ return any(marker in message for marker in _CAPACITY_MARKERS)
32
+
33
+
34
+ def retry_on_capacity(
35
+ action: Callable[[], T],
36
+ *,
37
+ timeout: float,
38
+ interval: float,
39
+ on_retry: Optional[Callable[[int, BaseException, float], None]] = None,
40
+ monotonic: Callable[[], float] = time.monotonic,
41
+ sleep: Callable[[float], None] = time.sleep,
42
+ ) -> T:
43
+ """Call `action`, retrying transient capacity errors within a budget.
44
+
45
+ Args:
46
+ action: Zero-arg callable performing the launch/start attempt.
47
+ timeout: Total wall-clock budget in seconds. A new attempt is only
48
+ started while elapsed time is strictly below this. ``0`` yields a
49
+ single attempt (no retries).
50
+ interval: Seconds to wait between attempts.
51
+ on_retry: Optional callback invoked before each sleep with
52
+ ``(attempt_number, error, wait_seconds)`` — used to print status.
53
+ monotonic: Monotonic clock source (injected for testing).
54
+ sleep: Sleep function (injected for testing).
55
+
56
+ Returns:
57
+ Whatever `action` returns on its first success.
58
+
59
+ Raises:
60
+ The last capacity error if the budget is exhausted, or immediately
61
+ re-raises any non-capacity error from `action`.
62
+ """
63
+ start = monotonic()
64
+ attempt = 0
65
+ last_error: Optional[BaseException] = None
66
+ while True:
67
+ attempt += 1
68
+ try:
69
+ return action()
70
+ except Exception as error: # noqa: BLE001 — re-raised below unless retryable
71
+ if not is_capacity_error(error):
72
+ raise
73
+ last_error = error
74
+ # Stop if the next attempt would not begin within the budget.
75
+ if (monotonic() - start) + interval >= timeout:
76
+ raise last_error
77
+ if on_retry is not None:
78
+ on_retry(attempt, error, interval)
79
+ sleep(interval)
@@ -6,10 +6,8 @@ import typer
6
6
 
7
7
  from dh_cli.cloud_commands import aws_app, gcp_app
8
8
  from dh_cli.github_commands import gh_app
9
- from dh_cli.utility_commands import (
10
- build_and_upload_wheel,
11
- delete_local_branch,
12
- )
9
+ from dh_cli.sweep import sweep
10
+ from dh_cli.utility_commands import build_and_upload_wheel
13
11
  from dh_cli.warehouse import get_from_warehouse_typer
14
12
 
15
13
 
@@ -26,7 +24,7 @@ app = typer.Typer(
26
24
  )
27
25
 
28
26
  # Utility commands
29
- app.command("clean")(delete_local_branch)
27
+ app.command("sweep")(sweep)
30
28
  app.command("wget")(get_from_warehouse_typer)
31
29
 
32
30
  # Cloud commands
@@ -0,0 +1,293 @@
1
+ """Post-merge branch cleanup across every repo in the workspace.
2
+
3
+ `dh sweep` takes no arguments: the work to do is derived entirely from
4
+ origin state. A local branch whose upstream has been deleted (`[gone]`
5
+ after `git fetch -p`) is a cleanup candidate; it is only deleted once a
6
+ merged PR for it is confirmed via `gh`. Anything unproven or unsafe is
7
+ skipped and reported.
8
+
9
+ Because the outcome depends only on origin, the command is idempotent and
10
+ convergent -- running it on several machines needs no coordination.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ import typer
22
+
23
+ FETCH_WORKERS = 8
24
+
25
+
26
+ @dataclass
27
+ class RepoReport:
28
+ """What happened (or didn't) in one repo."""
29
+
30
+ name: str
31
+ deleted: list[str] = field(default_factory=list)
32
+ skipped: list[tuple[str, str]] = field(default_factory=list)
33
+ advanced: str | None = None
34
+ note: str | None = None
35
+ error: str | None = None
36
+
37
+ @property
38
+ def changed(self) -> bool:
39
+ return bool(self.deleted) or self.advanced is not None
40
+
41
+ @property
42
+ def quiet(self) -> bool:
43
+ return not self.changed and not self.skipped and self.note is None
44
+
45
+
46
+ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
47
+ return subprocess.run(
48
+ ["git", *args],
49
+ cwd=repo,
50
+ capture_output=True,
51
+ text=True,
52
+ )
53
+
54
+
55
+ def _git_out(repo: Path, *args: str) -> str:
56
+ return _git(repo, *args).stdout.strip()
57
+
58
+
59
+ def discover_repos(root: Path) -> list[Path]:
60
+ """Return immediate subdirectories of root that are distinct git repos.
61
+
62
+ Linked worktrees share their parent's ref store, so sweeping them
63
+ separately both races on concurrent fetch and double-counts branches.
64
+ Only one checkout per repo is returned, preferring the primary one.
65
+ """
66
+ found: dict[str, Path] = {}
67
+ for path in sorted(p for p in root.iterdir() if (p / ".git").exists()):
68
+ common = _git_out(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
69
+ key = common or str(path)
70
+ # A primary checkout owns its common dir; a linked worktree points elsewhere.
71
+ if key not in found or Path(key).is_relative_to(path):
72
+ found[key] = path
73
+ return sorted(found.values())
74
+
75
+
76
+ def default_branch(repo: Path) -> str | None:
77
+ """Resolve the repo's default branch from origin/HEAD, else main/master."""
78
+ head = _git_out(repo, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
79
+ if head.startswith("refs/remotes/origin/"):
80
+ return head.rsplit("/", 1)[-1]
81
+ for candidate in ("main", "master"):
82
+ if _git(repo, "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{candidate}").returncode == 0:
83
+ return candidate
84
+ return None
85
+
86
+
87
+ def gone_branches(repo: Path) -> list[str]:
88
+ """Local branches whose upstream no longer exists on the remote."""
89
+ out = _git_out(
90
+ repo,
91
+ "for-each-ref",
92
+ "--format=%(refname:short)%09%(upstream:track)",
93
+ "refs/heads",
94
+ )
95
+ gone = []
96
+ for line in out.splitlines():
97
+ name, _, track = line.partition("\t")
98
+ if "[gone]" in track:
99
+ gone.append(name)
100
+ return gone
101
+
102
+
103
+ def is_dirty(repo: Path) -> bool:
104
+ """True if the working tree has staged, unstaged, or untracked changes."""
105
+ return bool(_git_out(repo, "status", "--porcelain"))
106
+
107
+
108
+ def pr_is_merged(repo: Path, branch: str) -> bool | None:
109
+ """Whether branch has a merged PR. None if gh cannot answer."""
110
+ result = subprocess.run(
111
+ [
112
+ "gh",
113
+ "pr",
114
+ "list",
115
+ "--head",
116
+ branch,
117
+ "--state",
118
+ "merged",
119
+ "--limit",
120
+ "1",
121
+ "--json",
122
+ "number",
123
+ ],
124
+ cwd=repo,
125
+ capture_output=True,
126
+ text=True,
127
+ )
128
+ if result.returncode != 0:
129
+ return None
130
+ body = result.stdout.strip()
131
+ if not body:
132
+ return None
133
+ return body != "[]"
134
+
135
+
136
+ def fetch_pruned(repo: Path) -> str | None:
137
+ """Fetch with prune. Returns an error message on failure."""
138
+ result = _git(repo, "fetch", "--prune", "--quiet")
139
+ if result.returncode == 0:
140
+ return None
141
+ lines = (result.stderr or result.stdout).strip().splitlines()
142
+ return lines[-1] if lines else "fetch failed"
143
+
144
+
145
+ def _advance_default(repo: Path, default: str, current: str) -> str | None:
146
+ """Fast-forward the local default branch to origin. Returns new short sha."""
147
+ before = _git_out(repo, "rev-parse", "--short", default)
148
+ if current == default:
149
+ result = _git(repo, "merge", "--ff-only", f"origin/{default}")
150
+ else:
151
+ # Updates the ref without a checkout; git refuses if not a fast-forward.
152
+ result = _git(repo, "fetch", "origin", f"{default}:{default}")
153
+ if result.returncode != 0:
154
+ return None
155
+ after = _git_out(repo, "rev-parse", "--short", default)
156
+ return after if after != before else None
157
+
158
+
159
+ def _behind_count(repo: Path, default: str) -> int:
160
+ """How many commits origin/<default> is ahead of the local default branch."""
161
+ out = _git_out(repo, "rev-list", "--count", f"{default}..origin/{default}")
162
+ return int(out) if out.isdigit() else 0
163
+
164
+
165
+ def sweep_repo(repo: Path, *, dry_run: bool = False) -> RepoReport:
166
+ """Clean up one repo. Assumes fetch --prune has already run."""
167
+ report = RepoReport(name=repo.name)
168
+
169
+ default = default_branch(repo)
170
+ if default is None:
171
+ report.error = "no origin default branch"
172
+ return report
173
+
174
+ candidates = gone_branches(repo)
175
+ current = _git_out(repo, "branch", "--show-current")
176
+ dirty = is_dirty(repo)
177
+
178
+ for branch in candidates:
179
+ if branch == default:
180
+ report.skipped.append((branch, "is the default branch"))
181
+ continue
182
+
183
+ merged = pr_is_merged(repo, branch)
184
+ if merged is None:
185
+ report.skipped.append((branch, "no merged PR found (gh could not confirm)"))
186
+ continue
187
+ if not merged:
188
+ report.skipped.append((branch, "no merged PR — may hold unmerged work"))
189
+ continue
190
+
191
+ if branch == current:
192
+ if dirty:
193
+ report.skipped.append((branch, "checked out with uncommitted changes"))
194
+ continue
195
+ if not dry_run:
196
+ if _git(repo, "switch", "--quiet", default).returncode != 0:
197
+ report.skipped.append((branch, f"could not switch to {default}"))
198
+ continue
199
+ current = default
200
+
201
+ if dry_run:
202
+ report.deleted.append(branch)
203
+ continue
204
+
205
+ # -D is required: a squash-merged branch is never "fully merged" to git.
206
+ result = _git(repo, "branch", "-D", branch)
207
+ if result.returncode != 0:
208
+ report.skipped.append((branch, (result.stderr or "delete failed").strip().splitlines()[0]))
209
+ else:
210
+ report.deleted.append(branch)
211
+
212
+ if not (dirty and current == default):
213
+ if dry_run:
214
+ behind = _behind_count(repo, default)
215
+ if behind:
216
+ report.advanced = f"would fast-forward {default} ({behind} commit(s) behind)"
217
+ else:
218
+ new_sha = _advance_default(repo, default, current)
219
+ if new_sha:
220
+ report.advanced = f"{default} → {new_sha}"
221
+ else:
222
+ behind = _behind_count(repo, default)
223
+ if behind:
224
+ report.note = f"{default} is {behind} commit(s) behind — uncommitted changes, left alone"
225
+
226
+ return report
227
+
228
+
229
+ def _render(reports: list[RepoReport], dry_run: bool) -> None:
230
+ verb = "would delete" if dry_run else "deleted"
231
+ quiet = []
232
+
233
+ for report in reports:
234
+ if report.error:
235
+ typer.echo(f" {report.name}: {report.error}")
236
+ continue
237
+ if report.quiet:
238
+ quiet.append(report.name)
239
+ continue
240
+ typer.echo(f" {report.name}")
241
+ for branch in report.deleted:
242
+ typer.echo(f" {verb} {branch}")
243
+ if report.advanced:
244
+ typer.echo(f" {report.advanced}")
245
+ if report.note:
246
+ typer.echo(f" {report.note}")
247
+ for branch, reason in report.skipped:
248
+ typer.echo(f" kept {branch} — {reason}")
249
+
250
+ if quiet:
251
+ typer.echo(f" nothing to do: {', '.join(quiet)}")
252
+
253
+
254
+ def sweep(
255
+ dry_run: bool = typer.Option(
256
+ False,
257
+ "--dry-run",
258
+ "-n",
259
+ help="Report what would change without touching anything.",
260
+ ),
261
+ ) -> None:
262
+ """Delete merged branches and fast-forward main in every workspace repo.
263
+
264
+ Derived entirely from origin state, so it is safe to re-run and needs
265
+ no coordination between machines. Branches without a confirmed merged
266
+ PR are always kept.
267
+ """
268
+ root = Path(os.environ.get("WORKSPACE_ROOT", "/workspaces/code"))
269
+ if not root.is_dir():
270
+ typer.echo(f"Workspace root not found: {root}")
271
+ raise typer.Exit(1)
272
+
273
+ repos = discover_repos(root)
274
+ if not repos:
275
+ typer.echo(f"No git repos found in {root}")
276
+ raise typer.Exit(1)
277
+
278
+ typer.echo(f"Fetching {len(repos)} repos...")
279
+ with ThreadPoolExecutor(max_workers=FETCH_WORKERS) as pool:
280
+ fetch_errors = dict(zip(repos, pool.map(fetch_pruned, repos)))
281
+
282
+ reports = []
283
+ for repo in repos:
284
+ if fetch_errors[repo]:
285
+ reports.append(RepoReport(name=repo.name, error=fetch_errors[repo]))
286
+ else:
287
+ reports.append(sweep_repo(repo, dry_run=dry_run))
288
+
289
+ _render(reports, dry_run)
290
+
291
+ kept = sum(len(r.skipped) for r in reports)
292
+ if kept:
293
+ typer.echo(f"\n{kept} branch(es) kept for review.")
@@ -11,38 +11,6 @@ import toml
11
11
  # Import cloud helper lazily inside functions to avoid heavy deps at module load
12
12
 
13
13
 
14
- def delete_local_branch(branch_name: str, folder_path: str):
15
- """Delete a local Git branch after fetching with pruning.
16
-
17
- Args:
18
- branch_name: Name of the branch to delete
19
- folder_path: Path to the git repository folder
20
- """
21
- try:
22
- # Store current working directory
23
- original_dir = os.getcwd()
24
-
25
- # Change to the specified directory
26
- os.chdir(folder_path)
27
- print(f"Changed to directory: {folder_path}")
28
-
29
- # Delete the specified branch
30
- delete_branch_cmd = ["git", "branch", "-D", branch_name]
31
- subprocess.run(delete_branch_cmd, check=True)
32
- print(f"Deleted branch: {branch_name}")
33
-
34
- # Fetch changes from the remote repository and prune obsolete branches
35
- fetch_prune_cmd = ["git", "fetch", "-p"]
36
- subprocess.run(fetch_prune_cmd, check=True)
37
- print("Fetched changes and pruned obsolete branches")
38
-
39
- except subprocess.CalledProcessError as e:
40
- print(f"Error occurred while running Git commands: {e}")
41
- finally:
42
- # Always return to the original directory
43
- os.chdir(original_dir)
44
-
45
-
46
14
  def get_current_version_from_toml(file_path="pyproject.toml"):
47
15
  """Reads the version from a pyproject.toml file."""
48
16
  try: