devagent-ai 0.3.1__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/__init__.py +5 -0
- agent/llm.py +6 -0
- agent/loop.py +16 -0
- agent/memory.py +5 -0
- agent/prompts.py +3 -0
- agent/tools.py +6 -0
- devagent/__init__.py +3 -0
- devagent/__main__.py +4 -0
- devagent/artifacts.py +46 -0
- devagent/cli.py +164 -0
- devagent/config.py +72 -0
- devagent/discovery.py +504 -0
- devagent/evaluation.py +388 -0
- devagent/memory.py +51 -0
- devagent/models.py +251 -0
- devagent/orchestrator.py +887 -0
- devagent/providers.py +344 -0
- devagent/report.py +52 -0
- devagent/retrieval.py +478 -0
- devagent/safety.py +131 -0
- devagent/state_machine.py +45 -0
- devagent/tasking.py +83 -0
- devagent/workspace.py +199 -0
- devagent/worktree.py +151 -0
- devagent_ai-0.3.1.dist-info/METADATA +415 -0
- devagent_ai-0.3.1.dist-info/RECORD +31 -0
- devagent_ai-0.3.1.dist-info/WHEEL +5 -0
- devagent_ai-0.3.1.dist-info/entry_points.txt +2 -0
- devagent_ai-0.3.1.dist-info/licenses/LICENSE +21 -0
- devagent_ai-0.3.1.dist-info/licenses/NOTICE +10 -0
- devagent_ai-0.3.1.dist-info/top_level.txt +2 -0
devagent/tasking.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from devagent.models import (
|
|
6
|
+
AcceptanceCriterion,
|
|
7
|
+
RiskLevel,
|
|
8
|
+
TaskSpec,
|
|
9
|
+
TaskType,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_CLASSIFIERS: tuple[tuple[TaskType, tuple[str, ...]], ...] = (
|
|
14
|
+
(TaskType.BUILD_FAILURE, ("build fail", "compile error", "won't build", "does not build")),
|
|
15
|
+
(TaskType.TEST_FAILURE, ("test fail", "failing test", "pytest error")),
|
|
16
|
+
(TaskType.RUNTIME_ERROR, ("traceback", "exception", "runtime error", "crash")),
|
|
17
|
+
(TaskType.PERFORMANCE, ("performance", "optimize", "slow", "latency", "n+1")),
|
|
18
|
+
(TaskType.REFACTOR, ("refactor", "restructure", "cleanup")),
|
|
19
|
+
(TaskType.UNIT_TEST, ("add unit test", "write tests", "test coverage")),
|
|
20
|
+
(TaskType.BUG_FIX, ("fix", "bug", "incorrect", "broken", "regression")),
|
|
21
|
+
(TaskType.FEATURE, ("add ", "implement", "support ", "feature")),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_HIGH_RISK = {
|
|
25
|
+
"auth",
|
|
26
|
+
"authentication",
|
|
27
|
+
"authorization",
|
|
28
|
+
"permission",
|
|
29
|
+
"payment",
|
|
30
|
+
"migration",
|
|
31
|
+
"crypto",
|
|
32
|
+
"credential",
|
|
33
|
+
"public api",
|
|
34
|
+
"concurrency",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _classify(text: str) -> TaskType:
|
|
39
|
+
lowered = text.lower()
|
|
40
|
+
for task_type, needles in _CLASSIFIERS:
|
|
41
|
+
if any(needle in lowered for needle in needles):
|
|
42
|
+
return task_type
|
|
43
|
+
return TaskType.GENERAL_ENGINEERING_TASK
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _risk(text: str, task_type: TaskType) -> RiskLevel:
|
|
47
|
+
lowered = text.lower()
|
|
48
|
+
if any(term in lowered for term in _HIGH_RISK):
|
|
49
|
+
return RiskLevel.HIGH
|
|
50
|
+
if task_type in {TaskType.FEATURE, TaskType.PERFORMANCE, TaskType.REFACTOR}:
|
|
51
|
+
return RiskLevel.MEDIUM
|
|
52
|
+
return RiskLevel.LOW
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compile_task(requirement: str) -> TaskSpec:
|
|
56
|
+
goal = re.sub(r"\s+", " ", requirement).strip()
|
|
57
|
+
if not goal:
|
|
58
|
+
raise ValueError("Engineering requirement cannot be empty")
|
|
59
|
+
task_type = _classify(goal)
|
|
60
|
+
code_change = task_type is not TaskType.UNIT_TEST or "only" not in goal.lower()
|
|
61
|
+
requires_tests = task_type not in {TaskType.BUILD_FAILURE, TaskType.REFACTOR}
|
|
62
|
+
|
|
63
|
+
criteria = [AcceptanceCriterion(goal), AcceptanceCriterion("No unrelated behavior changes")]
|
|
64
|
+
if task_type in {TaskType.BUG_FIX, TaskType.RUNTIME_ERROR, TaskType.TEST_FAILURE}:
|
|
65
|
+
criteria.insert(1, AcceptanceCriterion("The root cause is addressed, not merely masked"))
|
|
66
|
+
criteria.insert(2, AcceptanceCriterion("Regression coverage exercises the failing case"))
|
|
67
|
+
elif task_type is TaskType.FEATURE:
|
|
68
|
+
criteria.insert(1, AcceptanceCriterion("The feature follows existing repository conventions"))
|
|
69
|
+
criteria.insert(2, AcceptanceCriterion("Normal and relevant edge-case behavior are covered"))
|
|
70
|
+
elif task_type is TaskType.UNIT_TEST:
|
|
71
|
+
criteria.insert(1, AcceptanceCriterion("Tests assert externally meaningful behavior"))
|
|
72
|
+
if requires_tests:
|
|
73
|
+
criteria.append(AcceptanceCriterion("Relevant automated tests pass"))
|
|
74
|
+
criteria.append(AcceptanceCriterion("Final diff and independent review pass"))
|
|
75
|
+
|
|
76
|
+
return TaskSpec(
|
|
77
|
+
task_type=task_type,
|
|
78
|
+
goal=goal,
|
|
79
|
+
requires_code_change=code_change,
|
|
80
|
+
requires_tests=requires_tests,
|
|
81
|
+
acceptance_criteria=criteria,
|
|
82
|
+
risk=_risk(goal, task_type),
|
|
83
|
+
)
|
devagent/workspace.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from devagent.artifacts import RunArtifacts
|
|
12
|
+
from devagent.models import FailureClass, VerificationResult
|
|
13
|
+
from devagent.safety import CommandPolicy, PathPolicy, SKIP_DIRECTORIES, SafetyError, is_secret_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Workspace:
|
|
17
|
+
def __init__(self, root: Path | str, artifacts: RunArtifacts, dirty_files: Sequence[str] = ()) -> None:
|
|
18
|
+
self.root = Path(root).resolve()
|
|
19
|
+
self.paths = PathPolicy(self.root)
|
|
20
|
+
self.artifacts = artifacts
|
|
21
|
+
self.dirty_files = frozenset(dirty_files)
|
|
22
|
+
self.revision = 0
|
|
23
|
+
self.modified_paths: set[str] = set()
|
|
24
|
+
|
|
25
|
+
def _relative(self, target: Path) -> str:
|
|
26
|
+
return target.relative_to(self.root).as_posix()
|
|
27
|
+
|
|
28
|
+
def _writable(self, relative: str) -> Path:
|
|
29
|
+
target = self.paths.resolve(relative)
|
|
30
|
+
normalized = self._relative(target)
|
|
31
|
+
if normalized in self.dirty_files:
|
|
32
|
+
raise SafetyError(f"Refusing to overwrite pre-existing developer modification: {normalized}")
|
|
33
|
+
return target
|
|
34
|
+
|
|
35
|
+
def list_files(self, path: str = ".", pattern: str = "*", limit: int = 250) -> list[str]:
|
|
36
|
+
base = self.paths.resolve(path, allow_missing=False)
|
|
37
|
+
candidates = [base] if base.is_file() else base.rglob("*")
|
|
38
|
+
results: list[str] = []
|
|
39
|
+
for candidate in candidates:
|
|
40
|
+
if len(results) >= min(limit, 12_000):
|
|
41
|
+
break
|
|
42
|
+
relative = candidate.relative_to(self.root)
|
|
43
|
+
try:
|
|
44
|
+
resolved = candidate.resolve()
|
|
45
|
+
resolved_relative = resolved.relative_to(self.root)
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
continue
|
|
48
|
+
if (
|
|
49
|
+
not resolved.is_file()
|
|
50
|
+
or any(part in SKIP_DIRECTORIES for part in relative.parts)
|
|
51
|
+
or is_secret_path(relative)
|
|
52
|
+
or is_secret_path(resolved_relative)
|
|
53
|
+
):
|
|
54
|
+
continue
|
|
55
|
+
rendered = relative.as_posix()
|
|
56
|
+
if fnmatch.fnmatch(candidate.name, pattern) or fnmatch.fnmatch(rendered, pattern):
|
|
57
|
+
results.append(rendered)
|
|
58
|
+
return results
|
|
59
|
+
|
|
60
|
+
def read_file(self, path: str, max_chars: int = 60_000) -> str:
|
|
61
|
+
target = self.paths.resolve(path, allow_missing=False)
|
|
62
|
+
if not target.is_file() or target.stat().st_size > 2_000_000:
|
|
63
|
+
raise SafetyError(f"File is not safe bounded text: {path}")
|
|
64
|
+
try:
|
|
65
|
+
text = target.read_text(encoding="utf-8")
|
|
66
|
+
except UnicodeDecodeError as exc:
|
|
67
|
+
raise SafetyError(f"File is not UTF-8 text: {path}") from exc
|
|
68
|
+
return text[:max_chars] + (f"\n... truncated at {max_chars} characters" if len(text) > max_chars else "")
|
|
69
|
+
|
|
70
|
+
def search_text(self, query: str, path: str = ".", limit: int = 100) -> list[str]:
|
|
71
|
+
if not query.strip():
|
|
72
|
+
raise SafetyError("Search query cannot be empty")
|
|
73
|
+
matches: list[str] = []
|
|
74
|
+
for relative in self.list_files(path, "*", limit=1000):
|
|
75
|
+
try:
|
|
76
|
+
target = self.paths.resolve(relative, allow_missing=False)
|
|
77
|
+
except SafetyError:
|
|
78
|
+
continue
|
|
79
|
+
if target.stat().st_size > 2_000_000:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
lines = target.read_text(encoding="utf-8").splitlines()
|
|
83
|
+
except (OSError, UnicodeDecodeError):
|
|
84
|
+
continue
|
|
85
|
+
for line_number, line in enumerate(lines, 1):
|
|
86
|
+
if query.casefold() in line.casefold():
|
|
87
|
+
matches.append(f"{relative}:{line_number}: {line[:500]}")
|
|
88
|
+
if len(matches) >= limit:
|
|
89
|
+
return matches
|
|
90
|
+
return matches
|
|
91
|
+
|
|
92
|
+
def write_file(self, path: str, content: str) -> None:
|
|
93
|
+
target = self._writable(path)
|
|
94
|
+
if target.exists() and not target.is_file():
|
|
95
|
+
raise SafetyError(f"Write target is not a file: {path}")
|
|
96
|
+
self.artifacts.backup(target, relative_to=self.root)
|
|
97
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
target.write_text(content, encoding="utf-8")
|
|
99
|
+
self.revision += 1
|
|
100
|
+
self.modified_paths.add(self._relative(target))
|
|
101
|
+
self.artifacts.record("file_written", path=path, revision=self.revision)
|
|
102
|
+
|
|
103
|
+
def replace_text(self, path: str, old: str, new: str, count: int = 1) -> None:
|
|
104
|
+
if not old or count < 1:
|
|
105
|
+
raise SafetyError("replace_text requires non-empty old text and count >= 1")
|
|
106
|
+
target = self._writable(path)
|
|
107
|
+
if not target.is_file():
|
|
108
|
+
raise SafetyError(f"Replace target is not a file: {path}")
|
|
109
|
+
source = target.read_text(encoding="utf-8")
|
|
110
|
+
occurrences = source.count(old)
|
|
111
|
+
if occurrences < count:
|
|
112
|
+
raise SafetyError(f"Expected {count} exact occurrence(s), found {occurrences}: {path}")
|
|
113
|
+
self.artifacts.backup(target, relative_to=self.root)
|
|
114
|
+
target.write_text(source.replace(old, new, count), encoding="utf-8")
|
|
115
|
+
self.revision += 1
|
|
116
|
+
self.modified_paths.add(self._relative(target))
|
|
117
|
+
self.artifacts.record("text_replaced", path=path, count=count, revision=self.revision)
|
|
118
|
+
|
|
119
|
+
def run(self, command: str | Sequence[str], *, timeout: int = 300, phase: str, baseline: bool = False) -> VerificationResult:
|
|
120
|
+
argv = CommandPolicy.validate(command)
|
|
121
|
+
timeout = max(1, min(int(timeout), 1800))
|
|
122
|
+
started = time.monotonic()
|
|
123
|
+
timed_out = False
|
|
124
|
+
sandbox_home = self.artifacts.root / "command-home"
|
|
125
|
+
sandbox_home.mkdir(exist_ok=True)
|
|
126
|
+
environment = {
|
|
127
|
+
key: value
|
|
128
|
+
for key, value in os.environ.items()
|
|
129
|
+
if key in {"PATH", "LANG", "LC_ALL", "TERM", "TMPDIR", "VIRTUAL_ENV", "PYTHONPATH", "SYSTEMROOT", "WINDIR"}
|
|
130
|
+
}
|
|
131
|
+
environment.update({"HOME": str(sandbox_home), "CI": "true", "DEVAGENT_RUN_ID": self.artifacts.run_id})
|
|
132
|
+
try:
|
|
133
|
+
completed = subprocess.run(
|
|
134
|
+
argv, cwd=self.root, env=environment, capture_output=True, text=True, timeout=timeout, check=False
|
|
135
|
+
)
|
|
136
|
+
exit_code: int | None = completed.returncode
|
|
137
|
+
stdout, stderr = completed.stdout[-24_000:], completed.stderr[-24_000:]
|
|
138
|
+
except subprocess.TimeoutExpired as exc:
|
|
139
|
+
timed_out = True
|
|
140
|
+
exit_code = None
|
|
141
|
+
stdout = (exc.stdout or "")[-24_000:] if isinstance(exc.stdout, str) else ""
|
|
142
|
+
stderr = (exc.stderr or "")[-24_000:] if isinstance(exc.stderr, str) else ""
|
|
143
|
+
duration = time.monotonic() - started
|
|
144
|
+
classification = None if exit_code == 0 else classify_failure(stdout, stderr, timed_out)
|
|
145
|
+
tests_run, tests_passed = _test_counts(stdout, stderr, exit_code)
|
|
146
|
+
result = VerificationResult(
|
|
147
|
+
command=argv,
|
|
148
|
+
exit_code=exit_code,
|
|
149
|
+
duration_seconds=duration,
|
|
150
|
+
stdout=stdout,
|
|
151
|
+
stderr=stderr,
|
|
152
|
+
classification=classification,
|
|
153
|
+
revision=self.revision,
|
|
154
|
+
phase=phase,
|
|
155
|
+
timed_out=timed_out,
|
|
156
|
+
baseline=baseline,
|
|
157
|
+
tests_run=tests_run,
|
|
158
|
+
tests_passed=tests_passed,
|
|
159
|
+
)
|
|
160
|
+
self.artifacts.record("command_finished", result=result)
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _test_counts(stdout: str, stderr: str, exit_code: int | None) -> tuple[int | None, int | None]:
|
|
165
|
+
text = f"{stdout}\n{stderr}"
|
|
166
|
+
summary = "\n".join(text.splitlines()[-8:])
|
|
167
|
+
counts: dict[str, int] = {}
|
|
168
|
+
for value, status in re.findall(
|
|
169
|
+
r"(\d+)\s+(passed|failed|errors?|skipped|xfailed|xpassed)(?:\b|$)", summary, re.IGNORECASE
|
|
170
|
+
):
|
|
171
|
+
normalized = "error" if status.casefold().startswith("error") else status.casefold()
|
|
172
|
+
counts[normalized] = max(counts.get(normalized, 0), int(value))
|
|
173
|
+
if counts:
|
|
174
|
+
tests_run = sum(counts.get(name, 0) for name in ("passed", "failed", "error", "skipped", "xfailed", "xpassed"))
|
|
175
|
+
return tests_run, counts.get("passed", 0)
|
|
176
|
+
unittest_match = re.search(r"Ran\s+(\d+)\s+tests?", text)
|
|
177
|
+
if unittest_match:
|
|
178
|
+
tests_run = int(unittest_match.group(1))
|
|
179
|
+
return tests_run, tests_run if exit_code == 0 else None
|
|
180
|
+
return None, None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def classify_failure(stdout: str, stderr: str, timed_out: bool = False) -> FailureClass:
|
|
184
|
+
if timed_out:
|
|
185
|
+
return FailureClass.TIMEOUT
|
|
186
|
+
text = f"{stdout}\n{stderr}".lower()
|
|
187
|
+
patterns = (
|
|
188
|
+
(FailureClass.SYNTAX_ERROR, ("syntaxerror", "syntax error")),
|
|
189
|
+
(FailureClass.IMPORT_ERROR, ("importerror", "modulenotfounderror", "cannot find module", "no module named")),
|
|
190
|
+
(FailureClass.TYPE_ERROR, ("typeerror", "type error", "mypy")),
|
|
191
|
+
(FailureClass.ASSERTION_FAILURE, ("assertionerror", "assertion failed", " failed")),
|
|
192
|
+
(FailureClass.DEPENDENCY_ERROR, ("command not found", "could not resolve", "package is not installed")),
|
|
193
|
+
(FailureClass.BUILD_ERROR, ("build failed", "compilation failed", "linker")),
|
|
194
|
+
(FailureClass.ENVIRONMENT_ERROR, ("permission denied", "connection refused", "credentials", "not installed")),
|
|
195
|
+
)
|
|
196
|
+
for classification, needles in patterns:
|
|
197
|
+
if any(needle in text for needle in needles):
|
|
198
|
+
return classification
|
|
199
|
+
return FailureClass.UNKNOWN
|
devagent/worktree.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class WorktreeSelection:
|
|
13
|
+
root: Path
|
|
14
|
+
isolated: bool
|
|
15
|
+
reason: str
|
|
16
|
+
creation_failed: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _repository_identifier(source_root: Path) -> str:
|
|
20
|
+
identity = str(source_root.resolve()).encode("utf-8", errors="surrogatepass")
|
|
21
|
+
return "repo-" + hashlib.sha256(identity).hexdigest()[:20]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _state_root() -> Path:
|
|
25
|
+
configured = os.environ.get("XDG_STATE_HOME")
|
|
26
|
+
if configured:
|
|
27
|
+
return Path(configured).expanduser().resolve()
|
|
28
|
+
return (Path.home() / ".local" / "state").resolve()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _active_worktrees(source_root: Path) -> tuple[list[Path], str | None]:
|
|
32
|
+
try:
|
|
33
|
+
completed = subprocess.run(
|
|
34
|
+
["git", "worktree", "list", "--porcelain"],
|
|
35
|
+
cwd=source_root,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
timeout=15,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
42
|
+
return [], str(exc)
|
|
43
|
+
if completed.returncode != 0:
|
|
44
|
+
reason = completed.stderr.strip() or completed.stdout.strip() or "git worktree list failed"
|
|
45
|
+
return [], reason
|
|
46
|
+
return (
|
|
47
|
+
[
|
|
48
|
+
Path(line.removeprefix("worktree ")).resolve()
|
|
49
|
+
for line in completed.stdout.splitlines()
|
|
50
|
+
if line.startswith("worktree ")
|
|
51
|
+
],
|
|
52
|
+
None,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def select_worktree(
|
|
57
|
+
source_root: Path,
|
|
58
|
+
run_id: str,
|
|
59
|
+
*,
|
|
60
|
+
enabled: bool,
|
|
61
|
+
git_head: str | None,
|
|
62
|
+
dirty_files: list[str],
|
|
63
|
+
state_root: Path | None = None,
|
|
64
|
+
) -> WorktreeSelection:
|
|
65
|
+
"""Create a retained detached worktree outside every active Git worktree."""
|
|
66
|
+
source_root = source_root.expanduser().resolve()
|
|
67
|
+
if not enabled:
|
|
68
|
+
return WorktreeSelection(source_root, False, "isolation disabled by caller")
|
|
69
|
+
if not git_head:
|
|
70
|
+
return WorktreeSelection(source_root, False, "repository is not a Git worktree")
|
|
71
|
+
if dirty_files:
|
|
72
|
+
return WorktreeSelection(
|
|
73
|
+
source_root,
|
|
74
|
+
False,
|
|
75
|
+
"repository has pre-existing changes; dirty files remain protected",
|
|
76
|
+
)
|
|
77
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", run_id):
|
|
78
|
+
return WorktreeSelection(
|
|
79
|
+
source_root,
|
|
80
|
+
False,
|
|
81
|
+
"isolation unavailable: run identifier is not a safe path segment",
|
|
82
|
+
True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
base = (state_root or _state_root()).expanduser().resolve()
|
|
86
|
+
destination = (
|
|
87
|
+
base / "devagent" / "worktrees" / _repository_identifier(source_root) / run_id
|
|
88
|
+
).resolve()
|
|
89
|
+
active_worktrees, enumeration_error = _active_worktrees(source_root)
|
|
90
|
+
if enumeration_error is not None:
|
|
91
|
+
return WorktreeSelection(
|
|
92
|
+
source_root,
|
|
93
|
+
False,
|
|
94
|
+
f"isolation unavailable: cannot enumerate active worktrees: {enumeration_error}",
|
|
95
|
+
True,
|
|
96
|
+
)
|
|
97
|
+
if any(
|
|
98
|
+
destination == active or destination.is_relative_to(active)
|
|
99
|
+
for active in active_worktrees
|
|
100
|
+
):
|
|
101
|
+
return WorktreeSelection(
|
|
102
|
+
source_root,
|
|
103
|
+
False,
|
|
104
|
+
"isolation unavailable: retained worktree destination is inside an active worktree",
|
|
105
|
+
True,
|
|
106
|
+
)
|
|
107
|
+
if destination.exists():
|
|
108
|
+
return WorktreeSelection(
|
|
109
|
+
source_root,
|
|
110
|
+
False,
|
|
111
|
+
"isolation unavailable: retained worktree destination already exists",
|
|
112
|
+
True,
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
return WorktreeSelection(
|
|
118
|
+
source_root,
|
|
119
|
+
False,
|
|
120
|
+
f"isolation unavailable: cannot create retained worktree parent: {exc}",
|
|
121
|
+
True,
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
completed = subprocess.run(
|
|
125
|
+
["git", "worktree", "add", "--detach", str(destination), git_head],
|
|
126
|
+
cwd=source_root,
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
timeout=30,
|
|
130
|
+
check=False,
|
|
131
|
+
)
|
|
132
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
133
|
+
return WorktreeSelection(
|
|
134
|
+
source_root,
|
|
135
|
+
False,
|
|
136
|
+
f"isolation unavailable: git worktree add failed safely: {exc}",
|
|
137
|
+
True,
|
|
138
|
+
)
|
|
139
|
+
if completed.returncode != 0:
|
|
140
|
+
reason = completed.stderr.strip() or completed.stdout.strip() or "git worktree add failed"
|
|
141
|
+
return WorktreeSelection(
|
|
142
|
+
source_root,
|
|
143
|
+
False,
|
|
144
|
+
f"isolation unavailable: {reason}",
|
|
145
|
+
True,
|
|
146
|
+
)
|
|
147
|
+
return WorktreeSelection(
|
|
148
|
+
destination,
|
|
149
|
+
True,
|
|
150
|
+
"clean Git HEAD isolated in a retained external detached worktree",
|
|
151
|
+
)
|