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/git_diff.py ADDED
@@ -0,0 +1,497 @@
1
+ """Net git diffs for /diffs: what a session's work adds up to, one entry per file.
2
+
3
+ A linked worktree belongs to its session, so its whole branch is compared with
4
+ the merge-base on the mainline branch. That is exactly what a merge would bring
5
+ in, and it stays right after `/worktree merge` merges mainline into the branch,
6
+ where a remembered starting commit would claim mainline's changes too. A shared
7
+ checkout has no branch of its own, so it compares the working tree with the
8
+ commit the session started from, but only for files this session's tools edited
9
+ or its commits touched, since anything else there may be the user's. A commit
10
+ counts as the session's when it is new since the start, was made after the
11
+ session began, and by this checkout's git identity; a pulled commit is neither.
12
+ The same load also cuts out what is not yet committed, and what changed since
13
+ the tree last marked reviewed, kept under the session's refs (`SESSION_REFS`)
14
+ with the tree it was compared against.
15
+
16
+ `git diff` never shows untracked files, and staging them would change the
17
+ user's index. So the working tree is recorded into a throwaway copy of the index
18
+ (`GIT_INDEX_FILE`), written out as a tree, and diffed tree to tree; the real
19
+ index is never written. The copy sits beside the real one because a split index
20
+ finds its shared half there. Untracked files that look sensitive are listed but
21
+ never hashed, so their contents do not reach the object store.
22
+ """
23
+
24
+ import os
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import tempfile
29
+ from collections.abc import Iterable
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+
33
+ from pcode import worktree
34
+ from pcode.edits import edit_text, sensitive_path, source_lines
35
+ from pcode.runtime import EditCompleted
36
+ from pcode.tool_display import plain
37
+
38
+ # One file's raw diff beyond this is counted but not shown; a lockfile rewrite
39
+ # would otherwise stall redaction and the browser's search.
40
+ MAX_FILE_DIFF = 1024 * 1024
41
+ MAX_DIFF_LINES = 2000
42
+ OPERATIONS = {"A": "created", "C": "copied", "D": "deleted", "R": "renamed"}
43
+ # Pin everything user config can change about the output this module parses.
44
+ DIFF_OPTIONS = (
45
+ "--no-color",
46
+ "--no-ext-diff",
47
+ "--no-textconv",
48
+ "--no-relative",
49
+ "--find-renames",
50
+ "--src-prefix=a/",
51
+ "--dst-prefix=b/",
52
+ )
53
+
54
+
55
+ class GitDiffError(Exception):
56
+ """Git could not produce the review."""
57
+
58
+
59
+ # Each session's refs live under one prefix, so one sweep removes them with the
60
+ # session or its worktree: a ref keeps what it names from garbage collection,
61
+ # and no session file has to be written for it. The review checkpoint is
62
+ # `reviewed` (the tree last marked reviewed) and `review-base` (the base tree
63
+ # it was compared against then, since merging mainline moves the base).
64
+ SESSION_REFS = "refs/pcode/sessions/"
65
+
66
+
67
+ def session_ref(key: str, name: str) -> str:
68
+ """Session `key`'s ref called `name`; GitDiffError for a key git would misread."""
69
+ if not _valid_key(key):
70
+ raise GitDiffError("invalid session id")
71
+ return f"{SESSION_REFS}{key}/{name}"
72
+
73
+
74
+ def forget_session(workspace: Path, key: str) -> None:
75
+ """Delete every ref session `key` holds, releasing what they kept."""
76
+ prefix = session_ref(key, "")
77
+ listed = _git(workspace, "for-each-ref", "--format=%(refname)", prefix).decode().split()
78
+ if listed:
79
+ commands = "".join(f"delete {name}\n" for name in listed)
80
+ _git(workspace, "update-ref", "--stdin", input=commands.encode())
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Checkpoint:
85
+ """What was marked reviewed: the working tree, and the base it was compared with."""
86
+
87
+ tree: str
88
+ # None for a checkpoint whose base was not recorded; the current one stands in.
89
+ base: str | None = None
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Review:
94
+ """One load of /diffs: the session's net work, and two narrower cuts of it.
95
+
96
+ `changes` is the net work against its base (`title` says which).
97
+ `uncommitted` is what the next commit would take in, and `since_review` what
98
+ changed since the tree last marked reviewed (None when there is none). All
99
+ three describe `tree`, the working tree as recorded for this load, so marking
100
+ it reviewed records exactly what was shown. Paths are relative to `root`.
101
+ """
102
+
103
+ title: str
104
+ changes: list[EditCompleted]
105
+ empty: str
106
+ root: Path | None = None
107
+ uncommitted: list[EditCompleted] = field(default_factory=list)
108
+ since_review: list[EditCompleted] | None = None
109
+ tree: str | None = None
110
+ # The base's tree, recorded with `tree` when it is marked reviewed.
111
+ base_tree: str | None = None
112
+
113
+ @property
114
+ def checkpoint(self) -> Checkpoint | None:
115
+ return Checkpoint(self.tree, self.base_tree) if self.tree else None
116
+
117
+
118
+ def load_review(
119
+ workspace: Path,
120
+ edited: Iterable[str],
121
+ start: str | None = None,
122
+ since: str | None = None,
123
+ reviewed: Checkpoint | None = None,
124
+ ) -> Review | None:
125
+ """The review of `workspace`, or None outside a git repository.
126
+
127
+ `edited` holds the paths this session's file tools changed, relative to
128
+ the workspace; `start` is the commit the session began at and `since` when
129
+ (ISO 8601). Only a shared checkout uses them. `reviewed` is the checkpoint
130
+ last marked reviewed. Raises GitDiffError when git cannot produce the review.
131
+ """
132
+ try:
133
+ if worktree.project_checkout(workspace) is None:
134
+ return None
135
+ linked = worktree.describe(workspace)
136
+ except worktree.WorktreeError as error:
137
+ raise GitDiffError(str(error)) from error
138
+ if linked is not None:
139
+ top = _toplevel(linked.path)
140
+ base, title, empty = _branch_base(linked)
141
+ only = None
142
+ else:
143
+ top = _toplevel(workspace)
144
+ base, title, empty, only = _session_base(workspace, top, edited, start, since)
145
+ if only is not None and not only:
146
+ return Review(title, [], empty, root=top)
147
+ tree, secret = _snapshot(top, only)
148
+ unread = [
149
+ EditCompleted("", plain(path, limit=None), "created", omitted="Sensitive file; not read")
150
+ for path in sorted(secret)
151
+ ]
152
+ changes = _tree_diff(top, base, tree, only) + unread
153
+ uncommitted = changes if base == "HEAD" else _tree_diff(top, "HEAD", tree, only) + unread
154
+ since_review = None
155
+ if reviewed is not None:
156
+ try:
157
+ # Only the session's work: files that differ from the base now, or
158
+ # differed from the base of the time when reviewed. A file
159
+ # mainline's merge (or a pull) brought in differs from neither, so
160
+ # it is not news. The reviewed tree never held the unread files.
161
+ then = reviewed.base or base
162
+ ours = _names(top, base, tree, only) | _names(top, then, reviewed.tree, only)
163
+ since_review = _tree_diff(top, reviewed.tree, tree, ours) if ours else []
164
+ except GitDiffError:
165
+ since_review = None # a tree git no longer has: review everything
166
+ base_tree = _git(top, "rev-parse", f"{base}^{{tree}}").decode().strip()
167
+ return Review(
168
+ title,
169
+ sorted(changes, key=_path),
170
+ empty,
171
+ root=top,
172
+ uncommitted=sorted(uncommitted, key=_path),
173
+ since_review=since_review,
174
+ tree=tree,
175
+ base_tree=base_tree,
176
+ )
177
+
178
+
179
+ def _path(change: EditCompleted) -> str:
180
+ return change.path
181
+
182
+
183
+ def reviewed(workspace: Path, key: str) -> Checkpoint | None:
184
+ """The checkpoint last marked reviewed for session `key`, if git still has it."""
185
+ if not _valid_key(key):
186
+ return None
187
+ found = []
188
+ for name in ("reviewed", "review-base"):
189
+ try:
190
+ spec = f"{session_ref(key, name)}^{{tree}}"
191
+ out = _git(workspace, "rev-parse", "--verify", "--quiet", spec, check=False)
192
+ except GitDiffError:
193
+ return None
194
+ found.append(out.decode().strip() or None)
195
+ tree, base = found
196
+ return Checkpoint(tree, base) if tree else None
197
+
198
+
199
+ def mark_reviewed(workspace: Path, key: str, checkpoint: Checkpoint) -> None:
200
+ """Record `checkpoint` as session `key`'s reviewed state, both refs at once."""
201
+ names = [checkpoint.tree, *([checkpoint.base] if checkpoint.base else [])]
202
+ if not _valid_key(key) or not all(_is_object_name(name) for name in names):
203
+ raise GitDiffError("invalid review checkpoint")
204
+ commands = f"update {session_ref(key, 'reviewed')} {checkpoint.tree}\n"
205
+ base = session_ref(key, "review-base")
206
+ commands += f"update {base} {checkpoint.base}\n" if checkpoint.base else f"delete {base}\n"
207
+ _git(workspace, "update-ref", "--stdin", input=commands.encode())
208
+
209
+
210
+ def _is_object_name(name: str) -> bool:
211
+ return bool(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", name))
212
+
213
+
214
+ def _valid_key(key: str) -> bool:
215
+ # Stricter than git's ref rules: one plain path component, no "..", no
216
+ # ".lock" ending, so the key can never reach outside its prefix.
217
+ return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", key))
218
+
219
+
220
+ def _branch_base(linked: worktree.Worktree) -> tuple[str, str, str]:
221
+ """A linked worktree's whole branch, against its merge-base with mainline."""
222
+ try:
223
+ mainline = worktree.mainline_branch(linked.main)
224
+ except worktree.WorktreeError as error:
225
+ raise GitDiffError(str(error)) from error
226
+ base = _git(linked.path, "merge-base", "HEAD", mainline).decode().strip()
227
+ branch, mainline = plain(linked.branch), plain(mainline)
228
+ return (
229
+ base,
230
+ f"{branch} vs {mainline} (merge-base {_short(linked.path, base)})"
231
+ " · includes uncommitted and new files",
232
+ f"{branch} has nothing that {mainline} does not already have.",
233
+ )
234
+
235
+
236
+ def _session_base(
237
+ workspace: Path, top: Path, edited: Iterable[str], start: str | None, since: str | None
238
+ ) -> tuple[str, str, str, set[str]]:
239
+ """A shared checkout's session work: the working tree against the starting commit.
240
+
241
+ Limited to files this session edited or committed. Falls back to
242
+ uncommitted changes against HEAD, in edited files only, when no start was
243
+ recorded or HEAD no longer descends from it (a rebase, a branch switch).
244
+ """
245
+ edited_paths = _top_relative(workspace, top, edited)
246
+ if start is None or not _is_ancestor(top, start):
247
+ why = "no starting commit recorded" if start is None else "HEAD moved off the start"
248
+ return (
249
+ "HEAD",
250
+ f"uncommitted changes vs HEAD ({_short(top, 'HEAD')})"
251
+ f" · only files edited this session · {why}",
252
+ "No uncommitted changes in files edited this session.",
253
+ edited_paths,
254
+ )
255
+ return (
256
+ start,
257
+ f"since the session started ({_short(top, start)})"
258
+ " · files this session edited or committed, including uncommitted and new files",
259
+ "No changes since the session started in files it edited or committed.",
260
+ edited_paths | _committed(top, start, since),
261
+ )
262
+
263
+
264
+ def _toplevel(workspace: Path) -> Path:
265
+ top = Path(_git(workspace, "rev-parse", "--show-toplevel").decode().strip()).resolve()
266
+ if not _git(workspace, "rev-parse", "--verify", "--quiet", "HEAD^{commit}", check=False):
267
+ raise GitDiffError("the repository has no commits yet")
268
+ return top
269
+
270
+
271
+ def _top_relative(workspace: Path, top: Path, edited: Iterable[str]) -> set[str]:
272
+ paths = set()
273
+ for path in edited:
274
+ # Tool paths are relative to the workspace, which may sit below the top level.
275
+ location = Path(os.path.normpath(workspace / path))
276
+ if location.is_relative_to(top):
277
+ paths.add(location.relative_to(top).as_posix())
278
+ return paths
279
+
280
+
281
+ def _is_ancestor(top: Path, start: str) -> bool:
282
+ """Whether HEAD still descends from `start`; a commit git lost is not an ancestor."""
283
+ # The hash comes from session.json; never let it reach git as an option.
284
+ if not re.fullmatch(r"[0-9a-f]{40,64}", start):
285
+ return False
286
+ try:
287
+ _git(top, "merge-base", "--is-ancestor", start, "HEAD")
288
+ except GitDiffError:
289
+ return False
290
+ return True
291
+
292
+
293
+ def _committed(top: Path, start: str, since: str | None) -> set[str]:
294
+ """Paths touched by the session's own commits after `start`."""
295
+ args = ["log", "--format=", "--name-only", "-z", "--no-renames", "--no-merges"]
296
+ args.append("--no-show-signature")
297
+ if since:
298
+ args.append(f"--since={since}")
299
+ email = _git(top, "config", "user.email", check=False).decode().strip()
300
+ if email:
301
+ args += ["--fixed-strings", f"--committer=<{email}>"]
302
+ return _paths(_git(top, *args, f"{start}..HEAD", "--"))
303
+
304
+
305
+ def _snapshot(top: Path, only: set[str] | None = None) -> tuple[str, set[str]]:
306
+ """Record the working tree as a tree object, and the untracked secrets left out.
307
+
308
+ Untracked files are included, limited to `only` where given; the real
309
+ index is never written. Untracked files that look sensitive are not
310
+ hashed, so their contents never reach the object store.
311
+ """
312
+ index = Path(
313
+ _git(top, "rev-parse", "--path-format=absolute", "--git-path", "index").decode().strip()
314
+ )
315
+ descriptor, scratch = tempfile.mkstemp(prefix="pcode-diff-index-", dir=index.parent)
316
+ os.close(descriptor)
317
+ env = {**os.environ, "GIT_INDEX_FILE": scratch, "GIT_OPTIONAL_LOCKS": "0"}
318
+ try:
319
+ # Starting from the real index keeps staged work and its stat cache, so
320
+ # only files that differ from it are hashed below. copy2 keeps the
321
+ # index's mtime: git rechecks the content of any entry modified in the
322
+ # same second the index was written, and a fresh mtime would make a
323
+ # same-size edit from that second look unchanged.
324
+ if index.exists():
325
+ shutil.copy2(index, scratch)
326
+ else:
327
+ os.unlink(scratch)
328
+ untracked = _paths(_git(top, "ls-files", "-z", "--others", "--exclude-standard", env=env))
329
+ changed = _paths(_git(top, "ls-files", "-z", "--modified", "--deleted", env=env))
330
+ if only is not None:
331
+ untracked &= only
332
+ changed &= only
333
+ # A tree cannot hold a conflict, so a merge in progress outside `only`
334
+ # is recorded as its files stand; the diffs never look outside `only`.
335
+ unmerged = _paths(_git(top, "diff", "--name-only", "-z", "--diff-filter=U", env=env))
336
+ secret = {path for path in untracked if sensitive_path(path)}
337
+ stage = sorted(changed | unmerged | (untracked - secret))
338
+ if stage:
339
+ listing = b"\0".join(path.encode("utf-8", "surrogateescape") for path in stage)
340
+ _git(
341
+ top,
342
+ "add",
343
+ "--all",
344
+ "--pathspec-from-file=-",
345
+ "--pathspec-file-nul",
346
+ env={**env, "GIT_LITERAL_PATHSPECS": "1"},
347
+ input=listing,
348
+ )
349
+ tree = _git(top, "write-tree", env=env).decode().strip()
350
+ finally:
351
+ Path(scratch).unlink(missing_ok=True)
352
+ return tree, secret
353
+
354
+
355
+ DIFF_ENV = {"GIT_LITERAL_PATHSPECS": "1", "GIT_OPTIONAL_LOCKS": "0"}
356
+
357
+
358
+ def _names(top: Path, base: str, tree: str, only: set[str] | None) -> set[str]:
359
+ """Every path, either side of a rename, that differs between `base` and `tree`."""
360
+ paths = ["--", *sorted(only)] if only is not None else []
361
+ names = _git(
362
+ top,
363
+ "diff",
364
+ *DIFF_OPTIONS,
365
+ "--name-status",
366
+ "-z",
367
+ base,
368
+ tree,
369
+ *paths,
370
+ env={**os.environ, **DIFF_ENV},
371
+ )
372
+ found = {path for _, old, new in _entries(names) for path in (old, new)}
373
+ return found if only is None else found & only
374
+
375
+
376
+ def _tree_diff(top: Path, base: str, tree: str, only: set[str] | None) -> list[EditCompleted]:
377
+ """`tree` against `base`, one change per file, limited to `only` where given."""
378
+ env = {**os.environ, **DIFF_ENV}
379
+ # Limit git itself, not just the result: against a start commit, a pull
380
+ # would otherwise patch every file it brought in. A rename with one side
381
+ # outside `only` shows as one side.
382
+ paths = ["--", *sorted(only)] if only is not None else []
383
+ names = _git(top, "diff", *DIFF_OPTIONS, "--name-status", "-z", base, tree, *paths, env=env)
384
+ patch = _git(top, "diff", *DIFF_OPTIONS, "--patch", base, tree, *paths, env=env)
385
+ return [
386
+ _change(status, old, new, chunk)
387
+ for (status, old, new), chunk in _pair(_entries(names), patch)
388
+ if only is None or old in only or new in only
389
+ ]
390
+
391
+
392
+ def _pair(entries: list[tuple[str, str, str]], patch: bytes):
393
+ """Match each name-status entry with its patch text, both in git's file order."""
394
+ chunks = iter(chunk for chunk in re.split(rb"^(?=diff --git )", patch, flags=re.M) if chunk)
395
+ paired = []
396
+ for entry in entries:
397
+ # A type change (file to symlink) is printed as a deletion and a creation.
398
+ taken = [next(chunks, None) for _ in range(2 if entry[0] == "T" else 1)]
399
+ if None in taken:
400
+ raise GitDiffError("git listed more files than patches")
401
+ paired.append((entry, b"".join(taken)))
402
+ if next(chunks, None) is not None:
403
+ raise GitDiffError("git listed more patches than files")
404
+ return paired
405
+
406
+
407
+ def _change(status: str, old: str, new: str, chunk: bytes) -> EditCompleted:
408
+ path = plain(edit_text(new if old == new else f"{old} → {new}"), limit=None)
409
+ operation = OPERATIONS.get(status, "edited")
410
+ # Only "\n" ends a line, as in git's hunk counts; a CRLF file's "\r" goes.
411
+ text = chunk.decode("utf-8", "replace")
412
+ lines = [line.rstrip("\n").removesuffix("\r") for line in source_lines(text)]
413
+ added, removed = _counts(lines)
414
+ if sensitive_path(old) or sensitive_path(new):
415
+ return EditCompleted("", path, operation, "", added, removed, omitted="Sensitive file")
416
+ if len(chunk) > MAX_FILE_DIFF:
417
+ return EditCompleted(
418
+ "", path, operation, "", added, removed, omitted="Diff exceeds preview size limit"
419
+ )
420
+ # `diff --git` repeats the path and `index` only names blobs. Hunk lines
421
+ # always carry a prefix, so neither can be file content.
422
+ body = [line for line in lines if not line.startswith(("diff --git ", "index "))]
423
+ shown = edit_text("\n".join(body)).split("\n")
424
+ truncated = len(shown) > MAX_DIFF_LINES
425
+ patch = "\n".join(shown[:MAX_DIFF_LINES])
426
+ return EditCompleted("", path, operation, patch, added, removed, truncated)
427
+
428
+
429
+ def _counts(lines: list[str]) -> tuple[int, int]:
430
+ """Changed lines inside hunks; each file's `---`/`+++` headers come before its first.
431
+
432
+ A type change is two patches in one chunk, so a second `diff --git` header
433
+ leaves the first patch's hunks.
434
+ """
435
+ added = removed = 0
436
+ in_hunk = False
437
+ for line in lines:
438
+ if line.startswith("diff --git "):
439
+ in_hunk = False
440
+ elif line.startswith("@@"):
441
+ in_hunk = True
442
+ elif in_hunk and line.startswith("+"):
443
+ added += 1
444
+ elif in_hunk and line.startswith("-"):
445
+ removed += 1
446
+ return added, removed
447
+
448
+
449
+ def _entries(raw: bytes) -> list[tuple[str, str, str]]:
450
+ """`--name-status -z` records as (status, old path, new path)."""
451
+ fields = raw.split(b"\0")
452
+ entries = []
453
+ position = 0
454
+ while position < len(fields) and fields[position]:
455
+ status = fields[position].decode()[0]
456
+ if status in "RC":
457
+ old, new = fields[position + 1], fields[position + 2]
458
+ position += 3
459
+ else:
460
+ old = new = fields[position + 1]
461
+ position += 2
462
+ entries.append((status, _decode(old), _decode(new)))
463
+ return entries
464
+
465
+
466
+ def _paths(raw: bytes) -> set[str]:
467
+ return {_decode(path) for path in raw.split(b"\0") if path}
468
+
469
+
470
+ def _decode(path: bytes) -> str:
471
+ # Surrogate escapes round-trip a non-UTF-8 name back to git as a pathspec.
472
+ return path.decode("utf-8", "surrogateescape")
473
+
474
+
475
+ def _short(top: Path, revision: str) -> str:
476
+ return _git(top, "rev-parse", "--short", revision).decode().strip()
477
+
478
+
479
+ def _git(
480
+ cwd: Path, *args: str, env: dict | None = None, input: bytes | None = None, check: bool = True
481
+ ) -> bytes:
482
+ try:
483
+ result = subprocess.run(
484
+ ["git", "-C", str(cwd), "-c", "core.quotePath=false", *args],
485
+ capture_output=True,
486
+ env=env,
487
+ input=input,
488
+ check=False,
489
+ )
490
+ except OSError as error:
491
+ raise GitDiffError(f"git could not run: {error}") from error
492
+ if result.returncode:
493
+ if not check:
494
+ return b""
495
+ detail = result.stderr.decode("utf-8", "replace").strip() or f"exit {result.returncode}"
496
+ raise GitDiffError(f"git {args[0]} failed: {detail}")
497
+ return result.stdout