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.
- {dh_cli-0.12.2 → dh_cli-0.12.3}/PKG-INFO +2 -2
- {dh_cli-0.12.2 → dh_cli-0.12.3}/README.md +1 -1
- {dh_cli-0.12.2 → dh_cli-0.12.3}/pyproject.toml +1 -1
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/main.py +3 -5
- dh_cli-0.12.3/src/dh_cli/sweep.py +293 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/utility_commands.py +0 -32
- dh_cli-0.12.3/tests/test_sweep.py +306 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/.gitignore +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/LICENSE +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/disco.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/equilibrator.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/esmfold2.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/rfd3.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_no_train_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/batch/test_submit_mlflow_env.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_rotate.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/__init__.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/conftest.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_identity.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_login.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_logout.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/github/test_status.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/hz/test_init.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/hz/test_users.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_disco_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_disco_jobdef.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_equilibrator_command.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_esmfold2_cli.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_disco.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_disco_dispatch.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_equilibrator.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_esmfold2.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_protmpnn.py +0 -0
- {dh_cli-0.12.2 → dh_cli-0.12.3}/tests/test_finalize_rfd3.py +0 -0
- {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.
|
|
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
|
|
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
|
|
30
|
+
| `dh sweep` | Clean up merged branches across all workspace repos |
|
|
31
31
|
|
|
32
32
|
## Requirements
|
|
33
33
|
|
|
@@ -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.
|
|
10
|
-
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|