Skip to main content

ocserve_http/
vcs.rs

1//! `/vcs/*` — git status/diff/info, freeze-parity (WEBUI-PLAN W5).
2//!
3//! Shapes probed live against opencode 1.18.31 (2026-10-08):
4//! - `GET /vcs` → `{branch, default_branch}` (strings or null)
5//! - `GET /vcs/status` → `FileStatus[]` `{file, additions, deletions, status}`
6//!   (`status` ∈ added|deleted|modified), sorted by file
7//! - `GET /vcs/diff?mode=git|branch[&context=N]` → `FileDiff[]`
8//!   `{file, patch, additions, deletions, status}`
9//! - `GET /vcs/diff/raw` → the raw patch text (empty string when clean)
10//! - bad `mode` → 400 Effect Query envelope
11//!
12//! Subprocess discipline (the "large diffs are slow" complaint): a single
13//! `status --porcelain` + a single `diff --numstat` for stats, and ONE
14//! batched `diff --patch` for all tracked files (`git diff … -- .`), split
15//! by `diff --git` headers — never one spawn per file. Untracked files are
16//! synthesized per-file like freeze (unavoidable: `--no-index` is per path),
17//! capped. Total patch bytes capped at 10 MB (freeze `MAX_TOTAL_PATCH_BYTES`).
18//! All git runs off the async workers via `run_blocking` at the call site.
19//!
20//! `/vcs/apply` applies a patch to the worktree via `git apply -` (SNAPSHOT
21//! §5); `vcs::apply` is the shell-out used by the v2 handler.
22
23use serde_json::{Value, json};
24use std::process::Command;
25
26const MAX_TOTAL_PATCH_BYTES: usize = 10_000_000;
27/// Full context, matching freeze PATCH_CONTEXT_LINES (2^31-1) semantics when
28/// the caller passes no `context`.
29const FULL_CONTEXT: &str = "2147483647";
30
31fn run_git(dir: &str, args: &[&str]) -> Option<(String, i32)> {
32    let out = Command::new("git")
33        .current_dir(dir)
34        .args(args)
35        .output()
36        .ok()?;
37    let text = String::from_utf8_lossy(&out.stdout).into_owned();
38    Some((text, out.status.code().unwrap_or(-1)))
39}
40
41/// `git status --porcelain=v1 -z --untracked-files=all --no-renames`.
42/// Returns (file, code2) where code2 is the two-char XY.
43fn status_entries(dir: &str) -> Vec<(String, String)> {
44    let Some((text, _)) = run_git(
45        dir,
46        &[
47            "status",
48            "--porcelain=v1",
49            "--untracked-files=all",
50            "--no-renames",
51            "-z",
52            "--",
53            ".",
54        ],
55    ) else {
56        return vec![];
57    };
58    let mut out = Vec::new();
59    for item in text.split('\0').filter(|s| !s.is_empty()) {
60        if item.len() < 3 {
61            continue;
62        }
63        let code = item[..2].to_string();
64        let file = item[3..].to_string();
65        out.push((file, code));
66    }
67    out
68}
69
70/// `git diff --numstat -z <ref>` → file → (additions, deletions).
71fn numstat(dir: &str, ref_: &str) -> std::collections::HashMap<String, (i64, i64)> {
72    let Some((text, _)) = run_git(
73        dir,
74        &[
75            "diff",
76            "--no-ext-diff",
77            "--no-renames",
78            "--numstat",
79            "-z",
80            ref_,
81            "--",
82            ".",
83        ],
84    ) else {
85        return std::collections::HashMap::new();
86    };
87    let mut map = std::collections::HashMap::new();
88    // -z format: "add\tdel\tfile\0" (renames would add a second path, but
89    // --no-renames suppresses them)
90    for item in text.split('\0').filter(|s| !s.is_empty()) {
91        let mut it = item.splitn(3, '\t');
92        let (Some(a), Some(d), Some(f)) = (it.next(), it.next(), it.next()) else {
93            continue;
94        };
95        let add = a.parse::<i64>().unwrap_or(0);
96        let del = d.parse::<i64>().unwrap_or(0);
97        map.insert(f.to_string(), (add, del));
98    }
99    map
100}
101
102fn kind(code: &str) -> &'static str {
103    // freeze kind(code): A→added, D→deleted, else modified
104    if code.starts_with('A') || code == "??" {
105        "added"
106    } else if code.starts_with('D') {
107        "deleted"
108    } else {
109        "modified"
110    }
111}
112
113/// Maximum patch size accepted by `apply` (SNAPSHOT §5). A larger patch is
114/// refused as `not-clean` (upstream `git.applyPatch` would also fail/misbehave
115/// on an absurd patch; this bounds the write).
116const MAX_APPLY_PATCH_BYTES: usize = 64 * 1024 * 1024;
117
118/// `git apply -` in `dir` with `patch` on stdin (upstream `Git.applyPatch`).
119/// `None` when `dir` is not inside a git work tree; `Some(ok)` otherwise.
120pub fn apply(dir: &str, patch: &str) -> Option<bool> {
121    if patch.len() > MAX_APPLY_PATCH_BYTES {
122        return Some(false);
123    }
124    if !is_git(dir) {
125        return None;
126    }
127    let mut child = Command::new("git")
128        .current_dir(dir)
129        .args(["apply", "-"])
130        .stdin(std::process::Stdio::piped())
131        .stdout(std::process::Stdio::piped())
132        .stderr(std::process::Stdio::piped())
133        .spawn()
134        .ok()?;
135    if let Some(mut si) = child.stdin.take() {
136        use std::io::Write;
137        let _ = si.write_all(patch.as_bytes());
138    }
139    let out = child.wait_with_output().ok()?;
140    Some(out.status.success())
141}
142
143fn has_head(dir: &str) -> bool {
144    run_git(dir, &["rev-parse", "--verify", "--quiet", "HEAD"])
145        .map(|(_, c)| c == 0)
146        .unwrap_or(false)
147}
148
149/// Git worktree toplevel for `dir` (upstream `project.worktree`). None when
150/// `dir` is not inside a git work tree.
151pub fn toplevel(dir: &std::path::Path) -> Option<String> {
152    let d = dir.to_str()?;
153    let (t, c) = run_git(d, &["rev-parse", "--show-toplevel"])?;
154    (c == 0 && !t.trim().is_empty()).then(|| t.trim().to_string())
155}
156
157pub fn info(dir: &str) -> Value {
158    let branch = run_git(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
159        .filter(|(t, c)| *c == 0 && !t.trim().is_empty())
160        .map(|(t, _)| t.trim().to_string());
161    let default_branch = run_git(dir, &["symbolic-ref", "refs/remotes/origin/HEAD"])
162        .filter(|(t, c)| *c == 0 && !t.trim().is_empty())
163        .map(|(t, _)| {
164            t.trim()
165                .trim_start_matches("refs/remotes/origin/")
166                .to_string()
167        });
168    json!({"branch": branch, "default_branch": default_branch})
169}
170
171fn is_git(dir: &str) -> bool {
172    run_git(dir, &["rev-parse", "--is-inside-work-tree"])
173        .map(|(t, c)| c == 0 && t.trim() == "true")
174        .unwrap_or(false)
175}
176
177pub fn status(dir: &str) -> Value {
178    if !is_git(dir) {
179        return Value::Array(vec![]);
180    }
181    let ref_ = if has_head(dir) { "HEAD" } else { "" };
182    let stats = if ref_.is_empty() {
183        std::collections::HashMap::new()
184    } else {
185        numstat(dir, ref_)
186    };
187    let mut items: Vec<(String, String)> = status_entries(dir);
188    items.sort_by(|a, b| a.0.cmp(&b.0));
189    let arr: Vec<Value> = items
190        .into_iter()
191        .map(|(file, code)| {
192            let (a, d) = stats.get(&file).copied().unwrap_or_else(|| {
193                if code == "??" {
194                    stat_untracked(dir, &file)
195                } else {
196                    (0, 0)
197                }
198            });
199            json!({"file": file, "additions": a, "deletions": d, "status": kind(&code)})
200        })
201        .collect();
202    Value::Array(arr)
203}
204
205/// `git diff --no-index --numstat -- /dev/null <file>` → (adds, dels).
206fn stat_untracked(dir: &str, file: &str) -> (i64, i64) {
207    let Some((text, _)) = run_git(
208        dir,
209        &["diff", "--no-index", "--numstat", "--", "/dev/null", file],
210    ) else {
211        return (0, 0);
212    };
213    let line = text.lines().next().unwrap_or("");
214    let mut it = line.splitn(3, '\t');
215    let (Some(a), Some(d)) = (it.next(), it.next()) else {
216        return (0, 0);
217    };
218    (a.parse::<i64>().unwrap_or(0), d.parse::<i64>().unwrap_or(0))
219}
220
221/// One batched tracked patch, split by `diff --git` headers → file→patch.
222fn tracked_patches(
223    dir: &str,
224    ref_: &str,
225    context: &str,
226) -> (std::collections::HashMap<String, String>, bool) {
227    let Some((text, _)) = run_git(
228        dir,
229        &[
230            "diff",
231            "--patch",
232            "--no-ext-diff",
233            "--no-renames",
234            &format!("--unified={context}"),
235            ref_,
236            "--",
237            ".",
238        ],
239    ) else {
240        return (std::collections::HashMap::new(), false);
241    };
242    // split on "diff --git "
243    let mut map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
244    let mut cur_file: Option<String> = None;
245    let mut cur = String::new();
246    for line in text.split_inclusive('\n') {
247        if line.starts_with("diff --git ") {
248            if let Some(f) = cur_file.take() {
249                map.entry(f).or_default().push_str(&cur);
250                cur.clear();
251            }
252            cur_file = parse_git_header(line);
253        }
254        cur.push_str(line);
255    }
256    if let Some(f) = cur_file {
257        map.entry(f).or_default().push_str(&cur);
258    }
259    // detect truncation cheaply: freeze caps by bytes; we cap at the caller
260    (map, false)
261}
262
263/// Parse the destination path from a `diff --git a/x b/x` header line.
264fn parse_git_header(line: &str) -> Option<String> {
265    let rest = line.trim_end().strip_prefix("diff --git ")?;
266    // find " b/" separator (unquoted)
267    let idx = rest.find(" b/")?;
268    Some(rest[idx + 3..].to_string())
269}
270
271pub fn diff_raw(dir: &str) -> String {
272    if !is_git(dir) {
273        return String::new();
274    }
275    let ref_ = if has_head(dir) { "HEAD" } else { "" };
276    let mut parts: Vec<String> = Vec::new();
277    if !ref_.is_empty()
278        && let Some((text, _)) = run_git(
279            dir,
280            &[
281                "diff",
282                "--patch",
283                "--no-ext-diff",
284                "--no-renames",
285                &format!("--unified={FULL_CONTEXT}"),
286                ref_,
287                "--",
288                ".",
289            ],
290        )
291    {
292        parts.push(text);
293    }
294    for (file, code) in status_entries(dir) {
295        if code == "??"
296            && let Some((text, _)) = run_git(
297                dir,
298                &[
299                    "diff",
300                    "--no-index",
301                    "--patch",
302                    "--no-ext-diff",
303                    "--no-renames",
304                    &format!("--unified={FULL_CONTEXT}"),
305                    "--",
306                    "/dev/null",
307                    &file,
308                ],
309            )
310        {
311            parts.push(text);
312        }
313    }
314    parts
315        .into_iter()
316        .filter(|s| !s.is_empty())
317        .collect::<Vec<_>>()
318        .join("\n")
319}
320
321/// `/vcs/diff/raw` variant: same patch text but bounded by
322/// `MAX_TOTAL_PATCH_BYTES` (the tracked `diff()` caps its aggregate; the raw
323/// route concatenated the tracked patch AND one `--no-index` per untracked file
324/// with no cap — an unbounded request-reachable allocation on a large dirty
325/// tree). Truncation is at a char boundary; the caller marks it truncated.
326pub fn diff_raw_bounded(dir: &str) -> (String, bool) {
327    let full = diff_raw(dir);
328    if full.len() <= MAX_TOTAL_PATCH_BYTES {
329        return (full, false);
330    }
331    let mut cut = MAX_TOTAL_PATCH_BYTES;
332    while cut > 0 && !full.is_char_boundary(cut) {
333        cut -= 1;
334    }
335    (full[..cut].to_string(), true)
336}
337
338/// `mode`: git | branch. `context`: unified context lines (default full).
339pub fn diff(dir: &str, mode: &str, context: Option<u32>) -> Value {
340    if !is_git(dir) {
341        return Value::Array(vec![]);
342    }
343    let ref_ = match mode {
344        "git" => {
345            if has_head(dir) {
346                "HEAD".to_string()
347            } else {
348                String::new()
349            }
350        }
351        "branch" => {
352            let Some((def, c)) = run_git(dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]) else {
353                return Value::Array(vec![]);
354            };
355            if c != 0 || def.trim().is_empty() {
356                return Value::Array(vec![]);
357            }
358            let name = def
359                .trim()
360                .trim_start_matches("refs/remotes/origin/")
361                .to_string();
362            match run_git(dir, &["merge-base", "HEAD", &name]) {
363                Some((mb, 0)) if !mb.trim().is_empty() => mb.trim().to_string(),
364                _ => return Value::Array(vec![]),
365            }
366        }
367        _ => return json!({"__error": "mode"}),
368    };
369
370    let ctx = context
371        .map(|c| c.to_string())
372        .unwrap_or_else(|| FULL_CONTEXT.into());
373    let stats = if ref_.is_empty() {
374        std::collections::HashMap::new()
375    } else {
376        numstat(dir, &ref_)
377    };
378    let (tracked, _trunc) = if ref_.is_empty() {
379        (std::collections::HashMap::new(), false)
380    } else {
381        tracked_patches(dir, &ref_, &ctx)
382    };
383
384    // Build the file list: status entries (covers untracked), sorted.
385    let mut items: Vec<(String, String)> = status_entries(dir);
386    items.sort_by(|a, b| a.0.cmp(&b.0));
387
388    let mut out: Vec<Value> = Vec::new();
389    let mut total = 0usize;
390    for (file, code) in items {
391        let (a, d) = stats.get(&file).copied().unwrap_or_else(|| {
392            if code == "??" {
393                stat_untracked(dir, &file)
394            } else {
395                (0, 0)
396            }
397        });
398        let patch = if total >= MAX_TOTAL_PATCH_BYTES {
399            String::new()
400        } else if let Some(p) = tracked.get(&file) {
401            p.clone()
402        } else if code == "??" {
403            run_git(
404                dir,
405                &[
406                    "diff",
407                    "--no-index",
408                    "--patch",
409                    "--no-ext-diff",
410                    "--no-renames",
411                    &format!("--unified={ctx}"),
412                    "--",
413                    "/dev/null",
414                    &file,
415                ],
416            )
417            .map(|(t, _)| t)
418            .unwrap_or_default()
419        } else {
420            String::new()
421        };
422        total += patch.len();
423        out.push(json!({
424            "file": file,
425            "patch": patch,
426            "additions": a,
427            "deletions": d,
428            "status": kind(&code),
429        }));
430    }
431    Value::Array(out)
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn kind_mapping() {
440        assert_eq!(kind("A "), "added");
441        assert_eq!(kind("D "), "deleted");
442        assert_eq!(kind("M "), "modified");
443        assert_eq!(kind("??"), "added");
444    }
445
446    #[test]
447    fn header_parse() {
448        assert_eq!(
449            parse_git_header("diff --git a/src/x.rs b/src/x.rs\n"),
450            Some("src/x.rs".into())
451        );
452        assert_eq!(parse_git_header("not a header"), None);
453    }
454}