cc-plugin-codex 0.1.4__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.
@@ -0,0 +1,210 @@
1
+ """Gather git diff context for review. Claude never runs git itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+
10
+ from cc_plugin_codex.config import git_timeout_seconds
11
+ from cc_plugin_codex.schemas import ContextSummary
12
+
13
+ MAX_DIFF_BYTES = 200_000
14
+
15
+ _REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
16
+
17
+
18
+ class InvalidScopeError(ValueError):
19
+ """Raised when the requested diff scope is not recognized."""
20
+
21
+
22
+ class InvalidBaseError(ValueError):
23
+ """Raised when the base ref for scope=branch is malformed/unsafe."""
24
+
25
+
26
+ def _valid_ref(ref: str) -> bool:
27
+ """A conservative git ref/commit check: no leading dash, no option/shell chars."""
28
+ return bool(ref) and not ref.startswith("-") and bool(_REF_RE.match(ref))
29
+
30
+
31
+ SECRET_PATH_RE = re.compile(
32
+ r"(^|/)(\.env(\.|$)|.*\.env$|.*\.pem$|.*\.key$|id_rsa|id_ed25519|.*\.p12$)",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+ SECRET_VALUE_PATTERNS = [
37
+ re.compile(r"AKIA[0-9A-Z]{16}"),
38
+ re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"),
39
+ re.compile(r"xox[baprs]-[A-Za-z0-9-]{20,}"),
40
+ re.compile(r"(?i)(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/=-]{16,}"),
41
+ re.compile(
42
+ r"(?i)((?:api|access|secret|private)?_?(?:key|token|secret)\s*[:=]\s*['\"]?)[A-Za-z0-9._~+/=-]{16,}"
43
+ ),
44
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
45
+ ]
46
+
47
+
48
+ @dataclass
49
+ class ContextResult:
50
+ text: str
51
+ summary: ContextSummary
52
+ truncated: bool = False
53
+ truncation_hint: str | None = None
54
+ redacted_paths: list[str] = field(default_factory=list)
55
+ diff_bytes: int = 0 # full (pre-truncation) UTF-8 byte size of the redacted diff
56
+
57
+
58
+ def _git(cwd: str, *args: str) -> str:
59
+ timeout = git_timeout_seconds()
60
+ try:
61
+ proc = subprocess.run(
62
+ ["git", *args],
63
+ cwd=cwd,
64
+ capture_output=True,
65
+ text=True,
66
+ timeout=timeout,
67
+ check=False,
68
+ )
69
+ except subprocess.TimeoutExpired as exc:
70
+ raise RuntimeError(f"git {' '.join(args)} timed out after {timeout}s") from exc
71
+ if proc.returncode != 0:
72
+ raise RuntimeError(proc.stderr.strip() or "git failed")
73
+ return proc.stdout
74
+
75
+
76
+ def _base_exists(cwd: str, base: str) -> bool:
77
+ """Whether base resolves to a commit.
78
+
79
+ Syntactically safe but nonexistent refs should be reported as invalid_base,
80
+ not as a generic git/internal failure. This keeps branch-diff tools
81
+ repairable for agents.
82
+ """
83
+ timeout = git_timeout_seconds()
84
+ try:
85
+ proc = subprocess.run(
86
+ ["git", "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}"],
87
+ cwd=cwd,
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=timeout,
91
+ check=False,
92
+ )
93
+ except subprocess.TimeoutExpired as exc:
94
+ raise RuntimeError(f"git rev-parse timed out after {timeout}s") from exc
95
+ return proc.returncode == 0
96
+
97
+
98
+ def _diff_args(scope: str, base: str) -> list[str]:
99
+ # --no-ext-diff + --no-textconv prevent configured external/textconv diff drivers
100
+ # from executing commands during our own git call.
101
+ common = ["diff", "--no-ext-diff", "--no-textconv"]
102
+ if scope == "working_tree":
103
+ return common
104
+ if scope == "staged":
105
+ return [*common, "--cached"]
106
+ if scope == "branch":
107
+ if not _valid_ref(base):
108
+ raise InvalidBaseError(f"invalid base ref: {base!r}")
109
+ # --end-of-options ensures the ref can never be parsed as a git option.
110
+ return [*common, "--end-of-options", f"{base}...HEAD"]
111
+ raise InvalidScopeError(f"invalid scope: {scope}")
112
+
113
+
114
+ def _summary(cwd: str, diff_args: list[str]) -> ContextSummary:
115
+ numstat = _git(cwd, *diff_args, "--numstat")
116
+ files = added = removed = 0
117
+ for line in numstat.splitlines():
118
+ parts = line.split("\t")
119
+ if len(parts) != 3:
120
+ continue
121
+ files += 1
122
+ if parts[0].isdigit():
123
+ added += int(parts[0])
124
+ if parts[1].isdigit():
125
+ removed += int(parts[1])
126
+ return ContextSummary(files_changed=files, lines_added=added, lines_removed=removed)
127
+
128
+
129
+ def _diff_path_from_header(line: str) -> str:
130
+ spec = line[len("diff --git ") :]
131
+ try:
132
+ parts = shlex.split(spec)
133
+ except ValueError:
134
+ parts = spec.split()
135
+ if len(parts) >= 2:
136
+ path = parts[1]
137
+ return path[2:] if path.startswith("b/") else path
138
+ return spec
139
+
140
+
141
+ def _redact_secret_values(line: str) -> tuple[str, bool]:
142
+ redacted = False
143
+ out = line
144
+ for pattern in SECRET_VALUE_PATTERNS:
145
+
146
+ def repl(match: re.Match) -> str:
147
+ nonlocal redacted
148
+ redacted = True
149
+ if match.lastindex:
150
+ return f"{match.group(1)}[redacted: secret value]"
151
+ return "[redacted: secret value]"
152
+
153
+ out = pattern.sub(repl, out)
154
+ return out, redacted
155
+
156
+
157
+ def _redact(diff: str) -> tuple[str, list[str]]:
158
+ out_lines: list[str] = []
159
+ redacted: list[str] = []
160
+ skipping = False
161
+ current_path = ""
162
+ for line in diff.splitlines():
163
+ if line.startswith("diff --git "):
164
+ spec = line[len("diff --git ") :] # "a/<path> b/<path>" (paths may be quoted)
165
+ current_path = _diff_path_from_header(line)
166
+ skipping = bool(SECRET_PATH_RE.search(spec))
167
+ if skipping:
168
+ redacted.append(current_path or spec)
169
+ out_lines.append(line) # keep the real header so reviewers see the file
170
+ out_lines.append("[redacted: secret-looking file not sent]")
171
+ continue
172
+ if not skipping:
173
+ scan_line = (
174
+ line.startswith(("+", "-", " ")) and not line.startswith(("+++", "---"))
175
+ ) or line.startswith("Authorization:")
176
+ emit = line
177
+ if scan_line:
178
+ emit, changed = _redact_secret_values(line)
179
+ if changed and current_path and current_path not in redacted:
180
+ redacted.append(current_path)
181
+ out_lines.append(emit)
182
+ return "\n".join(out_lines), redacted
183
+
184
+
185
+ def gather_context(cwd: str, scope: str, base: str) -> ContextResult:
186
+ diff_args = _diff_args(scope, base) # raises InvalidScopeError / InvalidBaseError
187
+ if scope == "branch" and not _base_exists(cwd, base):
188
+ raise InvalidBaseError(f"base ref does not resolve to a commit: {base!r}")
189
+ summary = _summary(cwd, diff_args)
190
+ raw = _git(cwd, *diff_args)
191
+ text, redacted = _redact(raw)
192
+ truncated = False
193
+ hint = None
194
+ encoded = text.encode("utf-8", "replace")
195
+ diff_bytes = len(encoded) # the true size, reported even when we truncate below
196
+ if diff_bytes > MAX_DIFF_BYTES:
197
+ text = encoded[:MAX_DIFF_BYTES].decode("utf-8", "ignore")
198
+ truncated = True
199
+ hint = (
200
+ f"diff exceeded {MAX_DIFF_BYTES} bytes; use scope=staged, choose "
201
+ "a closer branch base, or call claude_ask with selected context"
202
+ )
203
+ return ContextResult(
204
+ text=text,
205
+ summary=summary,
206
+ truncated=truncated,
207
+ truncation_hint=hint,
208
+ redacted_paths=redacted,
209
+ diff_bytes=diff_bytes,
210
+ )