agent-worktree-manager 0.2.0__py3-none-any.whl
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.
- agent_worktree_manager/__init__.py +3 -0
- agent_worktree_manager/__main__.py +5 -0
- agent_worktree_manager/agent_skill.py +49 -0
- agent_worktree_manager/cli.py +386 -0
- agent_worktree_manager/config.py +196 -0
- agent_worktree_manager/environments.py +385 -0
- agent_worktree_manager/errors.py +5 -0
- agent_worktree_manager/git.py +96 -0
- agent_worktree_manager/inspection.py +324 -0
- agent_worktree_manager/lifecycle.py +607 -0
- agent_worktree_manager/migration.py +125 -0
- agent_worktree_manager/process.py +63 -0
- agent_worktree_manager/registry.py +100 -0
- agent_worktree_manager/shell.py +123 -0
- agent_worktree_manager/skills/awm/SKILL.md +111 -0
- agent_worktree_manager/skills/awm/agents/openai.yaml +4 -0
- agent_worktree_manager/storage.py +85 -0
- agent_worktree_manager/tui.py +769 -0
- agent_worktree_manager-0.2.0.dist-info/METADATA +125 -0
- agent_worktree_manager-0.2.0.dist-info/RECORD +24 -0
- agent_worktree_manager-0.2.0.dist-info/WHEEL +5 -0
- agent_worktree_manager-0.2.0.dist-info/entry_points.txt +2 -0
- agent_worktree_manager-0.2.0.dist-info/licenses/LICENSE +29 -0
- agent_worktree_manager-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Inspect and reproduce installed environments without writing to the base."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
14
|
+
|
|
15
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
16
|
+
from packaging.utils import canonicalize_name
|
|
17
|
+
from packaging.version import InvalidVersion, Version
|
|
18
|
+
|
|
19
|
+
from .errors import AWMError
|
|
20
|
+
from .process import clean_env, run
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Env:
|
|
25
|
+
name: str
|
|
26
|
+
path: str
|
|
27
|
+
kind: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def label(self) -> str:
|
|
31
|
+
return f"{self.name} ({self.kind})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
INSPECT = r"""
|
|
35
|
+
import importlib.metadata as md
|
|
36
|
+
import json, os, platform, re, sys, sysconfig
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
packages = []
|
|
39
|
+
metadata_paths = {}
|
|
40
|
+
# Editable .pth files can expose stale source egg-info outside the environment.
|
|
41
|
+
# Only installed metadata belongs in the inventory; source URLs come from it.
|
|
42
|
+
environment_paths = [root for root in sys.path
|
|
43
|
+
if Path(root).resolve().is_relative_to(Path(sys.prefix).resolve())]
|
|
44
|
+
for root in environment_paths:
|
|
45
|
+
root = Path(root)
|
|
46
|
+
if root.is_dir():
|
|
47
|
+
for entry in root.iterdir():
|
|
48
|
+
if entry.is_dir() and entry.name.endswith(('.dist-info', '.egg-info')):
|
|
49
|
+
distribution = md.PathDistribution(entry)
|
|
50
|
+
key = (re.sub(r'[-_.]+', '-', distribution.metadata.get('Name', '')).lower(), distribution.version)
|
|
51
|
+
for filename in ('METADATA', 'PKG-INFO'):
|
|
52
|
+
if (entry / filename).is_file():
|
|
53
|
+
metadata_paths[key] = entry / filename
|
|
54
|
+
break
|
|
55
|
+
for d in md.distributions(path=environment_paths):
|
|
56
|
+
name = d.metadata.get('Name')
|
|
57
|
+
if not name:
|
|
58
|
+
raise RuntimeError('Installed distribution has no Name metadata')
|
|
59
|
+
direct = d.read_text('direct_url.json')
|
|
60
|
+
expected = re.sub(r'[-_.]+', '-', name + '-' + d.version + '.dist-info').lower()
|
|
61
|
+
metadata_file = next((d.locate_file(f) for f in d.files or [] if f.name == 'METADATA'
|
|
62
|
+
and re.sub(r'[-_.]+', '-', f.parent.name).lower() == expected), None)
|
|
63
|
+
if metadata_file is None:
|
|
64
|
+
metadata_file = metadata_paths.get((re.sub(r'[-_.]+', '-', name).lower(), d.version))
|
|
65
|
+
packages.append(dict(name=name, version=d.version,
|
|
66
|
+
direct_url=json.loads(direct) if direct else None,
|
|
67
|
+
requires=d.requires or [], installer=(d.read_text('INSTALLER') or '').strip(),
|
|
68
|
+
metadata_path=str(metadata_file) if metadata_file else None))
|
|
69
|
+
site = Path(sysconfig.get_path('purelib'))
|
|
70
|
+
if list(site.glob('*.egg-link')):
|
|
71
|
+
raise RuntimeError('Legacy egg-link editables are unsupported; reinstall them using PEP 660 first')
|
|
72
|
+
impl = sys.implementation.version
|
|
73
|
+
iv = '.'.join(str(x) for x in impl[:3])
|
|
74
|
+
if impl.releaselevel != 'final':
|
|
75
|
+
iv += impl.releaselevel[0] + str(impl.serial)
|
|
76
|
+
markers = dict(implementation_name=sys.implementation.name, implementation_version=iv,
|
|
77
|
+
os_name=os.name, platform_machine=platform.machine(), platform_release=platform.release(),
|
|
78
|
+
platform_system=platform.system(), platform_version=platform.version(),
|
|
79
|
+
python_full_version=platform.python_version(), platform_python_implementation=platform.python_implementation(),
|
|
80
|
+
python_version='.'.join(platform.python_version_tuple()[:2]), sys_platform=sys.platform)
|
|
81
|
+
print(json.dumps(dict(python=platform.python_version(), prefix=sys.prefix,
|
|
82
|
+
base_prefix=sys.base_prefix, executable=sys.executable, markers=markers,
|
|
83
|
+
site_packages=str(site), packages=packages)))
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def interpreter(path: Path) -> Path:
|
|
88
|
+
return path / "bin" / "python"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def inspect(path: Path) -> dict:
|
|
92
|
+
result = run([str(interpreter(path)), "-I", "-B", "-c", INSPECT])
|
|
93
|
+
try:
|
|
94
|
+
data = json.loads(result.stdout)
|
|
95
|
+
names = [canonicalize_name(p["name"]) for p in data["packages"]]
|
|
96
|
+
if len(names) != len(set(names)):
|
|
97
|
+
raise ValueError("duplicate installed distributions")
|
|
98
|
+
if Path(data["prefix"]).resolve() != path.resolve():
|
|
99
|
+
raise ValueError("interpreter resolves to another environment")
|
|
100
|
+
for package in data["packages"]:
|
|
101
|
+
direct = package["direct_url"]
|
|
102
|
+
if direct:
|
|
103
|
+
url = urlsplit(direct["url"])
|
|
104
|
+
if url.username or url.password:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"credential-bearing source URL for {package['name']}; sanitize its metadata first"
|
|
107
|
+
)
|
|
108
|
+
data["packages"].sort(key=lambda p: canonicalize_name(p["name"]))
|
|
109
|
+
annotate_conda_ownership(path, data)
|
|
110
|
+
return data
|
|
111
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
112
|
+
raise AWMError(f"Cannot inspect environment {path}: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def annotate_conda_ownership(path: Path, snapshot: dict) -> None:
|
|
116
|
+
"""Conda builds may use pip and retain build-host URLs; INSTALLER alone is insufficient."""
|
|
117
|
+
owners = {}
|
|
118
|
+
for record_path in (path / "conda-meta").glob("*.json"):
|
|
119
|
+
record = json.loads(record_path.read_text())
|
|
120
|
+
hashes = {}
|
|
121
|
+
for entry in record.get("paths_data", {}).get("paths", []):
|
|
122
|
+
relative = entry["_path"]
|
|
123
|
+
if relative.startswith("site-packages/"):
|
|
124
|
+
relative = str(
|
|
125
|
+
Path(snapshot["site_packages"]).relative_to(path)
|
|
126
|
+
/ relative[len("site-packages/") :]
|
|
127
|
+
)
|
|
128
|
+
hashes[relative] = entry.get("sha256_in_prefix") or entry.get("sha256")
|
|
129
|
+
for filename in record.get("files", []):
|
|
130
|
+
if filename.endswith((".dist-info/METADATA", ".egg-info/PKG-INFO")):
|
|
131
|
+
owners[str(path / filename)] = (set(record["files"]), hashes)
|
|
132
|
+
for package in snapshot["packages"]:
|
|
133
|
+
package["conda_owned"] = False
|
|
134
|
+
metadata = package.get("metadata_path")
|
|
135
|
+
if not metadata or metadata not in owners:
|
|
136
|
+
continue
|
|
137
|
+
files, hashes = owners[metadata]
|
|
138
|
+
matched = True
|
|
139
|
+
for filename in (Path(metadata).name, "INSTALLER", "direct_url.json"):
|
|
140
|
+
current = Path(metadata).parent / filename
|
|
141
|
+
relative = str(current.relative_to(path))
|
|
142
|
+
if current.exists() != (relative in files):
|
|
143
|
+
matched = False
|
|
144
|
+
break
|
|
145
|
+
expected = hashes.get(relative)
|
|
146
|
+
if (
|
|
147
|
+
current.exists()
|
|
148
|
+
and expected
|
|
149
|
+
and hashlib.sha256(current.read_bytes()).hexdigest() != expected
|
|
150
|
+
):
|
|
151
|
+
matched = False
|
|
152
|
+
break
|
|
153
|
+
package["conda_owned"] = matched
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def editable_path(package: dict) -> Path | None:
|
|
157
|
+
direct = package.get("direct_url") or {}
|
|
158
|
+
if not direct.get("dir_info", {}).get("editable"):
|
|
159
|
+
return None
|
|
160
|
+
url = urlsplit(direct["url"])
|
|
161
|
+
if url.scheme != "file" or url.netloc not in ("", "localhost"):
|
|
162
|
+
raise AWMError(f"Unsupported editable source: {package['name']}")
|
|
163
|
+
return Path(unquote(url.path)).resolve()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def stale_editables(snapshot: dict) -> list[tuple[str, str, str, Path]]:
|
|
167
|
+
"""Report editables whose recorded version no longer matches their source.
|
|
168
|
+
|
|
169
|
+
A reproduction reinstalls an editable from its source, so the sandbox gets the version
|
|
170
|
+
that source declares today rather than the one recorded here. Only statically declared
|
|
171
|
+
versions can be compared without a build; dynamic ones are left alone.
|
|
172
|
+
"""
|
|
173
|
+
stale = []
|
|
174
|
+
for package in snapshot["packages"]:
|
|
175
|
+
source = editable_path(package)
|
|
176
|
+
if source is None:
|
|
177
|
+
continue
|
|
178
|
+
declared = declared_version(source)
|
|
179
|
+
if declared is None or same_version(declared, package["version"]):
|
|
180
|
+
continue
|
|
181
|
+
stale.append((package["name"], package["version"], declared, source))
|
|
182
|
+
return stale
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def declared_version(source: Path) -> str | None:
|
|
186
|
+
try:
|
|
187
|
+
project = tomllib.loads((source / "pyproject.toml").read_text(encoding="utf-8"))
|
|
188
|
+
except (OSError, ValueError):
|
|
189
|
+
return None
|
|
190
|
+
version = project.get("project", {}).get("version")
|
|
191
|
+
return version if isinstance(version, str) else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def same_version(left: str, right: str) -> bool:
|
|
195
|
+
try:
|
|
196
|
+
return Version(left) == Version(right)
|
|
197
|
+
except InvalidVersion:
|
|
198
|
+
return left == right
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def requirement(package: dict) -> str:
|
|
202
|
+
direct = package.get("direct_url")
|
|
203
|
+
if not direct:
|
|
204
|
+
return f"{package['name']}=={package['version']}"
|
|
205
|
+
url = direct["url"]
|
|
206
|
+
vcs = direct.get("vcs_info")
|
|
207
|
+
if vcs:
|
|
208
|
+
commit = vcs.get("commit_id")
|
|
209
|
+
if not commit:
|
|
210
|
+
raise AWMError(f"Missing immutable VCS revision for {package['name']}")
|
|
211
|
+
url = f"{vcs['vcs']}+{url}@{commit}"
|
|
212
|
+
parsed = urlsplit(url)
|
|
213
|
+
fragments = []
|
|
214
|
+
if direct.get("subdirectory"):
|
|
215
|
+
from urllib.parse import quote
|
|
216
|
+
|
|
217
|
+
fragments.append("subdirectory=" + quote(direct["subdirectory"], safe="/"))
|
|
218
|
+
hashes = direct.get("archive_info", {}).get("hashes", {})
|
|
219
|
+
if "sha256" in hashes:
|
|
220
|
+
fragments.append("sha256=" + hashes["sha256"])
|
|
221
|
+
if fragments:
|
|
222
|
+
url = urlunsplit(parsed._replace(fragment="&".join(fragments)))
|
|
223
|
+
if parsed.scheme == "file" and not Path(unquote(parsed.path)).exists():
|
|
224
|
+
raise AWMError(f"Source artifact is unavailable for {package['name']}: {parsed.path}")
|
|
225
|
+
return f"{package['name']} @ {url}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def dependency_errors(snapshot: dict, extras: dict[str, set[str]] | None = None) -> list[str]:
|
|
229
|
+
packages = {canonicalize_name(p["name"]): p for p in snapshot["packages"]}
|
|
230
|
+
errors = []
|
|
231
|
+
for name, package in packages.items():
|
|
232
|
+
for text in package["requires"]:
|
|
233
|
+
try:
|
|
234
|
+
req = Requirement(text)
|
|
235
|
+
except InvalidRequirement as exc:
|
|
236
|
+
errors.append(f"{name}: invalid dependency metadata: {exc}")
|
|
237
|
+
continue
|
|
238
|
+
active_extras = {"", *(extras or {}).get(name, set())}
|
|
239
|
+
if req.marker and not any(
|
|
240
|
+
req.marker.evaluate({**snapshot["markers"], "extra": extra})
|
|
241
|
+
for extra in active_extras
|
|
242
|
+
):
|
|
243
|
+
continue
|
|
244
|
+
installed = packages.get(canonicalize_name(req.name))
|
|
245
|
+
if installed is None or (
|
|
246
|
+
req.specifier and not req.specifier.contains(installed["version"], prereleases=True)
|
|
247
|
+
):
|
|
248
|
+
errors.append(
|
|
249
|
+
f"{name} requires {req}; installed: {installed['version'] if installed else 'missing'}"
|
|
250
|
+
)
|
|
251
|
+
return errors
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def executable(kind: str) -> str:
|
|
255
|
+
if kind == "venv":
|
|
256
|
+
return sys.executable
|
|
257
|
+
candidate = os.environ.get("CONDA_EXE") if kind == "conda" else None
|
|
258
|
+
result = candidate if candidate and os.access(candidate, os.X_OK) else shutil.which(kind)
|
|
259
|
+
if not result:
|
|
260
|
+
raise AWMError(
|
|
261
|
+
f"Selected backend '{kind}' is unavailable; install it or select another backend"
|
|
262
|
+
)
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def validate_base(path: Path, kind: str) -> dict:
|
|
267
|
+
executable(kind)
|
|
268
|
+
if kind == "conda":
|
|
269
|
+
if not (path / "conda-meta").is_dir():
|
|
270
|
+
raise AWMError(f"Not a conda environment: {path}")
|
|
271
|
+
else:
|
|
272
|
+
cfg = path / "pyvenv.cfg"
|
|
273
|
+
if not cfg.is_file():
|
|
274
|
+
raise AWMError("The base must be a dedicated virtual environment")
|
|
275
|
+
values = dict(
|
|
276
|
+
line.lower().split("=", 1) for line in cfg.read_text().splitlines() if "=" in line
|
|
277
|
+
)
|
|
278
|
+
if any(
|
|
279
|
+
key.strip() == "include-system-site-packages" and value.strip() == "true"
|
|
280
|
+
for key, value in values.items()
|
|
281
|
+
):
|
|
282
|
+
raise AWMError("Bases that inherit system site-packages cannot be reproduced safely")
|
|
283
|
+
snapshot = inspect(path)
|
|
284
|
+
errors = dependency_errors(snapshot)
|
|
285
|
+
if errors:
|
|
286
|
+
raise AWMError("Base environment has inconsistent dependencies:\n" + "\n".join(errors))
|
|
287
|
+
return snapshot
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def create_environment(kind: str, base: Path, target: Path) -> None:
|
|
291
|
+
if kind == "conda":
|
|
292
|
+
args = [
|
|
293
|
+
executable(kind),
|
|
294
|
+
"create",
|
|
295
|
+
"--yes",
|
|
296
|
+
"--copy",
|
|
297
|
+
"--clone",
|
|
298
|
+
str(base),
|
|
299
|
+
"--prefix",
|
|
300
|
+
str(target),
|
|
301
|
+
]
|
|
302
|
+
elif kind == "uv":
|
|
303
|
+
args = [
|
|
304
|
+
executable(kind),
|
|
305
|
+
"venv",
|
|
306
|
+
"--no-project",
|
|
307
|
+
"--no-python-downloads",
|
|
308
|
+
"--python",
|
|
309
|
+
str(interpreter(base)),
|
|
310
|
+
str(target),
|
|
311
|
+
]
|
|
312
|
+
else:
|
|
313
|
+
args = [str(interpreter(base)), "-I", "-B", "-m", "venv", "--without-pip", str(target)]
|
|
314
|
+
run(args, timeout=1800)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def install(kind: str, target: Path, packages: list[str], editables: list[str]) -> None:
|
|
318
|
+
if not packages and not editables:
|
|
319
|
+
return
|
|
320
|
+
if kind == "uv":
|
|
321
|
+
args = [
|
|
322
|
+
executable(kind),
|
|
323
|
+
"pip",
|
|
324
|
+
"install",
|
|
325
|
+
"--python",
|
|
326
|
+
str(interpreter(target)),
|
|
327
|
+
"--no-deps",
|
|
328
|
+
"--link-mode",
|
|
329
|
+
"copy",
|
|
330
|
+
]
|
|
331
|
+
else:
|
|
332
|
+
args = [
|
|
333
|
+
sys.executable,
|
|
334
|
+
"-I",
|
|
335
|
+
"-m",
|
|
336
|
+
"pip",
|
|
337
|
+
"--python",
|
|
338
|
+
str(interpreter(target)),
|
|
339
|
+
"install",
|
|
340
|
+
"--no-deps",
|
|
341
|
+
"--disable-pip-version-check",
|
|
342
|
+
]
|
|
343
|
+
if kind == "conda":
|
|
344
|
+
args.append("--force-reinstall") # repair entry points of pip packages copied by conda
|
|
345
|
+
for path in editables:
|
|
346
|
+
args.extend(["--editable", path])
|
|
347
|
+
args.extend(packages)
|
|
348
|
+
environment = clean_env()
|
|
349
|
+
# Installer destination defaults must not redirect a write into another environment.
|
|
350
|
+
for key in ("PIP_TARGET", "PIP_PREFIX", "PIP_ROOT", "PIP_USER", "PIP_PYTHON", "PIP_LOG"):
|
|
351
|
+
environment.pop(key, None)
|
|
352
|
+
environment["PIP_CONFIG_FILE"] = os.devnull
|
|
353
|
+
run(args, timeout=1800, env=environment)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def conda_environments() -> list[Env]:
|
|
357
|
+
data = json.loads(run([executable("conda"), "env", "list", "--json"]).stdout)
|
|
358
|
+
return [Env(Path(p).name, str(Path(p).resolve()), "conda") for p in data.get("envs", [])]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def remove_environment(kind: str, path: Path) -> None:
|
|
362
|
+
if kind == "conda":
|
|
363
|
+
run([executable(kind), "env", "remove", "--yes", "--prefix", str(path)], timeout=1800)
|
|
364
|
+
# Conda may leave untracked files. Do not claim a successful cleanup of them.
|
|
365
|
+
if path.exists():
|
|
366
|
+
if any(path.iterdir()):
|
|
367
|
+
raise AWMError(f"Conda left files at {path}; inspect them before retrying cleanup")
|
|
368
|
+
path.rmdir()
|
|
369
|
+
else:
|
|
370
|
+
shutil.rmtree(path)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def run_environment(kind: str, path: Path, args: list[str], cwd: Path) -> int:
|
|
374
|
+
import subprocess
|
|
375
|
+
|
|
376
|
+
env = clean_env()
|
|
377
|
+
env["PATH"] = str(path / "bin") + os.pathsep + env.get("PATH", "")
|
|
378
|
+
if kind == "conda":
|
|
379
|
+
args = [executable(kind), "run", "--no-capture-output", "--prefix", str(path), *args]
|
|
380
|
+
else:
|
|
381
|
+
env["VIRTUAL_ENV"] = str(path)
|
|
382
|
+
try:
|
|
383
|
+
return subprocess.run(args, cwd=cwd, env=env).returncode
|
|
384
|
+
except OSError as exc:
|
|
385
|
+
raise AWMError(f"Cannot run command: {exc}") from exc
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Git metadata and operations; no workspace naming conventions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .errors import AWMError
|
|
9
|
+
from .process import run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Worktree:
|
|
14
|
+
repo: Path
|
|
15
|
+
path: Path
|
|
16
|
+
branch: str | None
|
|
17
|
+
prunable: bool = False
|
|
18
|
+
locked: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def git(repo: Path, *args: str, check: bool = False):
|
|
22
|
+
return run(["git", "-C", str(repo), *args], check=check)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def common_dir(repo: Path) -> Path:
|
|
26
|
+
result = git(repo, "rev-parse", "--git-common-dir", check=True)
|
|
27
|
+
return (repo / result.stdout.strip()).resolve()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def worktrees_of(repo: Path, *, include_primary: bool = False) -> list[Worktree]:
|
|
31
|
+
result = git(repo, "worktree", "list", "--porcelain", "-z", check=True)
|
|
32
|
+
records: list[dict] = []
|
|
33
|
+
current: dict = {}
|
|
34
|
+
for field in result.stdout.split("\0"):
|
|
35
|
+
if not field:
|
|
36
|
+
if current:
|
|
37
|
+
records.append(current)
|
|
38
|
+
current = {}
|
|
39
|
+
else:
|
|
40
|
+
key, _, value = field.partition(" ")
|
|
41
|
+
current[key] = value
|
|
42
|
+
if current:
|
|
43
|
+
records.append(current)
|
|
44
|
+
output = []
|
|
45
|
+
for record in records:
|
|
46
|
+
if "bare" in record:
|
|
47
|
+
continue
|
|
48
|
+
path = Path(record["worktree"])
|
|
49
|
+
if not include_primary and path.resolve() == repo.resolve():
|
|
50
|
+
continue
|
|
51
|
+
branch = record.get("branch")
|
|
52
|
+
output.append(
|
|
53
|
+
Worktree(
|
|
54
|
+
repo,
|
|
55
|
+
path,
|
|
56
|
+
branch.removeprefix("refs/heads/") if branch else None,
|
|
57
|
+
"prunable" in record or not path.exists(),
|
|
58
|
+
"locked" in record,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return output
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_dirty(path: Path) -> bool:
|
|
65
|
+
return bool(git(path, "status", "--porcelain", "--untracked-files=all", check=True).stdout)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_orphan_worktree_dir(path: Path, repo: Path) -> bool:
|
|
69
|
+
gitfile = path / ".git"
|
|
70
|
+
if path.is_symlink() or not gitfile.is_file() or gitfile.is_symlink():
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
text = gitfile.read_text(encoding="utf-8").strip()
|
|
74
|
+
if not text.startswith("gitdir: "):
|
|
75
|
+
return False
|
|
76
|
+
target = (path / text[8:]).resolve()
|
|
77
|
+
return target.is_relative_to(common_dir(repo) / "worktrees")
|
|
78
|
+
except (OSError, AWMError):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_repo(path: Path) -> None:
|
|
83
|
+
if not path.is_dir() or git(path, "rev-parse", "--show-toplevel").returncode:
|
|
84
|
+
raise AWMError(f"Not a Git checkout: {path}")
|
|
85
|
+
top = Path(git(path, "rev-parse", "--show-toplevel", check=True).stdout.strip()).resolve()
|
|
86
|
+
if top != path.resolve():
|
|
87
|
+
raise AWMError(f"Configure the repository root {top}, not {path}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_new_branch(repo: Path, branch: str, ref: str) -> str:
|
|
91
|
+
git(repo, "check-ref-format", "--branch", branch, check=True)
|
|
92
|
+
if git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}").returncode == 0:
|
|
93
|
+
raise AWMError(f"Branch already exists in {repo}: {branch}")
|
|
94
|
+
return git(
|
|
95
|
+
repo, "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}", check=True
|
|
96
|
+
).stdout.strip()
|