Skip to main content

vtcode_core/git/
workspace_diff.rs

1//! Read-only, bounded current Git state, independent of agent attribution.
2use 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
18/// Capture tracked changes against HEAD without executing Git extensions.
19/// Missing Git/HEAD, timeout, and I/O failures remain explicit in the snapshot.
20pub 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    // Git diff can run clean/process filters even with --no-textconv.
52    // Override every configured driver, or fail closed when inspection fails.
53    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    // Carry beyond the visible boundary before redacting split secrets.
88    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}