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