dh-cli 0.12.2__tar.gz → 0.12.3__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 (112) hide show
  1. {dh_cli-0.12.2 → dh_cli-0.12.3}/PKG-INFO +2 -2
  2. {dh_cli-0.12.2 → dh_cli-0.12.3}/README.md +1 -1
  3. {dh_cli-0.12.2 → dh_cli-0.12.3}/pyproject.toml +1 -1
  4. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/main.py +3 -5
  5. dh_cli-0.12.3/src/dh_cli/sweep.py +293 -0
  6. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/utility_commands.py +0 -32
  7. dh_cli-0.12.3/tests/test_sweep.py +306 -0
  8. {dh_cli-0.12.2 → dh_cli-0.12.3}/.gitignore +0 -0
  9. {dh_cli-0.12.2 → dh_cli-0.12.3}/LICENSE +0 -0
  10. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/__init__.py +0 -0
  11. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/_identity.py +0 -0
  12. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/__init__.py +0 -0
  13. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/aws_batch.py +0 -0
  14. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/__init__.py +0 -0
  15. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/boltz.py +0 -0
  16. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/cancel.py +0 -0
  17. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/clean.py +0 -0
  18. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/disco.py +0 -0
  19. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  20. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/equilibrator.py +0 -0
  21. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/esmfold2.py +0 -0
  22. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/finalize.py +0 -0
  23. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  24. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/local.py +0 -0
  25. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/logs.py +0 -0
  26. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/orca.py +0 -0
  27. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  28. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  29. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/retry.py +0 -0
  30. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/rfd3.py +0 -0
  31. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/status.py +0 -0
  32. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/submit.py +0 -0
  33. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/wait_for.py +0 -0
  34. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/fasta_utils.py +0 -0
  35. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/h5_utils.py +0 -0
  36. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/job_id.py +0 -0
  37. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/manifest.py +0 -0
  38. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/s3_transport.py +0 -0
  39. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/__init__.py +0 -0
  40. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/commands.py +0 -0
  41. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/cost_report.py +0 -0
  42. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/pricing.yaml +0 -0
  43. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/cloud_commands.py +0 -0
  44. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/codeartifact.py +0 -0
  45. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/__init__.py +0 -0
  46. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/api_client.py +0 -0
  47. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/auth.py +0 -0
  48. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  49. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/progress.py +0 -0
  50. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  51. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  52. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/github_commands.py +0 -0
  53. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/__init__.py +0 -0
  54. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/deploy.py +0 -0
  55. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/local.py +0 -0
  56. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/test.py +0 -0
  57. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/tf.py +0 -0
  58. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/users.py +0 -0
  59. {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/warehouse.py +0 -0
  60. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/__init__.py +0 -0
  61. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_aws_batch_resources.py +0 -0
  62. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_image_override.py +0 -0
  63. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_no_train_command.py +0 -0
  64. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_cpu_only.py +0 -0
  65. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_image_validation.py +0 -0
  66. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_merge.py +0 -0
  67. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_mlflow_env.py +0 -0
  68. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/conftest.py +0 -0
  69. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  70. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  71. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/C_plain.json +0 -0
  72. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  73. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/E_service_role.json +0 -0
  74. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  75. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  76. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_build_report.py +0 -0
  77. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_classify_arn.py +0 -0
  78. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cli_exit_codes.py +0 -0
  79. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cost_calc.py +0 -0
  80. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cost_command.py +0 -0
  81. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cur_reconciliation.py +0 -0
  82. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_key_command.py +0 -0
  83. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_render_formats.py +0 -0
  84. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_resolve_base_model.py +0 -0
  85. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_rotate.py +0 -0
  86. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_s3_walker.py +0 -0
  87. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/__init__.py +0 -0
  88. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/conftest.py +0 -0
  89. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  90. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_identity.py +0 -0
  91. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_login.py +0 -0
  92. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_login_error_paths.py +0 -0
  93. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_login_security.py +0 -0
  94. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_logout.py +0 -0
  95. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_rotate.py +0 -0
  96. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_status.py +0 -0
  97. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/hz/test_init.py +0 -0
  98. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/hz/test_suites.py +0 -0
  99. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/hz/test_users.py +0 -0
  100. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_cloud_gcp.py +0 -0
  101. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_disco_command.py +0 -0
  102. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_disco_jobdef.py +0 -0
  103. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_equilibrator_command.py +0 -0
  104. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_esmfold2_cli.py +0 -0
  105. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_boltz_tar.py +0 -0
  106. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_disco.py +0 -0
  107. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_disco_dispatch.py +0 -0
  108. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_equilibrator.py +0 -0
  109. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_esmfold2.py +0 -0
  110. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_protmpnn.py +0 -0
  111. {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_rfd3.py +0 -0
  112. {dh_cli-0.12.2 → dh_cli-0.12.3}/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.3
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.3"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -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:
@@ -0,0 +1,306 @@
1
+ """Tests for `dh sweep`.
2
+
3
+ These drive real git repositories in a temp dir. The command's entire job is
4
+ interacting with git's upstream-tracking bookkeeping across a squash-merge,
5
+ which a mocked subprocess would not exercise at all. Only the GitHub PR
6
+ lookup is stubbed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+ from unittest.mock import patch
14
+
15
+ import pytest
16
+ import typer
17
+ from click.testing import CliRunner
18
+ from typer.main import get_command
19
+
20
+
21
+ def _git(repo: Path, *args: str) -> str:
22
+ result = subprocess.run(
23
+ ["git", *args],
24
+ cwd=repo,
25
+ check=True,
26
+ capture_output=True,
27
+ text=True,
28
+ )
29
+ return result.stdout.strip()
30
+
31
+
32
+ def make_repo(tmp_path: Path, name: str) -> Path:
33
+ """Create a bare origin plus a working clone holding one commit on main."""
34
+ origin = tmp_path / "remotes" / f"{name}.git"
35
+ subprocess.run(
36
+ ["git", "init", "--quiet", "--bare", "-b", "main", str(origin)],
37
+ check=True,
38
+ capture_output=True,
39
+ )
40
+ work = tmp_path / "ws" / name
41
+ subprocess.run(
42
+ ["git", "clone", "--quiet", str(origin), str(work)],
43
+ check=True,
44
+ capture_output=True,
45
+ )
46
+ _git(work, "symbolic-ref", "HEAD", "refs/heads/main")
47
+ _git(work, "config", "user.email", "test@dayhofflabs.com")
48
+ _git(work, "config", "user.name", "Test")
49
+ (work / "base.txt").write_text("base\n")
50
+ _git(work, "add", ".")
51
+ _git(work, "commit", "--quiet", "-m", "base")
52
+ _git(work, "push", "--quiet", "-u", "origin", "main")
53
+ return work
54
+
55
+
56
+ def start_branch(work: Path, branch: str) -> None:
57
+ """Branch off main, commit, and push with upstream tracking."""
58
+ _git(work, "switch", "--quiet", "main")
59
+ _git(work, "switch", "--quiet", "-c", branch)
60
+ (work / f"{branch}.txt").write_text(f"{branch}\n")
61
+ _git(work, "add", ".")
62
+ _git(work, "commit", "--quiet", "-m", f"{branch} work")
63
+ _git(work, "push", "--quiet", "-u", "origin", branch)
64
+
65
+
66
+ def squash_merge_on_server(tmp_path: Path, name: str, branch: str) -> None:
67
+ """Squash-merge `branch` into main and delete it, the way GitHub does.
68
+
69
+ Performed through a throwaway second clone so the developer's clone is
70
+ left with a stale local main -- exactly the state sweep must repair.
71
+ """
72
+ origin = tmp_path / "remotes" / f"{name}.git"
73
+ server = tmp_path / "server"
74
+ subprocess.run(
75
+ ["git", "clone", "--quiet", str(origin), str(server)],
76
+ check=True,
77
+ capture_output=True,
78
+ )
79
+ _git(server, "config", "user.email", "test@dayhofflabs.com")
80
+ _git(server, "config", "user.name", "Test")
81
+ _git(server, "merge", "--squash", f"origin/{branch}")
82
+ _git(server, "commit", "--quiet", "-m", f"{branch} (#1)")
83
+ _git(server, "push", "--quiet", "origin", "main")
84
+ _git(server, "push", "--quiet", "origin", "--delete", branch)
85
+ subprocess.run(["rm", "-rf", str(server)], check=True)
86
+
87
+
88
+ def branches(work: Path) -> set[str]:
89
+ return set(_git(work, "branch", "--format=%(refname:short)").split())
90
+
91
+
92
+ def current_branch(work: Path) -> str:
93
+ return _git(work, "branch", "--show-current")
94
+
95
+
96
+ def sha(work: Path, rev: str) -> str:
97
+ return _git(work, "rev-parse", rev)
98
+
99
+
100
+ @pytest.fixture
101
+ def ws(tmp_path: Path) -> Path:
102
+ (tmp_path / "ws").mkdir()
103
+ (tmp_path / "remotes").mkdir()
104
+ return tmp_path / "ws"
105
+
106
+
107
+ def run_sweep(ws: Path, merged: object = True, args: tuple[str, ...] = ()):
108
+ from dh_cli import sweep as sweep_mod
109
+
110
+ app = typer.Typer()
111
+ app.command("sweep")(sweep_mod.sweep)
112
+ resolve = merged if callable(merged) else (lambda repo, branch: merged)
113
+ with (
114
+ patch.dict("os.environ", {"WORKSPACE_ROOT": str(ws)}),
115
+ patch.object(sweep_mod, "pr_is_merged", side_effect=resolve),
116
+ ):
117
+ return CliRunner().invoke(get_command(app), list(args))
118
+
119
+
120
+ def test_deletes_merged_branch_and_returns_to_main(tmp_path, ws):
121
+ work = make_repo(tmp_path, "repo")
122
+ start_branch(work, "feat")
123
+ squash_merge_on_server(tmp_path, "repo", "feat")
124
+ assert current_branch(work) == "feat"
125
+
126
+ result = run_sweep(ws)
127
+
128
+ assert result.exit_code == 0, result.output
129
+ assert "feat" not in branches(work)
130
+ assert current_branch(work) == "main"
131
+
132
+
133
+ def test_fast_forwards_stale_main(tmp_path, ws):
134
+ work = make_repo(tmp_path, "repo")
135
+ start_branch(work, "feat")
136
+ squash_merge_on_server(tmp_path, "repo", "feat")
137
+ stale = sha(work, "main")
138
+
139
+ run_sweep(ws)
140
+
141
+ assert sha(work, "main") != stale
142
+ assert sha(work, "main") == sha(work, "origin/main")
143
+
144
+
145
+ def test_keeps_branch_without_merged_pr(tmp_path, ws):
146
+ work = make_repo(tmp_path, "repo")
147
+ start_branch(work, "feat")
148
+ squash_merge_on_server(tmp_path, "repo", "feat")
149
+
150
+ result = run_sweep(ws, merged=False)
151
+
152
+ assert "feat" in branches(work)
153
+ assert "unmerged work" in result.output
154
+
155
+
156
+ def test_keeps_branch_when_gh_cannot_confirm(tmp_path, ws):
157
+ work = make_repo(tmp_path, "repo")
158
+ start_branch(work, "feat")
159
+ squash_merge_on_server(tmp_path, "repo", "feat")
160
+
161
+ result = run_sweep(ws, merged=None)
162
+
163
+ assert "feat" in branches(work)
164
+ assert "gh could not confirm" in result.output
165
+
166
+
167
+ def test_dirty_worktree_on_merged_branch_is_left_alone(tmp_path, ws):
168
+ work = make_repo(tmp_path, "repo")
169
+ start_branch(work, "feat")
170
+ squash_merge_on_server(tmp_path, "repo", "feat")
171
+ (work / "scratch.txt").write_text("uncommitted\n")
172
+
173
+ result = run_sweep(ws)
174
+
175
+ assert "feat" in branches(work)
176
+ assert current_branch(work) == "feat"
177
+ assert "uncommitted changes" in result.output
178
+
179
+
180
+ def test_advances_main_without_leaving_the_active_branch(tmp_path, ws):
181
+ work = make_repo(tmp_path, "repo")
182
+ start_branch(work, "feat")
183
+ start_branch(work, "live")
184
+ squash_merge_on_server(tmp_path, "repo", "feat")
185
+ stale = sha(work, "main")
186
+ assert current_branch(work) == "live"
187
+
188
+ run_sweep(ws, merged=lambda repo, branch: branch == "feat")
189
+
190
+ assert current_branch(work) == "live"
191
+ assert "live" in branches(work)
192
+ assert "feat" not in branches(work)
193
+ assert sha(work, "main") != stale
194
+
195
+
196
+ def test_leaves_branches_with_live_upstream_alone(tmp_path, ws):
197
+ work = make_repo(tmp_path, "repo")
198
+ start_branch(work, "live")
199
+
200
+ result = run_sweep(ws)
201
+
202
+ assert result.exit_code == 0, result.output
203
+ assert branches(work) == {"main", "live"}
204
+
205
+
206
+ def test_sweeps_every_repo_in_the_workspace(tmp_path, ws):
207
+ works = []
208
+ for name in ("alpha", "beta", "gamma"):
209
+ work = make_repo(tmp_path, name)
210
+ start_branch(work, "shared")
211
+ squash_merge_on_server(tmp_path, name, "shared")
212
+ works.append(work)
213
+
214
+ result = run_sweep(ws)
215
+
216
+ assert result.exit_code == 0, result.output
217
+ for work in works:
218
+ assert "shared" not in branches(work)
219
+ assert current_branch(work) == "main"
220
+
221
+
222
+ def test_dry_run_changes_nothing(tmp_path, ws):
223
+ work = make_repo(tmp_path, "repo")
224
+ start_branch(work, "feat")
225
+ squash_merge_on_server(tmp_path, "repo", "feat")
226
+ stale = sha(work, "main")
227
+
228
+ result = run_sweep(ws, args=("--dry-run",))
229
+
230
+ assert "feat" in branches(work)
231
+ assert sha(work, "main") == stale
232
+ assert "would delete feat" in result.output
233
+
234
+
235
+ def test_second_run_is_a_no_op(tmp_path, ws):
236
+ work = make_repo(tmp_path, "repo")
237
+ start_branch(work, "feat")
238
+ squash_merge_on_server(tmp_path, "repo", "feat")
239
+
240
+ run_sweep(ws)
241
+ result = run_sweep(ws)
242
+
243
+ assert result.exit_code == 0, result.output
244
+ assert branches(work) == {"main"}
245
+ assert "nothing to do" in result.output
246
+
247
+
248
+ def test_dry_run_previews_pending_fast_forward(tmp_path, ws):
249
+ # A dry run must not call a repo with a stale main "nothing to do".
250
+ work = make_repo(tmp_path, "repo")
251
+ start_branch(work, "feat")
252
+ squash_merge_on_server(tmp_path, "repo", "feat")
253
+
254
+ result = run_sweep(ws, args=("--dry-run",))
255
+
256
+ assert "would fast-forward main" in result.output
257
+ assert "nothing to do" not in result.output
258
+
259
+
260
+ def test_dirty_default_branch_behind_origin_is_reported_not_hidden(tmp_path, ws):
261
+ # Left alone on purpose, but it must not be filed under "nothing to do".
262
+ work = make_repo(tmp_path, "repo")
263
+ start_branch(work, "feat")
264
+ squash_merge_on_server(tmp_path, "repo", "feat")
265
+ _git(work, "switch", "--quiet", "main")
266
+ stale = sha(work, "main")
267
+ (work / "wip.txt").write_text("in flight\n")
268
+
269
+ result = run_sweep(ws)
270
+
271
+ assert sha(work, "main") == stale
272
+ assert "nothing to do" not in result.output
273
+ assert "uncommitted changes, left alone" in result.output
274
+
275
+
276
+ def test_linked_worktrees_are_swept_once(tmp_path, ws):
277
+ # A linked worktree shares the parent's ref store; treating it as a
278
+ # separate repo races on concurrent fetch and double-counts branches.
279
+ from dh_cli.sweep import discover_repos
280
+
281
+ work = make_repo(tmp_path, "repo")
282
+ start_branch(work, "side")
283
+ linked = ws / "repo-side"
284
+ _git(work, "switch", "--quiet", "main")
285
+ _git(work, "worktree", "add", "--quiet", str(linked), "side")
286
+
287
+ assert discover_repos(ws) == [work]
288
+
289
+ result = run_sweep(ws)
290
+ assert result.exit_code == 0, result.output
291
+ assert "cannot lock ref" not in result.output
292
+
293
+
294
+ def test_branch_checked_out_in_linked_worktree_is_kept(tmp_path, ws):
295
+ work = make_repo(tmp_path, "repo")
296
+ start_branch(work, "side")
297
+ linked = ws / "repo-side"
298
+ _git(work, "switch", "--quiet", "main")
299
+ _git(work, "worktree", "add", "--quiet", str(linked), "side")
300
+ squash_merge_on_server(tmp_path, "repo", "side")
301
+
302
+ result = run_sweep(ws)
303
+
304
+ assert result.exit_code == 0, result.output
305
+ assert "side" in branches(work)
306
+ assert "kept side" in result.output
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