1use std::path::Path;
24use std::process::Command;
25
26#[derive(Debug, Clone, Default)]
28pub struct GitSnapshot {
29 pub branch: Option<String>,
31 pub head_oneline: Option<String>,
33 pub status_short: String,
35 pub is_dirty: bool,
37}
38
39const STATUS_MAX_LINES: usize = 20;
42
43#[derive(Debug, Clone, Default)]
48pub struct EnvSnapshot {
49 pub git: Option<GitSnapshot>,
50}
51
52impl EnvSnapshot {
53 pub fn capture(wd: &Path) -> Self {
57 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 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 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
141fn 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
150fn run_git(wd: &Path, args: &[&str]) -> Option<String> {
155 let mut cmd = Command::new("git");
156 cmd.args(args)
157 .current_dir(wd)
158 .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
170fn 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 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, 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}