返回 CodeWhale
git_mention.rs
根目录 / crates / tui / src / tui / git_mention.rs
1 //! `@git` and `@diff` composer mentions (#4067).
2 //!
3 //! The `@` mention system is otherwise path-centric: every token resolves to
4 //! a file or directory. These two tokens resolve to *curated git context*
5 //! instead, so a user can attach "what is going on in this working tree"
6 //! inline rather than making the model spend a round-trip on `git_diff` or a
7 //! shell command that may need approval.
8 //!
9 //! Two deliberate boundaries:
10 //!
11 //! * **Read-only and bounded.** Only `git status` and `git diff` run, always
12 //! with an explicit byte budget. A repository with a huge working-tree diff
13 //! truncates with a visible marker rather than flooding the turn.
14 //! * **Honest when unavailable.** No git binary, or a directory that is not a
15 //! repository, produces an explicit `<git-unavailable>` block. A mention
16 //! never silently contributes nothing.
17
18 use std::path::Path;
19
20 use crate::dependencies::{ExternalTool, Git};
21
22 /// Byte ceiling for the inlined `@diff` payload. Documented here because the
23 /// context inspector reports the budget alongside actual size.
24 pub const MAX_GIT_DIFF_MENTION_BYTES: usize = 32 * 1024;
25 /// Byte ceiling for the inlined `@git` status summary. Status output is
26 /// bounded in practice, but an unignored `node_modules` can still produce
27 /// tens of thousands of lines.
28 pub const MAX_GIT_STATUS_MENTION_BYTES: usize = 8 * 1024;
29
30 /// Which curated git payload a mention token asks for.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32 pub enum GitMentionKind {
33 /// `@git` — a bounded `git status` summary plus the current branch.
34 Status,
35 /// `@diff` — the working-tree diff, staged and unstaged.
36 Diff,
37 }
38
39 impl GitMentionKind {
40 /// The mention token that selects this payload, without the `@`.
41 #[must_use]
42 pub fn token(self) -> &'static str {
43 match self {
44 Self::Status => "git",
45 Self::Diff => "diff",
46 }
47 }
48
49 /// Byte budget for the inlined payload.
50 #[must_use]
51 pub fn byte_budget(self) -> usize {
52 match self {
53 Self::Status => MAX_GIT_STATUS_MENTION_BYTES,
54 Self::Diff => MAX_GIT_DIFF_MENTION_BYTES,
55 }
56 }
57
58 /// Every git mention kind, in completion-menu order.
59 pub fn iter_all() -> impl Iterator<Item = Self> {
60 GIT_MENTION_KINDS.into_iter()
61 }
62
63 /// Short label for composer previews and the context inspector.
64 #[must_use]
65 pub fn label(self) -> &'static str {
66 match self {
67 Self::Status => "git status",
68 Self::Diff => "working-tree diff",
69 }
70 }
71 }
72
73 /// Every git mention token, in completion-menu order.
74 pub const GIT_MENTION_KINDS: [GitMentionKind; 2] = [GitMentionKind::Status, GitMentionKind::Diff];
75
76 /// Classify a raw mention token. Case-insensitive so `@Git` and `@Diff`
77 /// behave like the lowercase spellings; a path that merely *starts* with
78 /// `git` (`@git/config`, `@diff.txt`) stays a file mention.
79 #[must_use]
80 pub fn git_mention_kind(raw: &str) -> Option<GitMentionKind> {
81 let token = raw.trim();
82 GIT_MENTION_KINDS
83 .into_iter()
84 .find(|kind| token.eq_ignore_ascii_case(kind.token()))
85 }
86
87 /// Outcome of resolving a git mention against a working directory.
88 #[derive(Debug, Clone, PartialEq, Eq)]
89 pub struct GitMentionPayload {
90 /// The model-facing block, already wrapped in its tag.
91 pub block: String,
92 /// Byte size of the payload actually inlined (the tag itself excluded).
93 pub bytes: usize,
94 /// Whether the payload hit its budget and was cut.
95 pub truncated: bool,
96 /// Present when git could not produce the payload at all.
97 pub unavailable_reason: Option<String>,
98 }
99
100 impl GitMentionPayload {
101 fn unavailable(kind: GitMentionKind, reason: &str) -> Self {
102 Self {
103 block: format!(
104 "<git-unavailable mention=\"@{token}\" reason=\"{reason}\" />",
105 token = kind.token(),
106 ),
107 bytes: 0,
108 truncated: false,
109 unavailable_reason: Some(reason.to_string()),
110 }
111 }
112 }
113
114 /// Per-submit memo for resolved git mentions.
115 ///
116 /// One message send resolves mentions twice — once to build the context
117 /// inspector references and once to build the model-facing payload. For
118 /// `@diff` each resolution makes git compute the *entire* working-tree diff
119 /// before the 32 KB budget applies, so a large repository paid for that twice
120 /// to attach it once.
121 ///
122 /// Scoped deliberately: a cache lives for one submit and is then dropped, so a
123 /// second `@diff` in a later message always re-shells out and can never show a
124 /// stale working tree.
125 #[derive(Debug, Default)]
126 pub struct GitMentionCache {
127 resolved: std::collections::HashMap<(GitMentionKind, std::path::PathBuf), GitMentionPayload>,
128 }
129
130 impl GitMentionCache {
131 /// Number of distinct mentions resolved so far this submit. Used by tests
132 /// to prove one submit shells out once per mention.
133 #[cfg(test)]
134 #[must_use]
135 pub fn len(&self) -> usize {
136 self.resolved.len()
137 }
138
139 /// Resolve `kind` against `workspace`, reusing this submit's result.
140 pub fn resolve(&mut self, kind: GitMentionKind, workspace: &Path) -> &GitMentionPayload {
141 self.resolved
142 .entry((kind, workspace.to_path_buf()))
143 .or_insert_with(|| resolve_git_mention(kind, workspace))
144 }
145 }
146
147 /// Run the git commands for `kind` in `cwd` and render the model-facing block.
148 ///
149 /// Never returns an error: an unavailable git, a non-repository directory, or
150 /// a failing command all resolve to an explicit `<git-unavailable>` block so
151 /// the turn records why the mention contributed nothing.
152 #[must_use]
153 pub fn resolve_git_mention(kind: GitMentionKind, cwd: &Path) -> GitMentionPayload {
154 if !Git::available() {
155 return GitMentionPayload::unavailable(kind, "git not found on PATH");
156 }
157 if !is_git_repository(cwd) {
158 return GitMentionPayload::unavailable(kind, "not a git repository");
159 }
160
161 let raw = match kind {
162 GitMentionKind::Status => git_status_payload(cwd),
163 GitMentionKind::Diff => git_output(
164 &[&["diff"][..], &Git::REVIEW_DIFF_ARGS[..], &["HEAD"][..]].concat(),
165 cwd,
166 ),
167 };
168 let Some(raw) = raw else {
169 return GitMentionPayload::unavailable(kind, "git command failed");
170 };
171
172 if raw.trim().is_empty() {
173 let reason = match kind {
174 GitMentionKind::Status => "working tree clean",
175 GitMentionKind::Diff => "no working-tree changes",
176 };
177 return GitMentionPayload::unavailable(kind, reason);
178 }
179
180 let (body, truncated) = truncate_on_char_boundary(&raw, kind.byte_budget());
181 let tag = match kind {
182 GitMentionKind::Status => "git-status",
183 GitMentionKind::Diff => "git-diff",
184 };
185 let truncated_attr = if truncated {
186 format!(
187 " truncated=\"true\" budget-bytes=\"{}\"",
188 kind.byte_budget()
189 )
190 } else {
191 String::new()
192 };
193 let block = format!(
194 "<{tag} mention=\"@{token}\" bytes=\"{bytes}\"{truncated_attr}>\n{body}\n</{tag}>",
195 token = kind.token(),
196 bytes = body.len(),
197 );
198
199 GitMentionPayload {
200 block,
201 bytes: body.len(),
202 truncated,
203 unavailable_reason: None,
204 }
205 }
206
207 /// `git status` plus the branch line, so the model does not have to infer the
208 /// branch from a porcelain listing.
209 fn git_status_payload(cwd: &Path) -> Option<String> {
210 let status = git_output(&["status", "--short", "--branch"], cwd)?;
211 Some(status)
212 }
213
214 /// True when `cwd` is inside a git work tree.
215 fn is_git_repository(cwd: &Path) -> bool {
216 git_output(&["rev-parse", "--is-inside-work-tree"], cwd).is_some_and(|out| out.trim() == "true")
217 }
218
219 /// Run git and return stdout, or `None` when the binary is missing or the
220 /// command exits non-zero.
221 ///
222 /// Runs under [`Git::review_command`], so repository-configured filters,
223 /// fsmonitor and hooks do not execute while reading the working tree.
224 fn git_output(args: &[&str], cwd: &Path) -> Option<String> {
225 let output = Git::review_command(cwd).ok()?.args(args).output().ok()?;
226 if !output.status.success() {
227 return None;
228 }
229 Some(String::from_utf8_lossy(&output.stdout).into_owned())
230 }
231
232 /// Cut `text` to at most `budget` bytes without splitting a UTF-8 scalar.
233 /// Returns the slice and whether anything was dropped.
234 fn truncate_on_char_boundary(text: &str, budget: usize) -> (&str, bool) {
235 if text.len() <= budget {
236 return (text, false);
237 }
238 let mut end = budget;
239 while end > 0 && !text.is_char_boundary(end) {
240 end -= 1;
241 }
242 (&text[..end], true)
243 }
244
245 #[cfg(test)]
246 mod tests {
247 use super::*;
248 use std::process::Command;
249
250 fn init_repo(dir: &Path) {
251 for args in [
252 vec!["init", "--initial-branch=main"],
253 vec!["config", "user.email", "test@example.com"],
254 vec!["config", "user.name", "Test"],
255 ] {
256 let status = Command::new("git")
257 .args(&args)
258 .current_dir(dir)
259 .output()
260 .expect("git available in tests");
261 assert!(status.status.success(), "git {args:?} failed");
262 }
263 }
264
265 fn commit_all(dir: &Path, message: &str) {
266 Command::new("git")
267 .args(["add", "-A"])
268 .current_dir(dir)
269 .output()
270 .unwrap();
271 Command::new("git")
272 .args(["commit", "-m", message])
273 .current_dir(dir)
274 .output()
275 .unwrap();
276 }
277
278 #[test]
279 fn only_exact_tokens_are_git_mentions() {
280 assert_eq!(git_mention_kind("git"), Some(GitMentionKind::Status));
281 assert_eq!(git_mention_kind("Diff"), Some(GitMentionKind::Diff));
282 // Paths that merely start with the token stay file mentions.
283 assert_eq!(git_mention_kind("git/config"), None);
284 assert_eq!(git_mention_kind("diff.txt"), None);
285 assert_eq!(git_mention_kind("gitignore"), None);
286 }
287
288 #[test]
289 fn non_repository_directory_is_explicitly_unavailable() {
290 let dir = tempfile::tempdir().unwrap();
291 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
292 assert_eq!(payload.bytes, 0);
293 assert!(payload.block.contains("git-unavailable"));
294 assert!(
295 payload
296 .unavailable_reason
297 .as_deref()
298 .is_some_and(|r| r.contains("not a git repository")),
299 "unexpected reason: {:?}",
300 payload.unavailable_reason
301 );
302 }
303
304 #[test]
305 fn empty_repository_reports_clean_rather_than_an_empty_block() {
306 let dir = tempfile::tempdir().unwrap();
307 init_repo(dir.path());
308 std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
309 commit_all(dir.path(), "initial");
310
311 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
312 assert_eq!(payload.bytes, 0);
313 assert!(payload.block.contains("no working-tree changes"));
314 }
315
316 /// `@diff` and `@git` read the working tree without running the
317 /// repository's external diff, textconv or clean filter.
318 #[cfg(unix)]
319 #[test]
320 fn mentions_run_no_repository_configured_commands() {
321 use std::os::unix::fs::PermissionsExt;
322 let dir = tempfile::tempdir().unwrap();
323 let outside = tempfile::tempdir().unwrap();
324 let marker = outside.path().join("marker");
325 let script = outside.path().join("helper.sh");
326 std::fs::write(
327 &script,
328 format!("#!/bin/sh\necho ran >> '{}'\ncat\n", marker.display()),
329 )
330 .unwrap();
331 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
332 let script = script.display().to_string();
333 init_repo(dir.path());
334 std::fs::write(
335 dir.path().join(".gitattributes"),
336 "a.txt diff=conv filter=x\n",
337 )
338 .unwrap();
339 std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
340 commit_all(dir.path(), "initial");
341 for (key, value) in [
342 ("filter.x.clean", &script),
343 ("diff.conv.textconv", &script),
344 ("diff.external", &script),
345 ] {
346 let status = Command::new("git")
347 .args(["config", key, value])
348 .current_dir(dir.path())
349 .status()
350 .unwrap();
351 assert!(status.success());
352 }
353 std::fs::write(dir.path().join("a.txt"), "changed\n").unwrap();
354
355 let diff = resolve_git_mention(GitMentionKind::Diff, dir.path());
356 let status = resolve_git_mention(GitMentionKind::Status, dir.path());
357 assert!(
358 !marker.exists(),
359 "a mention ran a repository-configured command"
360 );
361 assert!(diff.block.contains("+changed"), "{}", diff.block);
362 assert!(status.block.contains("a.txt"), "{}", status.block);
363 }
364
365 #[test]
366 fn status_reports_branch_and_dirty_paths() {
367 let dir = tempfile::tempdir().unwrap();
368 init_repo(dir.path());
369 std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
370 commit_all(dir.path(), "initial");
371 std::fs::write(dir.path().join("b.txt"), "new\n").unwrap();
372
373 let payload = resolve_git_mention(GitMentionKind::Status, dir.path());
374 assert!(payload.unavailable_reason.is_none());
375 assert!(payload.block.starts_with("<git-status mention=\"@git\""));
376 assert!(payload.block.contains("b.txt"), "{}", payload.block);
377 assert!(!payload.truncated);
378 }
379
380 #[test]
381 fn diff_covers_staged_and_unstaged_changes() {
382 let dir = tempfile::tempdir().unwrap();
383 init_repo(dir.path());
384 std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
385 std::fs::write(dir.path().join("b.txt"), "one\n").unwrap();
386 commit_all(dir.path(), "initial");
387
388 std::fs::write(dir.path().join("a.txt"), "staged\n").unwrap();
389 Command::new("git")
390 .args(["add", "a.txt"])
391 .current_dir(dir.path())
392 .output()
393 .unwrap();
394 std::fs::write(dir.path().join("b.txt"), "unstaged\n").unwrap();
395
396 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
397 assert!(payload.unavailable_reason.is_none());
398 assert!(payload.block.contains("staged"), "{}", payload.block);
399 assert!(payload.block.contains("unstaged"), "{}", payload.block);
400 }
401
402 #[test]
403 fn large_diff_truncates_at_the_documented_budget() {
404 let dir = tempfile::tempdir().unwrap();
405 init_repo(dir.path());
406 std::fs::write(dir.path().join("big.txt"), "seed\n").unwrap();
407 commit_all(dir.path(), "initial");
408
409 let bulk: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
410 std::fs::write(dir.path().join("big.txt"), bulk).unwrap();
411
412 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
413 assert!(payload.truncated, "expected truncation");
414 assert!(payload.bytes <= MAX_GIT_DIFF_MENTION_BYTES);
415 assert!(payload.block.contains("truncated=\"true\""));
416 assert!(payload.block.contains("budget-bytes=\"32768\""));
417 }
418
419 #[test]
420 fn truncation_never_splits_a_utf8_scalar() {
421 // Budget lands mid-scalar: "é" is two bytes starting at index 1.
422 let (cut, truncated) = truncate_on_char_boundary("aéb", 2);
423 assert!(truncated);
424 assert_eq!(cut, "a");
425 }
426 }
427
427 lines RUST