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/worktree.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""Throwaway git worktrees, one per session, so concurrent sessions never share a tree.
|
|
2
|
+
|
|
3
|
+
The workspace *is* the worktree: file tools, the shell, repo context, and the
|
|
4
|
+
saved session all point at it, so the model needs no instructions and relative
|
|
5
|
+
paths cannot land in the mainline checkout by mistake.
|
|
6
|
+
|
|
7
|
+
Setup that git cannot do (installing dependencies, copying untracked config,
|
|
8
|
+
symlinking shared caches) lives in two optional scripts run after checkout:
|
|
9
|
+
`~/.config/pcode/worktree-setup` for every repo, then `<repo>/.pcode/worktree-setup`
|
|
10
|
+
for this one. The project script is arbitrary code shipped with the repo, so it
|
|
11
|
+
only runs for a trusted repository, like `.pcode/extensions`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pcode.preferences import preferences_path
|
|
20
|
+
|
|
21
|
+
WORKTREES_DIR = ".worktrees"
|
|
22
|
+
DETACHED = "(detached)"
|
|
23
|
+
SETUP_SCRIPT = "worktree-setup"
|
|
24
|
+
PROJECT_SETUP = Path(".pcode") / SETUP_SCRIPT
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorktreeError(ValueError):
|
|
28
|
+
"""A ValueError so slash-command dispatch reports it as a plain message."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Worktree:
|
|
33
|
+
path: Path
|
|
34
|
+
branch: str
|
|
35
|
+
main: Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "-C", str(cwd), *args], capture_output=True, text=True, check=False
|
|
41
|
+
)
|
|
42
|
+
if check and result.returncode:
|
|
43
|
+
detail = (result.stderr or result.stdout).strip() or f"exit {result.returncode}"
|
|
44
|
+
raise WorktreeError(f"git {args[0]} failed: {detail}")
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main_checkout(path: Path) -> Path | None:
|
|
49
|
+
"""The repository's primary worktree, or None when `path` is not in a git repo."""
|
|
50
|
+
result = _git(path, "worktree", "list", "--porcelain", check=False)
|
|
51
|
+
if result.returncode:
|
|
52
|
+
return None
|
|
53
|
+
first = result.stdout.splitlines()[0]
|
|
54
|
+
return Path(first.removeprefix("worktree ")).resolve()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def project_checkout(path: Path) -> Path | None:
|
|
58
|
+
"""`main_checkout`, but None when git is missing rather than an error.
|
|
59
|
+
|
|
60
|
+
What a caller labelling or grouping a workspace wants: saving a session or
|
|
61
|
+
listing history must keep working on a machine without git.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
return main_checkout(path)
|
|
65
|
+
except OSError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def head_commit(path: Path) -> str | None:
|
|
70
|
+
"""The full hash `HEAD` names at `path`; None outside git or before the first commit."""
|
|
71
|
+
try:
|
|
72
|
+
result = _git(path, "rev-parse", "--verify", "--quiet", "HEAD^{commit}", check=False)
|
|
73
|
+
except OSError:
|
|
74
|
+
return None
|
|
75
|
+
if result.returncode:
|
|
76
|
+
return None
|
|
77
|
+
return result.stdout.strip() or None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def repo_scope(path: Path) -> Path:
|
|
81
|
+
"""The identity to group a workspace by: its main checkout, or itself.
|
|
82
|
+
|
|
83
|
+
Linked worktrees and the mainline checkout share a main checkout, so
|
|
84
|
+
comparing this value (rather than the raw workspace) groups sessions from
|
|
85
|
+
the same repository regardless of which worktree created them.
|
|
86
|
+
"""
|
|
87
|
+
path = path.resolve()
|
|
88
|
+
return project_checkout(path) or path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_linked(path: Path) -> bool:
|
|
92
|
+
"""True inside a secondary worktree (created by `git worktree add`)."""
|
|
93
|
+
main = main_checkout(path)
|
|
94
|
+
if main is None:
|
|
95
|
+
return False
|
|
96
|
+
toplevel = _git(path, "rev-parse", "--show-toplevel").stdout.strip()
|
|
97
|
+
return Path(toplevel).resolve() != main
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def mainline_branch(main: Path) -> str:
|
|
101
|
+
result = _git(main, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
|
|
102
|
+
if result.returncode:
|
|
103
|
+
raise WorktreeError(f"mainline checkout {main} is not on a branch")
|
|
104
|
+
return result.stdout.strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_name(name: str) -> str:
|
|
108
|
+
if not name or "/" in name or name.startswith(".") or any(c.isspace() for c in name):
|
|
109
|
+
raise WorktreeError(f"worktree name must be a plain name, got {name!r}")
|
|
110
|
+
return name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def create(repo: Path, name: str, base: str | None = None) -> Worktree:
|
|
114
|
+
"""Add `.worktrees/<name>` on branch `<name>`, reusing the branch if it exists."""
|
|
115
|
+
validate_name(name)
|
|
116
|
+
main = main_checkout(repo)
|
|
117
|
+
if main is None:
|
|
118
|
+
raise WorktreeError(f"{repo} is not inside a git repository")
|
|
119
|
+
path = main / WORKTREES_DIR / name
|
|
120
|
+
if path.exists():
|
|
121
|
+
raise WorktreeError(f"{path} already exists")
|
|
122
|
+
base = base or mainline_branch(main)
|
|
123
|
+
path.parent.mkdir(exist_ok=True)
|
|
124
|
+
_exclude_worktrees_dir(main)
|
|
125
|
+
exists = _git(main, "show-ref", "--quiet", "--verify", f"refs/heads/{name}", check=False)
|
|
126
|
+
if exists.returncode == 0:
|
|
127
|
+
_git(main, "worktree", "add", str(path), name)
|
|
128
|
+
else:
|
|
129
|
+
_git(main, "worktree", "add", "-b", name, str(path), base)
|
|
130
|
+
return Worktree(path=path, branch=name, main=main)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _exclude_worktrees_dir(main: Path) -> None:
|
|
134
|
+
"""Keep `.worktrees/` out of `git status` without touching the tracked .gitignore."""
|
|
135
|
+
common = _git(main, "rev-parse", "--path-format=absolute", "--git-common-dir").stdout
|
|
136
|
+
exclude = Path(common.strip()) / "info" / "exclude"
|
|
137
|
+
entry = f"/{WORKTREES_DIR}/"
|
|
138
|
+
try:
|
|
139
|
+
lines = exclude.read_text().splitlines() if exclude.exists() else []
|
|
140
|
+
if entry not in lines and f"{WORKTREES_DIR}/" not in lines:
|
|
141
|
+
exclude.parent.mkdir(exist_ok=True)
|
|
142
|
+
with exclude.open("a") as file:
|
|
143
|
+
file.write(f"{entry}\n")
|
|
144
|
+
except OSError:
|
|
145
|
+
pass # cosmetic; git status noise is not worth failing creation
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def describe(path: Path) -> Worktree | None:
|
|
149
|
+
"""The worktree `path` lives in, or None when it is the mainline or not a repo."""
|
|
150
|
+
main = main_checkout(path)
|
|
151
|
+
if main is None:
|
|
152
|
+
return None
|
|
153
|
+
toplevel = Path(_git(path, "rev-parse", "--show-toplevel").stdout.strip()).resolve()
|
|
154
|
+
if toplevel == main:
|
|
155
|
+
return None
|
|
156
|
+
branch = _git(toplevel, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
|
|
157
|
+
return Worktree(path=toplevel, branch=branch.stdout.strip() or DETACHED, main=main)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def setup_scripts(worktree: Worktree, project: bool | None = None) -> list[Path]:
|
|
161
|
+
"""Existing setup scripts in run order: user-level first, then the project's.
|
|
162
|
+
|
|
163
|
+
The project script is taken from the new checkout when its branch has one
|
|
164
|
+
(so it matches the code being set up), else from the mainline, where an
|
|
165
|
+
untracked local copy may live. `project` None defers to whether the
|
|
166
|
+
repository is trusted; True runs it regardless, for an explicit invocation
|
|
167
|
+
inside that repo.
|
|
168
|
+
"""
|
|
169
|
+
from pcode.project_trust import is_trusted
|
|
170
|
+
|
|
171
|
+
scripts = [preferences_path().parent / SETUP_SCRIPT]
|
|
172
|
+
if project is None:
|
|
173
|
+
project = is_trusted(worktree.main)
|
|
174
|
+
if project:
|
|
175
|
+
scripts.append(
|
|
176
|
+
next(
|
|
177
|
+
(
|
|
178
|
+
p
|
|
179
|
+
for p in (worktree.path / PROJECT_SETUP, worktree.main / PROJECT_SETUP)
|
|
180
|
+
if p.is_file()
|
|
181
|
+
),
|
|
182
|
+
worktree.main / PROJECT_SETUP,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return [script for script in scripts if script.is_file()]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_setup(worktree: Worktree, stream=None, project: bool | None = None) -> None:
|
|
189
|
+
"""Run each setup script inside the worktree, echoing its output to `stream`.
|
|
190
|
+
|
|
191
|
+
Executable scripts run directly (so they need a shebang, like git hooks);
|
|
192
|
+
anything else goes through `sh`. A failing script aborts creation.
|
|
193
|
+
"""
|
|
194
|
+
env = {
|
|
195
|
+
**os.environ,
|
|
196
|
+
"PCODE_MAIN": str(worktree.main),
|
|
197
|
+
"PCODE_WORKTREE": str(worktree.path),
|
|
198
|
+
"PCODE_BRANCH": worktree.branch,
|
|
199
|
+
}
|
|
200
|
+
for script in setup_scripts(worktree, project):
|
|
201
|
+
if stream is not None:
|
|
202
|
+
print(f"worktree: running {script}", file=stream)
|
|
203
|
+
command = [str(script)] if os.access(script, os.X_OK) else ["sh", str(script)]
|
|
204
|
+
result = subprocess.run(
|
|
205
|
+
command,
|
|
206
|
+
cwd=worktree.path,
|
|
207
|
+
env=env,
|
|
208
|
+
stdout=subprocess.PIPE,
|
|
209
|
+
stderr=subprocess.STDOUT,
|
|
210
|
+
text=True,
|
|
211
|
+
)
|
|
212
|
+
if stream is not None and result.stdout:
|
|
213
|
+
stream.write(result.stdout)
|
|
214
|
+
if result.returncode:
|
|
215
|
+
raise WorktreeError(f"{script} exited {result.returncode}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def is_dirty(path: Path) -> bool:
|
|
219
|
+
return bool(_git(path, "status", "--porcelain", "--untracked-files=no").stdout.strip())
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def unmerged_commits(worktree: Worktree) -> int:
|
|
223
|
+
"""Commits on the worktree's branch that the mainline branch does not have."""
|
|
224
|
+
mainline = mainline_branch(worktree.main)
|
|
225
|
+
result = _git(worktree.path, "rev-list", "--count", f"{mainline}..HEAD", check=False)
|
|
226
|
+
return int(result.stdout.strip() or 0) if result.returncode == 0 else 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def conflicted_files(path: Path) -> list[str]:
|
|
230
|
+
"""Paths still holding conflict markers from an in-progress merge, or empty."""
|
|
231
|
+
if _git(path, "rev-parse", "-q", "--verify", "MERGE_HEAD", check=False).returncode:
|
|
232
|
+
return []
|
|
233
|
+
out = _git(path, "diff", "--name-only", "--diff-filter=U").stdout
|
|
234
|
+
return [line for line in out.splitlines() if line]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_prompt(worktree: Worktree, files: list[str]) -> str:
|
|
238
|
+
"""What to ask the model once a merge has stopped on conflicts."""
|
|
239
|
+
mainline = mainline_branch(worktree.main)
|
|
240
|
+
listed = "\n".join(f"- {name}" for name in files)
|
|
241
|
+
return (
|
|
242
|
+
f"Merging `{mainline}` into `{worktree.branch}` in {worktree.path} stopped on "
|
|
243
|
+
f"conflicts in:\n{listed}\n\n"
|
|
244
|
+
"Resolve each file so the intent of both sides survives: read the conflict "
|
|
245
|
+
"markers, check `git log -p` on both branches for a hunk when its purpose is "
|
|
246
|
+
"unclear, and remove every marker. Run the relevant tests. Then `git add` the "
|
|
247
|
+
"resolved files and `git commit` (no message needed; the merge message is "
|
|
248
|
+
"prepared) to complete the merge. Do not run `git merge --abort`, and do not "
|
|
249
|
+
"discard either side's change without saying so."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def merge(worktree: Worktree) -> str:
|
|
254
|
+
"""Merge the mainline branch into the worktree, then fast-forward the mainline.
|
|
255
|
+
|
|
256
|
+
Conflicts are resolved inside the worktree so the mainline checkout is
|
|
257
|
+
never left mid-merge; it only ever moves by fast-forward.
|
|
258
|
+
"""
|
|
259
|
+
from pcode.task_worktrees import parent_operation
|
|
260
|
+
|
|
261
|
+
with parent_operation(worktree.path, worktree.main):
|
|
262
|
+
return _merge(worktree)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _merge(worktree: Worktree) -> str:
|
|
266
|
+
"""Merge with the source parent operation lock already held."""
|
|
267
|
+
from pcode.task_worktrees import children_reason, moved_reason, task_for
|
|
268
|
+
|
|
269
|
+
if reason := children_reason(worktree.path):
|
|
270
|
+
raise WorktreeError(f"not merging {worktree.path}: {reason}")
|
|
271
|
+
if record := task_for(worktree):
|
|
272
|
+
if reason := moved_reason(worktree, record):
|
|
273
|
+
raise WorktreeError(reason)
|
|
274
|
+
raise WorktreeError(
|
|
275
|
+
f"task {record.task_id} belongs to parent {record.parent}; "
|
|
276
|
+
"use task integration, not a mainline merge"
|
|
277
|
+
)
|
|
278
|
+
if conflicted := conflicted_files(worktree.path):
|
|
279
|
+
raise WorktreeError(
|
|
280
|
+
f"a merge is already in progress with conflicts in {', '.join(conflicted)}; "
|
|
281
|
+
"/worktree resolve has the model finish it"
|
|
282
|
+
)
|
|
283
|
+
if is_dirty(worktree.path):
|
|
284
|
+
raise WorktreeError(f"{worktree.path} has uncommitted changes; commit them first")
|
|
285
|
+
mainline = mainline_branch(worktree.main)
|
|
286
|
+
result = _git(worktree.path, "merge", "--no-edit", mainline, check=False)
|
|
287
|
+
if result.returncode:
|
|
288
|
+
conflicted = conflicted_files(worktree.path)
|
|
289
|
+
raise WorktreeError(
|
|
290
|
+
f"conflicts merging {mainline} into {worktree.branch} in "
|
|
291
|
+
f"{', '.join(conflicted) or worktree.path}; /worktree resolve has the model fix "
|
|
292
|
+
"them, then run this again"
|
|
293
|
+
)
|
|
294
|
+
result = _git(worktree.main, "merge", "--ff-only", worktree.branch, check=False)
|
|
295
|
+
if result.returncode:
|
|
296
|
+
detail = (result.stderr or result.stdout).strip()
|
|
297
|
+
raise WorktreeError(
|
|
298
|
+
f"could not fast-forward {mainline} in {worktree.main}: {detail}\n"
|
|
299
|
+
"Commit or stash those mainline changes, or re-run if the branch moved."
|
|
300
|
+
)
|
|
301
|
+
return f"merged {worktree.branch} into {mainline}"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def remove(worktree: Worktree, force: bool = False) -> str:
|
|
305
|
+
"""Delete the worktree directory, keeping its branch.
|
|
306
|
+
|
|
307
|
+
`force` is for a worktree pcode itself just created and abandoned; user
|
|
308
|
+
work is never forced because another session may still be in there.
|
|
309
|
+
"""
|
|
310
|
+
from pcode.task_worktrees import discard_integrated, moved_reason, parent_operation, task_for
|
|
311
|
+
|
|
312
|
+
if record := task_for(worktree):
|
|
313
|
+
if reason := moved_reason(worktree, record):
|
|
314
|
+
raise WorktreeError(reason)
|
|
315
|
+
# Task discard locks its owner and then its own potential children.
|
|
316
|
+
# Do not hold the child lock here: flock is not reentrant.
|
|
317
|
+
if record.status != "integrated":
|
|
318
|
+
raise WorktreeError(
|
|
319
|
+
f"not removing {worktree.path}: task {record.task_id} ({record.status}), "
|
|
320
|
+
"awaiting integration or explicit discard"
|
|
321
|
+
)
|
|
322
|
+
discard_integrated(worktree, record)
|
|
323
|
+
return f"removed {worktree.path}"
|
|
324
|
+
with parent_operation(worktree.path, worktree.main):
|
|
325
|
+
return _remove(worktree, force)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _remove(worktree: Worktree, force: bool = False) -> str:
|
|
329
|
+
"""Remove an ordinary checkout with its parent operation lock held."""
|
|
330
|
+
from pcode.task_worktrees import keep_reason as task_keep_reason
|
|
331
|
+
|
|
332
|
+
if reason := task_keep_reason(worktree):
|
|
333
|
+
raise WorktreeError(f"not removing {worktree.path}: {reason}")
|
|
334
|
+
flags = ["--force"] if force else []
|
|
335
|
+
result = _git(worktree.main, "worktree", "remove", *flags, str(worktree.path), check=False)
|
|
336
|
+
if result.returncode:
|
|
337
|
+
detail = (result.stderr or result.stdout).strip()
|
|
338
|
+
raise WorktreeError(
|
|
339
|
+
f"not removing {worktree.path}: {detail}\n"
|
|
340
|
+
f"If that work is disposable: git worktree remove --force {worktree.path}"
|
|
341
|
+
)
|
|
342
|
+
return f"removed {worktree.path}"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def keep_reason(worktree: Worktree) -> str:
|
|
346
|
+
"""Why this worktree must not be deleted, or "" when there is nothing to lose.
|
|
347
|
+
|
|
348
|
+
Ignored files (a `.venv`, the shared `tmp` symlink) do not count, matching
|
|
349
|
+
what a non-forced `git worktree remove` tolerates.
|
|
350
|
+
"""
|
|
351
|
+
from pcode.task_worktrees import keep_reason as task_keep_reason
|
|
352
|
+
|
|
353
|
+
task_reason = task_keep_reason(worktree)
|
|
354
|
+
if task_reason is not None:
|
|
355
|
+
return task_reason
|
|
356
|
+
status = _git(worktree.path, "status", "--porcelain", check=False)
|
|
357
|
+
if status.returncode:
|
|
358
|
+
return "git status failed"
|
|
359
|
+
if status.stdout.strip():
|
|
360
|
+
return "uncommitted or untracked files"
|
|
361
|
+
count = unmerged_commits(worktree)
|
|
362
|
+
return f"{count} unmerged commit{'s' if count != 1 else ''}" if count else ""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def is_untouched(worktree: Worktree) -> bool:
|
|
366
|
+
"""Nothing to lose: no tracked changes, no untracked files, nothing unmerged."""
|
|
367
|
+
return not keep_reason(worktree)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def delete_branch(worktree: Worktree) -> None:
|
|
371
|
+
"""Drop a fully merged branch; `-d` refuses anything unmerged, which is the point."""
|
|
372
|
+
from pcode.task_worktrees import records
|
|
373
|
+
|
|
374
|
+
# Integrated children still need their parent's ref as the cleanup safety anchor,
|
|
375
|
+
# even when the parent checkout has already gone away.
|
|
376
|
+
if any(
|
|
377
|
+
record.parent == str(worktree.path.resolve()) and record.status != "discarded"
|
|
378
|
+
for record in records(worktree.main)
|
|
379
|
+
):
|
|
380
|
+
return
|
|
381
|
+
if worktree.branch != DETACHED:
|
|
382
|
+
_git(worktree.main, "branch", "-d", worktree.branch, check=False)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def finish(worktree: Worktree) -> str:
|
|
386
|
+
"""Merge into the mainline, then remove the worktree and its branch.
|
|
387
|
+
|
|
388
|
+
Any refusal (dirty tree, conflicts, blocked fast-forward, untracked files)
|
|
389
|
+
raises before anything is deleted, leaving the worktree resumable.
|
|
390
|
+
"""
|
|
391
|
+
from pcode.task_worktrees import children_reason, parent_operation, task_for
|
|
392
|
+
|
|
393
|
+
if task_for(worktree):
|
|
394
|
+
return remove(worktree) # refuses pending tasks; never merges one into mainline
|
|
395
|
+
with parent_operation(worktree.path, worktree.main):
|
|
396
|
+
if reason := children_reason(worktree.path):
|
|
397
|
+
raise WorktreeError(f"not finishing {worktree.path}: {reason}")
|
|
398
|
+
if conflicted_files(worktree.path) or is_dirty(worktree.path):
|
|
399
|
+
_merge(worktree) # raises with the precise reason
|
|
400
|
+
merged = _merge(worktree) if unmerged_commits(worktree) else None
|
|
401
|
+
_remove(worktree)
|
|
402
|
+
delete_branch(worktree)
|
|
403
|
+
if merged:
|
|
404
|
+
return f"{merged} and removed its worktree"
|
|
405
|
+
return f"removed worktree {worktree.branch} (nothing to merge)"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def listing(repo: Path) -> str:
|
|
409
|
+
from pcode.task_worktrees import records
|
|
410
|
+
|
|
411
|
+
output = _git(repo, "worktree", "list").stdout.rstrip()
|
|
412
|
+
tasks = {record.worktree: record for record in records(repo) if record.status != "discarded"}
|
|
413
|
+
lines = []
|
|
414
|
+
for line in output.splitlines():
|
|
415
|
+
record = next(
|
|
416
|
+
(record for path, record in tasks.items() if line.startswith(path + " ")), None
|
|
417
|
+
)
|
|
418
|
+
if record:
|
|
419
|
+
pending = "" if record.status == "integrated" else ", awaiting integration"
|
|
420
|
+
line += f" (task, parent={record.parent}, {record.status}{pending})"
|
|
421
|
+
lines.append(line)
|
|
422
|
+
return "\n".join(lines)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def linked_worktrees(repo: Path) -> list[Worktree]:
|
|
426
|
+
"""Every secondary worktree of this repository, mainline first entry excluded.
|
|
427
|
+
|
|
428
|
+
Locked worktrees are left out: a lock is someone saying "this one is in
|
|
429
|
+
use", and `git worktree remove` refuses them anyway.
|
|
430
|
+
"""
|
|
431
|
+
result = _git(repo, "worktree", "list", "--porcelain", check=False)
|
|
432
|
+
if result.returncode:
|
|
433
|
+
return []
|
|
434
|
+
blocks = [
|
|
435
|
+
dict(line.partition(" ")[::2] for line in block.splitlines())
|
|
436
|
+
for block in result.stdout.strip().split("\n\n")
|
|
437
|
+
]
|
|
438
|
+
if not blocks:
|
|
439
|
+
return []
|
|
440
|
+
main = Path(blocks[0]["worktree"]).resolve()
|
|
441
|
+
return [
|
|
442
|
+
Worktree(
|
|
443
|
+
path=Path(block["worktree"]).resolve(),
|
|
444
|
+
branch=block.get("branch", "").removeprefix("refs/heads/") or DETACHED,
|
|
445
|
+
main=main,
|
|
446
|
+
)
|
|
447
|
+
for block in blocks[1:]
|
|
448
|
+
if "worktree" in block and "locked" not in block
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def clean(repo: Path, keep: Path | None = None) -> list[str]:
|
|
453
|
+
"""Remove every worktree of this repository that has nothing to lose.
|
|
454
|
+
|
|
455
|
+
"Nothing to lose" is `keep_reason`: no uncommitted or untracked files and
|
|
456
|
+
nothing the mainline branch does not already have. Everything else is left
|
|
457
|
+
alone and reported with the reason, so this is never a destructive command.
|
|
458
|
+
`keep` defaults to the worktree `repo` itself sits in, so cleaning from
|
|
459
|
+
inside one never deletes the caller's own directory out from under it.
|
|
460
|
+
|
|
461
|
+
An idle session sitting in one of the others loses its working directory,
|
|
462
|
+
not its work; stale registrations whose directory is already gone are
|
|
463
|
+
pruned first.
|
|
464
|
+
"""
|
|
465
|
+
main = main_checkout(repo)
|
|
466
|
+
if main is None:
|
|
467
|
+
raise WorktreeError(f"{repo} is not inside a git repository")
|
|
468
|
+
_git(main, "worktree", "prune", check=False)
|
|
469
|
+
own = describe(repo)
|
|
470
|
+
keep = keep or (own.path if own else None)
|
|
471
|
+
keep = keep.resolve() if keep else None
|
|
472
|
+
report = []
|
|
473
|
+
for tree in linked_worktrees(main):
|
|
474
|
+
if tree.path == keep:
|
|
475
|
+
continue
|
|
476
|
+
reason = keep_reason(tree) or _removal_failure(tree)
|
|
477
|
+
report.append(f"kept {tree.path} ({reason})" if reason else f"removed {tree.path}")
|
|
478
|
+
return report or ["nothing to clean"]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _removal_failure(tree: Worktree) -> str:
|
|
482
|
+
"""Delete the worktree and its branch, returning "" or why git refused."""
|
|
483
|
+
try:
|
|
484
|
+
remove(tree)
|
|
485
|
+
except WorktreeError as error:
|
|
486
|
+
return str(error).splitlines()[0].removeprefix(f"not removing {tree.path}: ")
|
|
487
|
+
delete_branch(tree)
|
|
488
|
+
return ""
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def main(argv: list[str] | None = None) -> int:
|
|
492
|
+
"""`python -m pcode.worktree new|merge|remove|list|clean [NAME]`, for Makefiles and humans.
|
|
493
|
+
|
|
494
|
+
Same operations as `pcode --worktree` and `/worktree`, without a session.
|
|
495
|
+
The project's setup script runs unconditionally here: invoking this inside
|
|
496
|
+
the repo is the consent that `project_extensions` stands in for otherwise.
|
|
497
|
+
"""
|
|
498
|
+
import argparse
|
|
499
|
+
import sys
|
|
500
|
+
|
|
501
|
+
parser = argparse.ArgumentParser(prog="python -m pcode.worktree")
|
|
502
|
+
parser.add_argument("command", choices=("new", "merge", "remove", "list", "clean"))
|
|
503
|
+
parser.add_argument("name", nargs="?")
|
|
504
|
+
parser.add_argument("--base", help="branch or commit to start from (new only)")
|
|
505
|
+
args = parser.parse_args(argv)
|
|
506
|
+
cwd = Path.cwd()
|
|
507
|
+
try:
|
|
508
|
+
if args.command == "list":
|
|
509
|
+
print(listing(cwd))
|
|
510
|
+
return 0
|
|
511
|
+
if args.command == "clean":
|
|
512
|
+
print("\n".join(clean(cwd)))
|
|
513
|
+
return 0
|
|
514
|
+
if not args.name:
|
|
515
|
+
parser.error(f"{args.command} needs a NAME")
|
|
516
|
+
if args.command == "new":
|
|
517
|
+
created = create(cwd, args.name, args.base)
|
|
518
|
+
try:
|
|
519
|
+
run_setup(created, stream=sys.stderr, project=True)
|
|
520
|
+
except WorktreeError:
|
|
521
|
+
remove(created, force=True)
|
|
522
|
+
raise
|
|
523
|
+
print(f"worktree ready: {created.path} (branch {created.branch})")
|
|
524
|
+
return 0
|
|
525
|
+
main_path = main_checkout(cwd)
|
|
526
|
+
if main_path is None:
|
|
527
|
+
raise WorktreeError(f"{cwd} is not inside a git repository")
|
|
528
|
+
target = describe(main_path / WORKTREES_DIR / args.name)
|
|
529
|
+
if target is None:
|
|
530
|
+
raise WorktreeError(f"no worktree named {args.name} under {main_path / WORKTREES_DIR}")
|
|
531
|
+
print(merge(target) if args.command == "merge" else remove(target))
|
|
532
|
+
return 0
|
|
533
|
+
except WorktreeError as error:
|
|
534
|
+
print(f"worktree: {error}", file=sys.stderr)
|
|
535
|
+
return 1
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
if __name__ == "__main__":
|
|
539
|
+
raise SystemExit(main())
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# Session worktrees pcode created itself; hand-made ones are never removed.
|
|
543
|
+
SESSION_WORKTREE_PREFIX = "pcode-"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def session_scope(info) -> Path:
|
|
547
|
+
"""The repository a saved session belongs to, even once its worktree is gone.
|
|
548
|
+
|
|
549
|
+
`repo_scope` asks git inside the directory, which can answer nothing at all
|
|
550
|
+
after the directory is deleted; the recorded project checkout outlives it.
|
|
551
|
+
"""
|
|
552
|
+
workspace = Path(info.workspace)
|
|
553
|
+
if workspace.is_dir():
|
|
554
|
+
return repo_scope(workspace)
|
|
555
|
+
return Path(info.project) if info.project else workspace
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def leave_worktree(workspace: Path, session, *, ask, notify, merge: bool = True) -> bool:
|
|
559
|
+
"""Tidy a session worktree on the way out of it, never losing work.
|
|
560
|
+
|
|
561
|
+
Untouched (clean, nothing unmerged): removed with its branch, no question;
|
|
562
|
+
a session that never had a turn is deleted too, and True is returned.
|
|
563
|
+
Unmerged commits: per `worktree_exit`, ask (default yes), merge silently,
|
|
564
|
+
or keep. Uncommitted changes, refusals, and hand-made worktrees (no
|
|
565
|
+
`pcode-` prefix): kept, with a note on how to resume. `ask=None` means
|
|
566
|
+
nobody is there to answer, and `merge=False` keeps unmerged commits even
|
|
567
|
+
under `worktree_exit=merge`. Used at process exit and when a live session
|
|
568
|
+
switches to another worktree of the same repository.
|
|
569
|
+
"""
|
|
570
|
+
import shutil
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
linked = describe(workspace)
|
|
574
|
+
if linked is None:
|
|
575
|
+
return False
|
|
576
|
+
ours = linked.branch.startswith(SESSION_WORKTREE_PREFIX)
|
|
577
|
+
dirty = is_dirty(linked.path)
|
|
578
|
+
unmerged = unmerged_commits(linked)
|
|
579
|
+
untouched = ours and is_untouched(linked)
|
|
580
|
+
except WorktreeError:
|
|
581
|
+
return False
|
|
582
|
+
if session is not None:
|
|
583
|
+
# A copied session shares its original's worktree; never pull it out
|
|
584
|
+
# from under the other one, open or waiting to be resumed. Only asked
|
|
585
|
+
# with a session, whose module is then already loaded: a bare exit
|
|
586
|
+
# must not import the agent stack.
|
|
587
|
+
from pcode.sessions import sharing
|
|
588
|
+
|
|
589
|
+
others = sharing(linked.path, session.directory.parent, exclude=session.info.id)
|
|
590
|
+
if others:
|
|
591
|
+
other, running = others[0]
|
|
592
|
+
state = "is still open in" if running else "still works in"
|
|
593
|
+
notify(f"worktree: kept; session {other} {state} {linked.path}")
|
|
594
|
+
return False
|
|
595
|
+
resume = f"`pcode -C {linked.path} -c` resumes there"
|
|
596
|
+
|
|
597
|
+
def repoint():
|
|
598
|
+
if session is not None:
|
|
599
|
+
session.info.workspace = str(linked.main)
|
|
600
|
+
session.save_info()
|
|
601
|
+
|
|
602
|
+
def forget():
|
|
603
|
+
# Its review checkpoint was of the checkout just removed.
|
|
604
|
+
if session is not None:
|
|
605
|
+
from pcode.sessions import forget_refs
|
|
606
|
+
|
|
607
|
+
forget_refs(session.info)
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
if untouched:
|
|
611
|
+
remove(linked)
|
|
612
|
+
delete_branch(linked)
|
|
613
|
+
deleted = session is not None and session.info.turns == 0
|
|
614
|
+
if deleted:
|
|
615
|
+
session.close()
|
|
616
|
+
shutil.rmtree(session.directory, ignore_errors=True)
|
|
617
|
+
else:
|
|
618
|
+
repoint()
|
|
619
|
+
forget()
|
|
620
|
+
notify(f"worktree: removed untouched {linked.path}")
|
|
621
|
+
return deleted
|
|
622
|
+
if dirty or not ours or not unmerged:
|
|
623
|
+
state = "uncommitted changes" if dirty else f"{unmerged} unmerged commit(s)"
|
|
624
|
+
notify(f"worktree: {linked.path} ({linked.branch}) has {state}; {resume}")
|
|
625
|
+
return False
|
|
626
|
+
from pcode.preferences import load_preferences
|
|
627
|
+
|
|
628
|
+
mode = load_preferences().get("worktree_exit", "ask")
|
|
629
|
+
mainline = mainline_branch(linked.main)
|
|
630
|
+
if mode == "ask" and ask is not None:
|
|
631
|
+
notify(f"worktree: {linked.branch} has {unmerged} commit(s) not in {mainline}.")
|
|
632
|
+
try:
|
|
633
|
+
answer = ask("Merge and remove the worktree? [Y/n] ").strip().lower()
|
|
634
|
+
except (EOFError, OSError, KeyboardInterrupt):
|
|
635
|
+
answer = "n"
|
|
636
|
+
if answer not in ("", "y", "yes"):
|
|
637
|
+
notify(f"worktree: kept; {resume}")
|
|
638
|
+
return False
|
|
639
|
+
elif mode != "merge" or not merge:
|
|
640
|
+
notify(
|
|
641
|
+
f"worktree: {linked.path} ({linked.branch}) has {unmerged} unmerged commit(s); "
|
|
642
|
+
f"{resume}"
|
|
643
|
+
)
|
|
644
|
+
return False
|
|
645
|
+
notify("worktree: " + finish(linked))
|
|
646
|
+
repoint()
|
|
647
|
+
forget()
|
|
648
|
+
except (WorktreeError, OSError) as error:
|
|
649
|
+
notify(f"worktree: {error}\nworktree: kept; {resume}")
|
|
650
|
+
return False
|