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/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
|