Skip to main content

atomcode_core/ctx/
env.rs

1//! Session-start environment snapshot (C1 in CC's taxonomy).
2//!
3//! Captures git branch / HEAD / working-tree status **once** at session
4//! start (or on `ChangeDir`), memoized by ownership: the struct lives on
5//! `AgentLoop`, is constructed in `new()`, and renders into
6//! `build_system_prompt` as a dedicated section.
7//!
8//! ## Why snapshot, not live
9//!
10//! Git status changes every turn (every tool call shifts working tree
11//! state). If we re-read each turn, the system-prompt prefix changes
12//! every turn → prompt cache breaks → every turn re-bills full prefix.
13//! CC observed this tradeoff and chose snapshot-at-session-start with an
14//! explicit disclaimer in prompt text, so the model doesn't mistake
15//! stale info for live state. We follow that pattern.
16//!
17//! ## Graceful degradation
18//!
19//! Non-git directories, no git binary on PATH, detached HEAD, permission
20//! errors — all surface as `git: None` and the prompt section is simply
21//! omitted. No panics, no half-rendered sections.
22
23use std::path::Path;
24use std::process::Command;
25
26/// Git repository state at the moment of capture.
27#[derive(Debug, Clone, Default)]
28pub struct GitSnapshot {
29    /// Current branch name. `None` in detached-HEAD state.
30    pub branch: Option<String>,
31    /// One-line HEAD description: `"<short-sha> <subject>"`.
32    pub head_oneline: Option<String>,
33    /// `git status --short` output, truncated to [`STATUS_MAX_LINES`] lines.
34    pub status_short: String,
35    /// `true` when the working tree has uncommitted changes / untracked files.
36    pub is_dirty: bool,
37}
38
39/// Hard cap on `git status --short` lines rendered into prompt.
40/// Prevents a repo with 500 untracked files from blowing up system prompt.
41const STATUS_MAX_LINES: usize = 20;
42
43/// Session-start environment snapshot.
44///
45/// Only git is captured today. Extend by adding fields (e.g. `rust_toolchain`,
46/// `node_version`) and rendering them in [`as_prompt_section`].
47#[derive(Debug, Clone, Default)]
48pub struct EnvSnapshot {
49    pub git: Option<GitSnapshot>,
50}
51
52impl EnvSnapshot {
53    /// Capture the current git state at `wd`. Blocking I/O — acceptable
54    /// at session start / `ChangeDir`, which run at most a few times per
55    /// process. Returns an empty snapshot (`git: None`) on any failure.
56    pub fn capture(wd: &Path) -> Self {
57        // First gate: is `wd` inside a git work tree at all? This avoids
58        // stderr spam in plain directories where every `git` subcommand
59        // would complain identically.
60        if !is_git_repo(wd) {
61            return Self::default();
62        }
63
64        let branch = run_git(wd, &["branch", "--show-current"]).filter(|s| !s.is_empty());
65
66        let head_oneline = run_git(wd, &["log", "-1", "--format=%h %s"]).filter(|s| !s.is_empty());
67
68        let status_raw = run_git(wd, &["status", "--short"]).unwrap_or_default();
69        let is_dirty = !status_raw.trim().is_empty();
70
71        // Truncate aggressively — long status (e.g. first clone with many
72        // untracked files) shouldn't dominate the system prompt.
73        let status_short = truncate_status(&status_raw, STATUS_MAX_LINES);
74
75        Self {
76            git: Some(GitSnapshot {
77                branch,
78                head_oneline,
79                status_short,
80                is_dirty,
81            }),
82        }
83    }
84
85    /// Render as a `=== GIT STATUS ===` block for splicing into the
86    /// system prompt. Returns `String::new()` when no git state exists
87    /// (so the caller can unconditionally push the result without
88    /// checking — empty string is a no-op).
89    ///
90    /// Format (matches CC's snapshot disclaimer pattern):
91    ///
92    /// ```text
93    /// === GIT STATUS (snapshot at session start, not live) ===
94    /// Branch: main
95    /// HEAD:   06f4537 feat(tuix): /context 命令
96    /// Status: 3 files modified
97    ///  M src/foo.rs
98    ///  M src/bar.rs
99    /// ?? new.rs
100    ///
101    /// This is a snapshot from session start. Use `bash` + `git status`
102    /// to check live state.
103    /// ```
104    pub fn as_prompt_section(&self) -> String {
105        let Some(git) = self.git.as_ref() else {
106            return String::new();
107        };
108
109        let mut out = String::from("\n=== GIT STATUS (snapshot at session start, not live) ===\n");
110
111        if let Some(branch) = git.branch.as_deref() {
112            out.push_str(&format!("Branch: {}\n", branch));
113        } else {
114            out.push_str("Branch: (detached HEAD)\n");
115        }
116
117        if let Some(head) = git.head_oneline.as_deref() {
118            out.push_str(&format!("HEAD:   {}\n", head));
119        }
120
121        if git.is_dirty {
122            let line_count = git.status_short.lines().count();
123            out.push_str(&format!("Status: {} change(s)\n", line_count));
124            out.push_str(&git.status_short);
125            if !git.status_short.ends_with('\n') {
126                out.push('\n');
127            }
128        } else {
129            out.push_str("Status: clean\n");
130        }
131
132        out.push_str(
133            "\nThis is a snapshot from session start. \
134             Use `bash` + `git status` to check live state.\n",
135        );
136
137        out
138    }
139}
140
141/// True when `wd` is inside a git working tree (checked via
142/// `git rev-parse --is-inside-work-tree`). Returns `false` on any
143/// error or when stdout isn't the literal `"true"`.
144fn is_git_repo(wd: &Path) -> bool {
145    run_git(wd, &["rev-parse", "--is-inside-work-tree"])
146        .map(|s| s.trim() == "true")
147        .unwrap_or(false)
148}
149
150/// Run `git <args>` in `wd`, return trimmed stdout on exit-0. `None` on
151/// any failure (git missing, non-zero exit, non-UTF8 output). stderr is
152/// intentionally discarded — this is best-effort context enrichment and
153/// error spam doesn't help the user.
154fn run_git(wd: &Path, args: &[&str]) -> Option<String> {
155    let mut cmd = Command::new("git");
156    cmd.args(args)
157        .current_dir(wd)
158        // Suppress paging in case user has `pager.*` configured.
159        .env("GIT_PAGER", "cat")
160        .env("PAGER", "cat");
161    crate::process_utils::suppress_console_window_sync(&mut cmd);
162    let output = cmd.output().ok()?;
163    if !output.status.success() {
164        return None;
165    }
166    let s = String::from_utf8(output.stdout).ok()?;
167    Some(s.trim().to_string())
168}
169
170/// Truncate `git status --short` output to at most `max_lines` lines.
171/// When truncated, appends a summary line indicating how many were
172/// omitted so the model doesn't think the repo is smaller than it is.
173fn truncate_status(raw: &str, max_lines: usize) -> String {
174    let lines: Vec<&str> = raw.lines().collect();
175    if lines.len() <= max_lines {
176        return raw.to_string();
177    }
178    let kept: Vec<&str> = lines.iter().take(max_lines).copied().collect();
179    format!(
180        "{}\n... and {} more line(s)\n",
181        kept.join("\n"),
182        lines.len() - max_lines
183    )
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn empty_snapshot_renders_nothing() {
192        let snap = EnvSnapshot::default();
193        assert_eq!(snap.as_prompt_section(), "");
194    }
195
196    #[test]
197    fn capture_non_git_dir_returns_empty() {
198        let tmp = tempfile::tempdir().expect("tempdir");
199        let snap = EnvSnapshot::capture(tmp.path());
200        assert!(snap.git.is_none(), "non-git dir must yield git: None");
201        assert_eq!(snap.as_prompt_section(), "");
202    }
203
204    #[test]
205    fn snapshot_with_branch_renders_section() {
206        let snap = EnvSnapshot {
207            git: Some(GitSnapshot {
208                branch: Some("main".into()),
209                head_oneline: Some("abc1234 test commit".into()),
210                status_short: String::new(),
211                is_dirty: false,
212            }),
213        };
214        let out = snap.as_prompt_section();
215        assert!(out.contains("=== GIT STATUS"));
216        assert!(out.contains("snapshot at session start"));
217        assert!(out.contains("Branch: main"));
218        assert!(out.contains("HEAD:   abc1234 test commit"));
219        assert!(out.contains("Status: clean"));
220        // Disclaimer at the end
221        assert!(out.contains("Use `bash` + `git status` to check live state"));
222    }
223
224    #[test]
225    fn detached_head_shown_explicitly() {
226        let snap = EnvSnapshot {
227            git: Some(GitSnapshot {
228                branch: None, // detached
229                head_oneline: Some("deadbee detached state".into()),
230                status_short: String::new(),
231                is_dirty: false,
232            }),
233        };
234        let out = snap.as_prompt_section();
235        assert!(out.contains("(detached HEAD)"));
236    }
237
238    #[test]
239    fn dirty_status_includes_changes() {
240        let snap = EnvSnapshot {
241            git: Some(GitSnapshot {
242                branch: Some("feat/x".into()),
243                head_oneline: Some("abc1234 wip".into()),
244                status_short: " M src/foo.rs\n M src/bar.rs\n?? new.rs".into(),
245                is_dirty: true,
246            }),
247        };
248        let out = snap.as_prompt_section();
249        assert!(out.contains("Status: 3 change(s)"));
250        assert!(out.contains(" M src/foo.rs"));
251        assert!(out.contains("?? new.rs"));
252        assert!(!out.contains("Status: clean"));
253    }
254
255    #[test]
256    fn truncate_status_caps_long_output() {
257        let raw = (0..50)
258            .map(|i| format!(" M file_{}.rs", i))
259            .collect::<Vec<_>>()
260            .join("\n");
261        let out = truncate_status(&raw, 20);
262        let kept_lines = out.lines().filter(|l| l.starts_with(" M")).count();
263        assert_eq!(kept_lines, 20);
264        assert!(out.contains("... and 30 more line"));
265    }
266
267    #[test]
268    fn truncate_status_passthrough_when_under_cap() {
269        let raw = " M a.rs\n M b.rs";
270        let out = truncate_status(raw, 20);
271        assert_eq!(out, raw);
272        assert!(!out.contains("more line"));
273    }
274}