返回 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(&["diff", "HEAD"], cwd),
164 };
165 let Some(raw) = raw else {
166 return GitMentionPayload::unavailable(kind, "git command failed");
167 };
168
169 if raw.trim().is_empty() {
170 let reason = match kind {
171 GitMentionKind::Status => "working tree clean",
172 GitMentionKind::Diff => "no working-tree changes",
173 };
174 return GitMentionPayload::unavailable(kind, reason);
175 }
176
177 let (body, truncated) = truncate_on_char_boundary(&raw, kind.byte_budget());
178 let tag = match kind {
179 GitMentionKind::Status => "git-status",
180 GitMentionKind::Diff => "git-diff",
181 };
182 let truncated_attr = if truncated {
183 format!(
184 " truncated=\"true\" budget-bytes=\"{}\"",
185 kind.byte_budget()
186 )
187 } else {
188 String::new()
189 };
190 let block = format!(
191 "<{tag} mention=\"@{token}\" bytes=\"{bytes}\"{truncated_attr}>\n{body}\n</{tag}>",
192 token = kind.token(),
193 bytes = body.len(),
194 );
195
196 GitMentionPayload {
197 block,
198 bytes: body.len(),
199 truncated,
200 unavailable_reason: None,
201 }
202 }
203
204 /// `git status` plus the branch line, so the model does not have to infer the
205 /// branch from a porcelain listing.
206 fn git_status_payload(cwd: &Path) -> Option<String> {
207 let status = git_output(&["status", "--short", "--branch"], cwd)?;
208 Some(status)
209 }
210
211 /// True when `cwd` is inside a git work tree.
212 fn is_git_repository(cwd: &Path) -> bool {
213 git_output(&["rev-parse", "--is-inside-work-tree"], cwd).is_some_and(|out| out.trim() == "true")
214 }
215
216 /// Run git and return stdout, or `None` when the binary is missing or the
217 /// command exits non-zero.
218 fn git_output(args: &[&str], cwd: &Path) -> Option<String> {
219 let output = Git::output(args, cwd).ok()?;
220 if !output.status.success() {
221 return None;
222 }
223 Some(String::from_utf8_lossy(&output.stdout).into_owned())
224 }
225
226 /// Cut `text` to at most `budget` bytes without splitting a UTF-8 scalar.
227 /// Returns the slice and whether anything was dropped.
228 fn truncate_on_char_boundary(text: &str, budget: usize) -> (&str, bool) {
229 if text.len() <= budget {
230 return (text, false);
231 }
232 let mut end = budget;
233 while end > 0 && !text.is_char_boundary(end) {
234 end -= 1;
235 }
236 (&text[..end], true)
237 }
238
239 #[cfg(test)]
240 mod tests {
241 use super::*;
242 use std::process::Command;
243
244 fn init_repo(dir: &Path) {
245 for args in [
246 vec!["init", "--initial-branch=main"],
247 vec!["config", "user.email", "test@example.com"],
248 vec!["config", "user.name", "Test"],
249 ] {
250 let status = Command::new("git")
251 .args(&args)
252 .current_dir(dir)
253 .output()
254 .expect("git available in tests");
255 assert!(status.status.success(), "git {args:?} failed");
256 }
257 }
258
259 fn commit_all(dir: &Path, message: &str) {
260 Command::new("git")
261 .args(["add", "-A"])
262 .current_dir(dir)
263 .output()
264 .unwrap();
265 Command::new("git")
266 .args(["commit", "-m", message])
267 .current_dir(dir)
268 .output()
269 .unwrap();
270 }
271
272 #[test]
273 fn only_exact_tokens_are_git_mentions() {
274 assert_eq!(git_mention_kind("git"), Some(GitMentionKind::Status));
275 assert_eq!(git_mention_kind("Diff"), Some(GitMentionKind::Diff));
276 // Paths that merely start with the token stay file mentions.
277 assert_eq!(git_mention_kind("git/config"), None);
278 assert_eq!(git_mention_kind("diff.txt"), None);
279 assert_eq!(git_mention_kind("gitignore"), None);
280 }
281
282 #[test]
283 fn non_repository_directory_is_explicitly_unavailable() {
284 let dir = tempfile::tempdir().unwrap();
285 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
286 assert_eq!(payload.bytes, 0);
287 assert!(payload.block.contains("git-unavailable"));
288 assert!(
289 payload
290 .unavailable_reason
291 .as_deref()
292 .is_some_and(|r| r.contains("not a git repository")),
293 "unexpected reason: {:?}",
294 payload.unavailable_reason
295 );
296 }
297
298 #[test]
299 fn empty_repository_reports_clean_rather_than_an_empty_block() {
300 let dir = tempfile::tempdir().unwrap();
301 init_repo(dir.path());
302 std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
303 commit_all(dir.path(), "initial");
304
305 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
306 assert_eq!(payload.bytes, 0);
307 assert!(payload.block.contains("no working-tree changes"));
308 }
309
310 #[test]
311 fn status_reports_branch_and_dirty_paths() {
312 let dir = tempfile::tempdir().unwrap();
313 init_repo(dir.path());
314 std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
315 commit_all(dir.path(), "initial");
316 std::fs::write(dir.path().join("b.txt"), "new\n").unwrap();
317
318 let payload = resolve_git_mention(GitMentionKind::Status, dir.path());
319 assert!(payload.unavailable_reason.is_none());
320 assert!(payload.block.starts_with("<git-status mention=\"@git\""));
321 assert!(payload.block.contains("b.txt"), "{}", payload.block);
322 assert!(!payload.truncated);
323 }
324
325 #[test]
326 fn diff_covers_staged_and_unstaged_changes() {
327 let dir = tempfile::tempdir().unwrap();
328 init_repo(dir.path());
329 std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
330 std::fs::write(dir.path().join("b.txt"), "one\n").unwrap();
331 commit_all(dir.path(), "initial");
332
333 std::fs::write(dir.path().join("a.txt"), "staged\n").unwrap();
334 Command::new("git")
335 .args(["add", "a.txt"])
336 .current_dir(dir.path())
337 .output()
338 .unwrap();
339 std::fs::write(dir.path().join("b.txt"), "unstaged\n").unwrap();
340
341 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
342 assert!(payload.unavailable_reason.is_none());
343 assert!(payload.block.contains("staged"), "{}", payload.block);
344 assert!(payload.block.contains("unstaged"), "{}", payload.block);
345 }
346
347 #[test]
348 fn large_diff_truncates_at_the_documented_budget() {
349 let dir = tempfile::tempdir().unwrap();
350 init_repo(dir.path());
351 std::fs::write(dir.path().join("big.txt"), "seed\n").unwrap();
352 commit_all(dir.path(), "initial");
353
354 let bulk: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
355 std::fs::write(dir.path().join("big.txt"), bulk).unwrap();
356
357 let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
358 assert!(payload.truncated, "expected truncation");
359 assert!(payload.bytes <= MAX_GIT_DIFF_MENTION_BYTES);
360 assert!(payload.block.contains("truncated=\"true\""));
361 assert!(payload.block.contains("budget-bytes=\"32768\""));
362 }
363
364 #[test]
365 fn truncation_never_splits_a_utf8_scalar() {
366 // Budget lands mid-scalar: "é" is two bytes starting at index 1.
367 let (cut, truncated) = truncate_on_char_boundary("aéb", 2);
368 assert!(truncated);
369 assert_eq!(cut, "a");
370 }
371 }
372
372 lines RUST