pcode 0.1.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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/task_worktrees.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Persistent isolated workers; results are integrated into their owning checkout only.
|
|
2
|
+
|
|
3
|
+
Records live in the shared Git directory. A parent lock serializes snapshots,
|
|
4
|
+
record transitions and merges across processes (and refuses concurrent callers).
|
|
5
|
+
Setup and execution deliberately belong to the caller, after create returns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import tempfile
|
|
17
|
+
import uuid
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import psutil
|
|
23
|
+
|
|
24
|
+
from pcode import worktree as wt
|
|
25
|
+
|
|
26
|
+
_STATUSES = {"running", "completed", "failed", "cancelled", "conflicted", "integrated", "discarded"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TaskRecord:
|
|
31
|
+
task_id: str
|
|
32
|
+
parent: str
|
|
33
|
+
parent_branch: str
|
|
34
|
+
base_commit: str
|
|
35
|
+
head_commit: str | None
|
|
36
|
+
branch: str
|
|
37
|
+
worktree: str
|
|
38
|
+
status: str
|
|
39
|
+
dirty: bool
|
|
40
|
+
summary: str = ""
|
|
41
|
+
pid: int = 0
|
|
42
|
+
process_started: float = 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _branch(path: Path) -> str:
|
|
46
|
+
result = wt._git(path, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
|
|
47
|
+
if result.returncode:
|
|
48
|
+
raise wt.WorktreeError(f"{path} has detached HEAD; check out the expected branch first")
|
|
49
|
+
return result.stdout.strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _head(path: Path) -> str:
|
|
53
|
+
return wt._git(path, "rev-parse", "HEAD").stdout.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dirty(path: Path, *, tracked: bool = False) -> bool:
|
|
57
|
+
args = ["status", "--porcelain", "--untracked-files=no" if tracked else "--untracked-files=all"]
|
|
58
|
+
return bool(wt._git(path, *args).stdout.strip())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _idle(path: Path) -> None:
|
|
62
|
+
for name in (
|
|
63
|
+
"MERGE_HEAD",
|
|
64
|
+
"rebase-merge",
|
|
65
|
+
"rebase-apply",
|
|
66
|
+
"CHERRY_PICK_HEAD",
|
|
67
|
+
"REVERT_HEAD",
|
|
68
|
+
"sequencer",
|
|
69
|
+
):
|
|
70
|
+
location = wt._git(path, "rev-parse", "--path-format=absolute", "--git-path", name)
|
|
71
|
+
if Path(location.stdout.strip()).exists():
|
|
72
|
+
raise wt.WorktreeError(f"Git operation in progress in {path}; finish or abort it first")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ancestor(repo: Path, older: str, newer: str) -> bool:
|
|
76
|
+
return wt._git(repo, "merge-base", "--is-ancestor", older, newer, check=False).returncode == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _alive(record: TaskRecord) -> bool:
|
|
80
|
+
try:
|
|
81
|
+
process = psutil.Process(record.pid)
|
|
82
|
+
return process.create_time() == record.process_started and process.is_running()
|
|
83
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TaskWorktrees:
|
|
88
|
+
def __init__(self, parent: Path):
|
|
89
|
+
self.parent = parent.resolve()
|
|
90
|
+
self.main = wt.main_checkout(self.parent)
|
|
91
|
+
if self.main is None:
|
|
92
|
+
raise wt.WorktreeError(f"{self.parent} is not a Git checkout")
|
|
93
|
+
# Metadata and locks also serve bare repositories and removed parents.
|
|
94
|
+
# Only create() requires a live checkout root.
|
|
95
|
+
common = wt._git(self.parent, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
96
|
+
self.directory = Path(common.stdout.strip()) / "pcode-tasks"
|
|
97
|
+
self.directory.mkdir(exist_ok=True)
|
|
98
|
+
|
|
99
|
+
@contextmanager
|
|
100
|
+
def _lock(self, *, wait: bool = False):
|
|
101
|
+
key = hashlib.sha256(str(self.parent).encode()).hexdigest()
|
|
102
|
+
with (self.directory / f"parent-{key}.lock").open("a") as lock:
|
|
103
|
+
try:
|
|
104
|
+
fcntl.flock(lock, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB))
|
|
105
|
+
except BlockingIOError as error:
|
|
106
|
+
raise wt.WorktreeError(
|
|
107
|
+
f"Another task operation is using parent {self.parent}; retry when it finishes"
|
|
108
|
+
) from error
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
113
|
+
|
|
114
|
+
def _path(self, task_id: str) -> Path:
|
|
115
|
+
if not re.fullmatch(r"[0-9a-f]{32}", task_id):
|
|
116
|
+
raise wt.WorktreeError("Invalid task id; use an id from the task list")
|
|
117
|
+
return self.directory / f"{task_id}.json"
|
|
118
|
+
|
|
119
|
+
def _read(self, task_id: str) -> TaskRecord:
|
|
120
|
+
path = self._path(task_id)
|
|
121
|
+
try:
|
|
122
|
+
if path.is_symlink():
|
|
123
|
+
raise ValueError("record is a symlink")
|
|
124
|
+
record = TaskRecord(**json.loads(path.read_text()))
|
|
125
|
+
expected = self.main / wt.WORKTREES_DIR / f"task-{task_id}"
|
|
126
|
+
if (
|
|
127
|
+
record.task_id != task_id
|
|
128
|
+
or record.branch != f"task-{task_id}"
|
|
129
|
+
or record.worktree != str(expected.resolve())
|
|
130
|
+
or expected.is_symlink()
|
|
131
|
+
or not Path(record.parent).is_absolute()
|
|
132
|
+
or record.status not in _STATUSES
|
|
133
|
+
or not re.fullmatch(r"[0-9a-f]{40,64}", record.base_commit)
|
|
134
|
+
or (
|
|
135
|
+
record.head_commit is not None
|
|
136
|
+
and not re.fullmatch(r"[0-9a-f]{40,64}", record.head_commit)
|
|
137
|
+
)
|
|
138
|
+
or wt._git(
|
|
139
|
+
self.main, "check-ref-format", f"refs/heads/{record.parent_branch}", check=False
|
|
140
|
+
).returncode
|
|
141
|
+
):
|
|
142
|
+
raise ValueError("invalid record paths, refs or status")
|
|
143
|
+
return record
|
|
144
|
+
except (OSError, TypeError, ValueError) as error:
|
|
145
|
+
raise wt.WorktreeError(
|
|
146
|
+
f"Cannot read task {task_id}: {error}; inspect {path}"
|
|
147
|
+
) from error
|
|
148
|
+
|
|
149
|
+
def _save(self, record: TaskRecord) -> None:
|
|
150
|
+
path = self._path(record.task_id)
|
|
151
|
+
fd, temporary = tempfile.mkstemp(prefix=".task-", dir=self.directory)
|
|
152
|
+
try:
|
|
153
|
+
with os.fdopen(fd, "w") as file:
|
|
154
|
+
json.dump(asdict(record), file, indent=2)
|
|
155
|
+
file.flush()
|
|
156
|
+
os.fsync(file.fileno())
|
|
157
|
+
os.replace(temporary, path)
|
|
158
|
+
finally:
|
|
159
|
+
Path(temporary).unlink(missing_ok=True)
|
|
160
|
+
|
|
161
|
+
def _owned(self, task_id: str) -> TaskRecord:
|
|
162
|
+
record = self._read(task_id)
|
|
163
|
+
if record.parent != str(self.parent):
|
|
164
|
+
raise wt.WorktreeError(
|
|
165
|
+
f"Task {task_id} belongs to parent {record.parent}, not {self.parent}"
|
|
166
|
+
)
|
|
167
|
+
if record.status == "running" and not _alive(record):
|
|
168
|
+
record.status = "failed"
|
|
169
|
+
record.summary = (
|
|
170
|
+
record.summary + "\nWorker interrupted: owning process exited; "
|
|
171
|
+
"partial work is preserved. Inspect before discarding."
|
|
172
|
+
).strip()
|
|
173
|
+
self._observe(record)
|
|
174
|
+
self._save(record)
|
|
175
|
+
return record
|
|
176
|
+
|
|
177
|
+
def _observe(self, record: TaskRecord) -> None:
|
|
178
|
+
path = Path(record.worktree)
|
|
179
|
+
# Observe these independently: even a missing HEAD must not hide dirty files.
|
|
180
|
+
errors = []
|
|
181
|
+
for field, operation in (("head_commit", _head), ("dirty", _dirty)):
|
|
182
|
+
try:
|
|
183
|
+
setattr(record, field, operation(path))
|
|
184
|
+
except (wt.WorktreeError, OSError) as error:
|
|
185
|
+
errors.append(str(error))
|
|
186
|
+
try:
|
|
187
|
+
if _branch(path) != record.branch:
|
|
188
|
+
errors.append("Worker changed branch; restore " + record.branch)
|
|
189
|
+
except (wt.WorktreeError, OSError) as error:
|
|
190
|
+
errors.append(str(error))
|
|
191
|
+
if errors:
|
|
192
|
+
record.status = "failed"
|
|
193
|
+
record.summary += "\n" + "; ".join(errors)
|
|
194
|
+
record.dirty = True # unknown is never safe for automatic deletion
|
|
195
|
+
|
|
196
|
+
def create(self, *, wait: bool = False) -> TaskRecord:
|
|
197
|
+
with self._lock(wait=wait):
|
|
198
|
+
# A manager may outlive its checkout. Recheck under the same lock used
|
|
199
|
+
# by generic removal, before writing any record or creating a child.
|
|
200
|
+
if not self.parent.is_dir():
|
|
201
|
+
raise wt.WorktreeError(f"Task parent {self.parent} no longer exists")
|
|
202
|
+
root = Path(wt._git(self.parent, "rev-parse", "--show-toplevel").stdout.strip())
|
|
203
|
+
if root.resolve() != self.parent or wt.main_checkout(self.parent) != self.main:
|
|
204
|
+
raise wt.WorktreeError(f"Task parent {self.parent} is no longer its checkout root")
|
|
205
|
+
branch = _branch(self.parent)
|
|
206
|
+
_idle(self.parent)
|
|
207
|
+
if _dirty(self.parent, tracked=True):
|
|
208
|
+
raise wt.WorktreeError(
|
|
209
|
+
"Parent has tracked changes; commit them before starting a task"
|
|
210
|
+
)
|
|
211
|
+
base = _head(self.parent)
|
|
212
|
+
task_id = uuid.uuid4().hex
|
|
213
|
+
name = f"task-{task_id}"
|
|
214
|
+
record = TaskRecord(
|
|
215
|
+
task_id,
|
|
216
|
+
str(self.parent),
|
|
217
|
+
branch,
|
|
218
|
+
base,
|
|
219
|
+
base,
|
|
220
|
+
name,
|
|
221
|
+
str(self.main / wt.WORKTREES_DIR / name),
|
|
222
|
+
"running",
|
|
223
|
+
False,
|
|
224
|
+
pid=os.getpid(),
|
|
225
|
+
process_started=psutil.Process().create_time(),
|
|
226
|
+
)
|
|
227
|
+
self._save(record) # also protects an interrupted worktree add
|
|
228
|
+
try:
|
|
229
|
+
wt.create(self.parent, name, base=base)
|
|
230
|
+
except Exception as error:
|
|
231
|
+
record.status = "failed"
|
|
232
|
+
record.summary = f"Worktree creation failed: {error}; inspect before discarding"
|
|
233
|
+
self._save(record)
|
|
234
|
+
raise
|
|
235
|
+
return record
|
|
236
|
+
|
|
237
|
+
def finish(
|
|
238
|
+
self, task_id: str, status: str, summary: str = "", *, wait: bool = False
|
|
239
|
+
) -> TaskRecord:
|
|
240
|
+
"""Persist a stopped worker's outcome; the caller must first await its exit.
|
|
241
|
+
|
|
242
|
+
Use wait=True for terminal persistence under contention. Async runtimes
|
|
243
|
+
must run this in asyncio.to_thread and shield/join that thread on cancellation,
|
|
244
|
+
so neither the event loop nor the terminal record is abandoned.
|
|
245
|
+
"""
|
|
246
|
+
if status not in {"completed", "failed", "cancelled"}:
|
|
247
|
+
raise wt.WorktreeError("Finish status must be completed, failed or cancelled")
|
|
248
|
+
with self._lock(wait=wait):
|
|
249
|
+
record = self._owned(task_id)
|
|
250
|
+
if record.status == "running" and record.pid != os.getpid():
|
|
251
|
+
raise wt.WorktreeError("Task worker belongs to another active process")
|
|
252
|
+
if record.status in {"integrated", "discarded", "conflicted"}:
|
|
253
|
+
raise wt.WorktreeError(
|
|
254
|
+
f"Task is {record.status}; inspect it rather than finishing again"
|
|
255
|
+
)
|
|
256
|
+
record.status, record.summary = status, summary
|
|
257
|
+
self._observe(record)
|
|
258
|
+
self._save(record)
|
|
259
|
+
return record
|
|
260
|
+
|
|
261
|
+
def get(self, task_id: str) -> TaskRecord:
|
|
262
|
+
with self._lock():
|
|
263
|
+
return self._owned(task_id)
|
|
264
|
+
|
|
265
|
+
def list(self) -> list[TaskRecord]:
|
|
266
|
+
with self._lock():
|
|
267
|
+
return [
|
|
268
|
+
self._owned(path.stem)
|
|
269
|
+
for path in sorted(self.directory.glob("*.json"))
|
|
270
|
+
if self._read(path.stem).parent == str(self.parent)
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
def integrate(self, task_id: str) -> TaskRecord:
|
|
274
|
+
with self._lock():
|
|
275
|
+
record = self._owned(task_id)
|
|
276
|
+
if _branch(self.parent) != record.parent_branch:
|
|
277
|
+
raise wt.WorktreeError(
|
|
278
|
+
f"Parent changed branch; restore {record.parent_branch} first"
|
|
279
|
+
)
|
|
280
|
+
if record.status not in {"completed", "conflicted", "integrated"}:
|
|
281
|
+
raise wt.WorktreeError(
|
|
282
|
+
f"Task is {record.status}; only successful committed results integrate"
|
|
283
|
+
)
|
|
284
|
+
child = Path(record.worktree)
|
|
285
|
+
for path in (self.parent, child):
|
|
286
|
+
_idle(path)
|
|
287
|
+
if _dirty(path):
|
|
288
|
+
raise wt.WorktreeError(
|
|
289
|
+
f"{path} is dirty (including untracked files); clean it first"
|
|
290
|
+
)
|
|
291
|
+
if _branch(child) != record.branch:
|
|
292
|
+
raise wt.WorktreeError(f"Worker changed branch; restore {record.branch} first")
|
|
293
|
+
if _head(child) != record.head_commit:
|
|
294
|
+
raise wt.WorktreeError(
|
|
295
|
+
"Worker HEAD changed since finish; inspect and finish the task again"
|
|
296
|
+
)
|
|
297
|
+
if not record.head_commit or not _ancestor(
|
|
298
|
+
self.parent, record.base_commit, record.head_commit
|
|
299
|
+
):
|
|
300
|
+
raise wt.WorktreeError(
|
|
301
|
+
"Task result does not descend from its recorded base; inspect its history"
|
|
302
|
+
)
|
|
303
|
+
if not _ancestor(self.parent, record.base_commit, "HEAD"):
|
|
304
|
+
raise wt.WorktreeError(
|
|
305
|
+
"Parent no longer descends from the task base; restore its history"
|
|
306
|
+
)
|
|
307
|
+
if not _ancestor(self.parent, record.head_commit, "HEAD"):
|
|
308
|
+
result = wt._git(
|
|
309
|
+
self.parent,
|
|
310
|
+
"merge",
|
|
311
|
+
"--no-squash",
|
|
312
|
+
"--commit",
|
|
313
|
+
"--no-edit",
|
|
314
|
+
record.head_commit,
|
|
315
|
+
check=False,
|
|
316
|
+
)
|
|
317
|
+
if result.returncode:
|
|
318
|
+
record.status = "conflicted"
|
|
319
|
+
self._save(record)
|
|
320
|
+
raise wt.WorktreeError(
|
|
321
|
+
f"Task integration failed in parent {self.parent}: "
|
|
322
|
+
f"{(result.stderr or result.stdout).strip()}. Resolve and commit there, "
|
|
323
|
+
"then retry integration; the child is preserved."
|
|
324
|
+
)
|
|
325
|
+
# Exit zero alone also describes a squash or an uncommitted merge.
|
|
326
|
+
# Never publish integration until Git is idle and HEAD contains the result.
|
|
327
|
+
try:
|
|
328
|
+
_idle(self.parent)
|
|
329
|
+
if _dirty(self.parent) or not _ancestor(self.parent, record.head_commit, "HEAD"):
|
|
330
|
+
raise wt.WorktreeError(
|
|
331
|
+
"Parent HEAD does not contain a clean committed task result"
|
|
332
|
+
)
|
|
333
|
+
except wt.WorktreeError as error:
|
|
334
|
+
record.status = "conflicted"
|
|
335
|
+
self._save(record)
|
|
336
|
+
raise wt.WorktreeError(
|
|
337
|
+
f"Task integration is incomplete in parent {self.parent}: {error}. "
|
|
338
|
+
"Finish or abort the merge there, then retry integration; "
|
|
339
|
+
"the child is preserved."
|
|
340
|
+
) from error
|
|
341
|
+
record.status, record.dirty = "integrated", False
|
|
342
|
+
self._save(record)
|
|
343
|
+
return record
|
|
344
|
+
|
|
345
|
+
def discard(self, task_id: str, confirm: bool = False) -> TaskRecord:
|
|
346
|
+
with self._lock():
|
|
347
|
+
record = self._owned(task_id)
|
|
348
|
+
return self._discard(record, confirm)
|
|
349
|
+
|
|
350
|
+
def _discard(self, record: TaskRecord, confirm: bool) -> TaskRecord:
|
|
351
|
+
with parent_operation(Path(record.worktree), self.main):
|
|
352
|
+
if record.status == "running":
|
|
353
|
+
raise wt.WorktreeError(
|
|
354
|
+
"Task worker is active; cancel and await its completion first"
|
|
355
|
+
)
|
|
356
|
+
if record.status == "discarded":
|
|
357
|
+
return record
|
|
358
|
+
child = Path(record.worktree)
|
|
359
|
+
if child.exists():
|
|
360
|
+
if record.status == "integrated" and not confirm:
|
|
361
|
+
tree = wt.Worktree(child, record.branch, self.main)
|
|
362
|
+
if reason := keep_reason(tree):
|
|
363
|
+
raise wt.WorktreeError(reason)
|
|
364
|
+
if _branch(child) != record.branch:
|
|
365
|
+
raise wt.WorktreeError(
|
|
366
|
+
f"Worker changed branch; restore {record.branch} before discard"
|
|
367
|
+
)
|
|
368
|
+
if reason := children_reason(child):
|
|
369
|
+
raise wt.WorktreeError(reason)
|
|
370
|
+
self._observe(record)
|
|
371
|
+
safe = record.status == "integrated" and not record.dirty
|
|
372
|
+
if safe and child.exists():
|
|
373
|
+
safe = _head(child) == record.head_commit and _ancestor(
|
|
374
|
+
self.main, _head(child), f"refs/heads/{record.parent_branch}"
|
|
375
|
+
)
|
|
376
|
+
if not safe and not confirm:
|
|
377
|
+
raise wt.WorktreeError(
|
|
378
|
+
"Task has unintegrated or dirty work; pass confirm=True to discard it"
|
|
379
|
+
)
|
|
380
|
+
if child.exists():
|
|
381
|
+
wt._git(
|
|
382
|
+
self.main, "worktree", "remove", *(["--force"] if confirm else []), str(child)
|
|
383
|
+
)
|
|
384
|
+
# Git itself refuses deletion when the branch is checked out anywhere else.
|
|
385
|
+
exists = wt._git(
|
|
386
|
+
self.main,
|
|
387
|
+
"show-ref",
|
|
388
|
+
"--verify",
|
|
389
|
+
"--quiet",
|
|
390
|
+
f"refs/heads/{record.branch}",
|
|
391
|
+
check=False,
|
|
392
|
+
)
|
|
393
|
+
if exists.returncode == 0:
|
|
394
|
+
wt._git(self.main, "branch", "-D", record.branch)
|
|
395
|
+
record.status = "discarded"
|
|
396
|
+
self._save(record)
|
|
397
|
+
return record
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@contextmanager
|
|
401
|
+
def parent_operation(parent: Path, repo: Path):
|
|
402
|
+
"""Nonblocking lifecycle lock, stored in repo's shared Git directory.
|
|
403
|
+
|
|
404
|
+
Resolve storage through a surviving checkout, not the potentially removed
|
|
405
|
+
parent. Do not call a locking operation for the same parent inside this scope.
|
|
406
|
+
"""
|
|
407
|
+
manager = TaskWorktrees(repo)
|
|
408
|
+
manager.parent = parent.resolve()
|
|
409
|
+
with manager._lock():
|
|
410
|
+
yield
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def discard_integrated(tree: wt.Worktree, record: TaskRecord) -> None:
|
|
414
|
+
"""Cleanup still works after the integrated task's parent checkout was removed."""
|
|
415
|
+
manager = TaskWorktrees(tree.main)
|
|
416
|
+
manager.parent = Path(record.parent)
|
|
417
|
+
manager.discard(record.task_id)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def records(repo: Path) -> list[TaskRecord]:
|
|
421
|
+
"""Repository-wide metadata for generic lifecycle guards (including other parents)."""
|
|
422
|
+
manager = TaskWorktrees(wt.main_checkout(repo) or repo)
|
|
423
|
+
return [manager._read(path.stem) for path in sorted(manager.directory.glob("*.json"))]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def task_for(tree: wt.Worktree) -> TaskRecord | None:
|
|
427
|
+
return next(
|
|
428
|
+
(
|
|
429
|
+
record
|
|
430
|
+
for record in records(tree.main)
|
|
431
|
+
if (record.worktree == str(tree.path.resolve()) or record.branch == tree.branch)
|
|
432
|
+
and record.status != "discarded"
|
|
433
|
+
),
|
|
434
|
+
None,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def moved_reason(tree: wt.Worktree, record: TaskRecord) -> str:
|
|
439
|
+
if record.worktree != str(tree.path.resolve()):
|
|
440
|
+
return (
|
|
441
|
+
f"task {record.task_id} moved to {tree.path}; restore it with "
|
|
442
|
+
f"git worktree move {shlex.quote(str(tree.path))} {shlex.quote(record.worktree)} "
|
|
443
|
+
"before task integration or removal"
|
|
444
|
+
)
|
|
445
|
+
return ""
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def children_reason(parent: Path) -> str:
|
|
449
|
+
for record in records(parent):
|
|
450
|
+
if record.parent == str(parent.resolve()) and record.status not in {
|
|
451
|
+
"integrated",
|
|
452
|
+
"discarded",
|
|
453
|
+
}:
|
|
454
|
+
return (
|
|
455
|
+
f"owns task {record.task_id} ({record.status}), "
|
|
456
|
+
"awaiting integration or explicit discard"
|
|
457
|
+
)
|
|
458
|
+
return ""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def keep_reason(tree: wt.Worktree) -> str | None:
|
|
462
|
+
"""None means an ordinary worktree; empty means a safely integrated task."""
|
|
463
|
+
if reason := children_reason(tree.path):
|
|
464
|
+
return reason
|
|
465
|
+
record = task_for(tree)
|
|
466
|
+
if record is None:
|
|
467
|
+
return None
|
|
468
|
+
if reason := moved_reason(tree, record):
|
|
469
|
+
return reason
|
|
470
|
+
if record.status != "integrated":
|
|
471
|
+
return f"task {record.task_id} ({record.status}), awaiting integration or explicit discard"
|
|
472
|
+
try:
|
|
473
|
+
_idle(tree.path)
|
|
474
|
+
if _dirty(tree.path) or _branch(tree.path) != record.branch:
|
|
475
|
+
return "integrated task has dirty files or a changed branch"
|
|
476
|
+
if _head(tree.path) != record.head_commit or not _ancestor(
|
|
477
|
+
tree.main, _head(tree.path), f"refs/heads/{record.parent_branch}"
|
|
478
|
+
):
|
|
479
|
+
return "task result changed or is no longer integrated into its parent"
|
|
480
|
+
except wt.WorktreeError as error:
|
|
481
|
+
return str(error)
|
|
482
|
+
return ""
|