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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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