1use anyhow::{Context, Result, bail};
3use std::path::Path;
4use std::process::Stdio;
5use std::time::Duration;
6use tokio::io::AsyncReadExt;
7use vtcode_memory::explanation::WorkspaceDiffSnapshot;
8
9const MAX_DIFF_BYTES: usize = 8 * 1024;
10
11struct GitOutput {
12 text: String,
13 truncated: bool,
14 success: bool,
15 exit_code: Option<i32>,
16}
17
18pub async fn capture_workspace_diff(workspace: &Path) -> WorkspaceDiffSnapshot {
21 let mut snapshot = WorkspaceDiffSnapshot {
22 captured_at: chrono::Utc::now().to_rfc3339(),
23 text: None,
24 truncated: false,
25 note: String::new(),
26 };
27 let result = tokio::time::timeout(Duration::from_secs(2), async {
28 let root = vtcode_commons::canonicalize(workspace).context("resolve workspace for current Git state")?;
29 read_diff(&root).await
30 })
31 .await;
32 snapshot.note = match result {
33 Ok(Ok(output)) => {
34 snapshot.text = Some(output.text);
35 snapshot.truncated = output.truncated;
36 "Tracked changes against HEAD; untracked files excluded. Ownership is not attributed to the agent."
37 .to_owned()
38 }
39 Ok(Err(error)) => format!("Current Git diff unavailable: {error:#}"),
40 Err(_) => "Current Git diff unavailable: capture timed out".to_owned(),
41 };
42 snapshot.note = vtcode_commons::sanitizer::redact_secrets(snapshot.note)
43 .chars()
44 .filter(|c| !c.is_control())
45 .take(2048)
46 .collect();
47 snapshot
48}
49
50async fn read_diff(workspace: &Path) -> Result<GitOutput> {
51 let (_config_directory, mut config) = super::worktree::git_command_at(workspace)?;
54 configure_read_only(&mut config);
55 config.args([
56 "config",
57 "--null",
58 "--get-regexp",
59 r"^filter\..*\.(clean|process|required)$",
60 ]);
61 let filters = read_output(config, MAX_DIFF_BYTES).await?;
62 if filters.truncated || !filters.success && !(filters.exit_code == Some(1) && filters.text.is_empty()) {
63 bail!("Git filter configuration could not be safely inspected");
64 }
65 let (_directory, mut command) = super::worktree::git_command_at(workspace)?;
66 configure_read_only(&mut command);
67 for record in filters.text.split('\0').filter(|record| !record.is_empty()) {
68 let (key, _) = record.split_once('\n').context("invalid Git filter configuration")?;
69 if key.len() > 256 || !key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) {
70 bail!("Git filter name cannot be safely overridden");
71 }
72 command.args([
73 "-c",
74 &format!("{key}={}", if key.ends_with(".required") { "false" } else { "" }),
75 ]);
76 }
77 command.args([
78 "diff",
79 "--no-ext-diff",
80 "--no-textconv",
81 "--no-renames",
82 "--color=never",
83 "HEAD",
84 "--",
85 ".",
86 ]);
87 let mut output = read_output(command, MAX_DIFF_BYTES + 1024).await?;
89 if !output.truncated && !output.success {
90 bail!("Git or HEAD is unavailable for this workspace");
91 }
92 output.truncated |= output.text.len() > MAX_DIFF_BYTES;
93 let mut text = vtcode_commons::sanitizer::redact_secrets(output.text);
94 let mut end = text.len().min(MAX_DIFF_BYTES);
95 while !text.is_char_boundary(end) {
96 end = end.saturating_sub(1);
97 }
98 text.truncate(end);
99 output.text = text.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').collect();
100 Ok(output)
101}
102
103fn configure_read_only(command: &mut std::process::Command) {
104 command.env_clear();
105 for key in ["PATH", "SystemRoot"] {
106 if let Some(value) = std::env::var_os(key) {
107 command.env(key, value);
108 }
109 }
110 command
111 .env("GIT_CONFIG_NOSYSTEM", "1")
112 .args(["-c", "core.fsmonitor=false", "--no-pager"])
113 .stdout(Stdio::piped())
114 .stderr(Stdio::null())
115 .stdin(Stdio::null());
116}
117
118async fn read_output(command: std::process::Command, limit: usize) -> Result<GitOutput> {
119 let mut command = tokio::process::Command::from(command);
120 command.kill_on_drop(true);
121 let mut child = command.spawn().context("launch read-only Git diff")?;
122 let stdout = child.stdout.take().context("Git diff stdout unavailable")?;
123 let mut bytes = Vec::with_capacity(limit + 1);
124 stdout
125 .take((limit + 1) as u64)
126 .read_to_end(&mut bytes)
127 .await
128 .context("read Git diff")?;
129 let truncated = bytes.len() > limit;
130 if truncated {
131 child.kill().await.context("stop bounded Git diff")?;
132 }
133 let status = child.wait().await.context("wait for Git diff")?;
134 Ok(GitOutput {
135 text: String::from_utf8_lossy(&bytes).into_owned(),
136 truncated,
137 success: status.success(),
138 exit_code: status.code(),
139 })
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn git(workspace: &Path, args: &[&str]) {
147 let output = std::process::Command::new("git")
148 .current_dir(workspace)
149 .args(args)
150 .output()
151 .unwrap();
152 assert!(output.status.success(), "Git setup failed: {}", String::from_utf8_lossy(&output.stderr));
153 }
154
155 fn repository() -> tempfile::TempDir {
156 let temp = tempfile::tempdir().unwrap();
157 git(temp.path(), &["init", "--quiet"]);
158 std::fs::write(temp.path().join("tracked.txt"), "original\n").unwrap();
159 std::fs::write(temp.path().join(".gitattributes"), "tracked.txt filter=probe diff=probe\n").unwrap();
160 git(temp.path(), &["add", "."]);
161 git(
162 temp.path(),
163 &[
164 "-c",
165 "user.name=Test",
166 "-c",
167 "user.email=test@example.invalid",
168 "commit",
169 "--quiet",
170 "-m",
171 "fixture",
172 ],
173 );
174 temp
175 }
176
177 #[tokio::test]
178 async fn workspace_snapshot_is_separate_bounded_redacted_and_disables_extensions() {
179 let temp = repository();
180 for (key, value) in [
181 ("filter.probe.clean", "touch filter-ran; cat"),
182 ("filter.probe.process", "touch process-ran; cat"),
183 ("filter.probe.required", "true"),
184 ("diff.probe.textconv", "touch textconv-ran; cat"),
185 ("diff.external", "touch external-ran"),
186 ("core.fsmonitor", "touch fsmonitor-ran"),
187 ] {
188 git(temp.path(), &["config", key, value]);
189 }
190 let secret = concat!("sk-", "test1234567890abcdefghijklmnop");
191 std::fs::write(temp.path().join("tracked.txt"), format!("human edit {secret}\n{}", "界\n".repeat(10000)))
192 .unwrap();
193 std::fs::write(temp.path().join("untracked.txt"), "untracked human edit").unwrap();
194 let snapshot = capture_workspace_diff(temp.path()).await;
195 let text = snapshot
196 .text
197 .as_ref()
198 .unwrap_or_else(|| panic!("snapshot unavailable: {}", snapshot.note));
199 assert!(text.contains("human edit"));
200 assert!(!text.contains(secret));
201 assert!(text.len() <= MAX_DIFF_BYTES);
202 assert!(snapshot.truncated);
203 assert!(!text.contains("untracked.txt"));
204 assert!(snapshot.note.contains("Ownership is not attributed"));
205 for name in [
206 "filter-ran",
207 "process-ran",
208 "textconv-ran",
209 "external-ran",
210 "fsmonitor-ran",
211 ] {
212 assert!(!temp.path().join(name).exists(), "Git extension executed: {name}");
213 }
214 }
215
216 #[tokio::test]
217 async fn clean_and_unavailable_workspaces_remain_distinguishable() {
218 let temp = repository();
219 let clean = capture_workspace_diff(temp.path()).await;
220 assert_eq!(clean.text.as_deref(), Some(""), "{}", clean.note);
221 assert!(!clean.truncated);
222 let other = tempfile::tempdir().unwrap();
223 let unavailable = capture_workspace_diff(other.path()).await;
224 assert!(unavailable.text.is_none());
225 assert!(unavailable.note.contains("unavailable"));
226 }
227}