Skip to main content

ryu_workspace/
git.rs

1//! The git engine: read-only status/branches plus checkout/create-branch/
2//! commit-push, all shelling `git` against a caller-supplied cwd. This is the
3//! "reads/runs what-is, no policy" half of the workspace primitive; the axum
4//! HTTP handlers that call these functions stay in Core (server wiring), as do
5//! the pure-filesystem `/api/workspace/{new-folder,list}` handlers (they shell
6//! no git — node-fs, kernel-owned).
7
8use std::process::Command;
9
10use crate::win_process::NoWindow;
11
12/// Shaped `GET /api/git/status` result: the working-tree state of a repo cwd.
13#[derive(serde::Serialize)]
14pub struct GitState {
15    pub is_repo: bool,
16    pub branch: Option<String>,
17    pub ahead: u32,
18    pub behind: u32,
19    pub dirty: bool,
20    pub changed_files_count: usize,
21    pub insertions: u32,
22    pub deletions: u32,
23}
24
25/// Files larger than this are counted as 0 added lines, the same way git treats
26/// a file it decides is binary. Keeps a stray multi-gigabyte artifact in an
27/// untracked folder from stalling a status poll.
28const MAX_UNTRACKED_SCAN_BYTES: u64 = 2 * 1024 * 1024;
29
30/// Added lines contributed by files git does not track yet.
31///
32/// `git diff HEAD --numstat` only sees tracked files, but `git status
33/// --porcelain` counts untracked ones — so without this the two halves of
34/// `GitState` describe different file sets, and a folder of brand-new files
35/// reads as "12 files changed, +0 −0". Every line of a new file is an insertion,
36/// which is what `git add -N` + `diff` would report. Binary and oversized files
37/// contribute 0, matching numstat's "-" rows.
38fn untracked_insertions(cwd: &str, untracked: &[String]) -> u32 {
39    let root = std::path::Path::new(cwd);
40    let mut insertions = 0u32;
41    for rel in untracked {
42        let path = root.join(rel);
43        let Ok(meta) = std::fs::metadata(&path) else {
44            continue;
45        };
46        if !meta.is_file() || meta.len() > MAX_UNTRACKED_SCAN_BYTES {
47            continue;
48        }
49        let Ok(bytes) = std::fs::read(&path) else {
50            continue;
51        };
52        if bytes.is_empty() || bytes.contains(&0) {
53            continue;
54        }
55        let newlines = bytes.iter().filter(|b| **b == b'\n').count();
56        // A trailing byte that is not a newline is still a line to git.
57        let lines = if bytes.last() == Some(&b'\n') {
58            newlines
59        } else {
60            newlines + 1
61        };
62        insertions = insertions.saturating_add(lines as u32);
63    }
64    insertions
65}
66
67/// Pull the untracked paths out of `git status --porcelain --untracked-files=all`
68/// output (the `?? <path>` rows), un-quoting the C-style quoting git applies to
69/// paths with unusual bytes.
70fn untracked_paths(porcelain: &str) -> Vec<String> {
71    porcelain
72        .lines()
73        .filter_map(|l| l.strip_prefix("?? "))
74        .map(unquote_git_path)
75        .collect()
76}
77
78/// Undo git's C-style path quoting (`"a\tb"`). Non-quoted paths pass through.
79fn unquote_git_path(raw: &str) -> String {
80    let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
81        return raw.to_string();
82    };
83    let mut out = String::with_capacity(inner.len());
84    let mut chars = inner.chars();
85    while let Some(c) = chars.next() {
86        if c != '\\' {
87            out.push(c);
88            continue;
89        }
90        match chars.next() {
91            Some('n') => out.push('\n'),
92            Some('t') => out.push('\t'),
93            Some('r') => out.push('\r'),
94            Some(other) => out.push(other),
95            None => break,
96        }
97    }
98    out
99}
100
101/// Total added/removed lines for the working tree vs HEAD (staged + unstaged),
102/// summed from `git diff HEAD --numstat`. Binary files (numstat "-") are skipped.
103fn query_diff_totals(cwd: &str) -> (u32, u32) {
104    let numstat = run_git(cwd, &["diff", "HEAD", "--numstat"]).unwrap_or_default();
105    let mut insertions = 0u32;
106    let mut deletions = 0u32;
107    for line in numstat.lines() {
108        let mut cols = line.split('\t');
109        let adds = cols.next().and_then(|c| c.parse::<u32>().ok());
110        let dels = cols.next().and_then(|c| c.parse::<u32>().ok());
111        if let (Some(a), Some(d)) = (adds, dels) {
112            insertions += a;
113            deletions += d;
114        }
115    }
116    (insertions, deletions)
117}
118
119fn run_git(cwd: &str, args: &[&str]) -> Option<String> {
120    let out = Command::new("git")
121        .args(args)
122        .current_dir(cwd)
123        .no_window()
124        .output()
125        .ok()?;
126    if out.status.success() {
127        Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
128    } else {
129        None
130    }
131}
132
133/// Compute the working-tree state for `cwd` (branch, ahead/behind, dirty, diff
134/// totals). Returns `is_repo:false` when `cwd` is not a git repository.
135pub fn query_git_state(cwd: &str) -> GitState {
136    // Confirm this is actually a git repo.
137    let branch = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
138    let is_repo = branch.is_some();
139
140    if !is_repo {
141        return GitState {
142            is_repo: false,
143            branch: None,
144            ahead: 0,
145            behind: 0,
146            dirty: false,
147            changed_files_count: 0,
148            insertions: 0,
149            deletions: 0,
150        };
151    }
152
153    // Dirty state from porcelain output — one line per changed file.
154    // `--untracked-files=all` lists new files individually rather than collapsing
155    // a new directory into a single row, so `changed_files_count` counts the same
156    // files the insertion total below is summed over.
157    let porcelain = run_git(cwd, &["status", "--porcelain", "--untracked-files=all"])
158        .unwrap_or_default();
159    let changed: Vec<&str> = porcelain.lines().filter(|l| !l.is_empty()).collect();
160    let dirty = !changed.is_empty();
161
162    // Ahead / behind relative to the upstream branch. Fails gracefully when no
163    // tracking branch is configured — defaults to 0/0.
164    let ahead_behind = run_git(cwd, &["rev-list", "--count", "--left-right", "@{u}...HEAD"]);
165    let (behind, ahead) = parse_ahead_behind(ahead_behind.as_deref());
166
167    let (tracked_insertions, deletions) = query_diff_totals(cwd);
168    let insertions =
169        tracked_insertions.saturating_add(untracked_insertions(cwd, &untracked_paths(&porcelain)));
170
171    GitState {
172        is_repo: true,
173        branch,
174        ahead,
175        behind,
176        dirty,
177        changed_files_count: changed.len(),
178        insertions,
179        deletions,
180    }
181}
182
183/// Parse `git rev-list --count --left-right @{u}...HEAD` output: "<behind>\t<ahead>".
184fn parse_ahead_behind(raw: Option<&str>) -> (u32, u32) {
185    let Some(s) = raw else {
186        return (0, 0);
187    };
188    let mut parts = s.split_whitespace();
189    let behind = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
190    let ahead = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
191    (behind, ahead)
192}
193
194/// Shaped `GET /api/git/branches` result: local branches plus the current one.
195#[derive(serde::Serialize)]
196pub struct GitBranches {
197    pub is_repo: bool,
198    pub current: Option<String>,
199    pub branches: Vec<String>,
200}
201
202/// List local branches plus the currently checked-out one for `cwd`. Returns
203/// `is_repo:false` when `cwd` is not a git repository.
204pub fn list_branches(cwd: &str) -> GitBranches {
205    let current = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
206    if current.is_none() {
207        return GitBranches {
208            is_repo: false,
209            current: None,
210            branches: Vec::new(),
211        };
212    }
213
214    let raw = run_git(cwd, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
215    let branches: Vec<String> = raw
216        .lines()
217        .map(|l| l.trim().to_string())
218        .filter(|l| !l.is_empty())
219        .collect();
220
221    GitBranches {
222        is_repo: true,
223        current,
224        branches,
225    }
226}
227
228/// Switch `cwd` to an existing local branch via `git switch`.
229///
230/// The branch is validated against the actual branch list to reject typos and
231/// argument injection (a name beginning with `-`). Returns the raw git stderr on
232/// failure so the caller can surface it (e.g. uncommitted-changes conflicts).
233pub fn checkout_branch(cwd: &str, branch: &str) -> Result<String, String> {
234    // Only switch to a branch git itself reports — guards against typos and any
235    // argument-injection (e.g. a name beginning with '-').
236    let known = list_branches(cwd);
237    if !known.is_repo {
238        return Err("not a git repository".to_string());
239    }
240    if !known.branches.iter().any(|b| b == branch) {
241        return Err(format!("branch '{branch}' not found"));
242    }
243
244    let out = Command::new("git")
245        .args(["switch", branch])
246        .current_dir(cwd)
247        .no_window()
248        .output()
249        .map_err(|e| format!("failed to run git: {e}"))?;
250
251    if out.status.success() {
252        Ok(branch.to_string())
253    } else {
254        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
255    }
256}
257
258/// Create a new branch off the current HEAD and switch to it (`git switch -c`).
259///
260/// Guards against argument injection (a name beginning with `-`) and obvious bad
261/// input; git validates the full ref-name grammar itself and errors cleanly.
262/// Returns the raw git stderr on failure (e.g. the branch already exists).
263pub fn create_branch(cwd: &str, branch: &str) -> Result<String, String> {
264    if !list_branches(cwd).is_repo {
265        return Err("not a git repository".to_string());
266    }
267    // Guard against argument injection (a name beginning with '-') and obvious bad
268    // input; git validates the full ref-name grammar itself and errors cleanly.
269    let name = branch.trim();
270    if name.is_empty()
271        || name.starts_with('-')
272        || name.contains("..")
273        || name.chars().any(|c| c.is_whitespace() || c.is_control())
274    {
275        return Err(format!("'{branch}' is not a valid branch name"));
276    }
277
278    let out = Command::new("git")
279        .args(["switch", "-c", name])
280        .current_dir(cwd)
281        .no_window()
282        .output()
283        .map_err(|e| format!("failed to run git: {e}"))?;
284
285    if out.status.success() {
286        Ok(name.to_string())
287    } else {
288        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
289    }
290}
291
292/// Shaped `POST /api/git/commit-push` result: what the action actually did.
293#[derive(serde::Serialize)]
294pub struct CommitPushOutcome {
295    pub success: bool,
296    pub committed: bool,
297    pub pushed: bool,
298    pub commit: Option<String>,
299}
300
301/// Commit, push, or do both for `cwd`. `action` is one of `commit`,
302/// `commit-push`, or `push` (validated by the caller). When `include_unstaged`
303/// is set, stages everything before committing. Returns the raw git stderr on
304/// any failure.
305pub fn run_git_action(
306    cwd: &str,
307    message: &str,
308    action: &str,
309    include_unstaged: bool,
310) -> Result<CommitPushOutcome, String> {
311    // Confirm this is a git repo before touching the working tree.
312    if run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).is_none() {
313        return Err("not a git repository".to_string());
314    }
315
316    if action != "push" && include_unstaged {
317        // Stage everything. A failure here is fatal (e.g. corrupt index).
318        let add = Command::new("git")
319            .args(["add", "-A"])
320            .current_dir(cwd)
321            .no_window()
322            .output()
323            .map_err(|e| format!("failed to run git: {e}"))?;
324        if !add.status.success() {
325            return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
326        }
327    }
328
329    let mut committed = false;
330    if action != "push" {
331        let staged_args = ["diff", "--cached", "--name-only"];
332        let has_staged = run_git(cwd, &staged_args)
333            .map(|s| s.lines().any(|l| !l.trim().is_empty()))
334            .unwrap_or(false);
335
336        if !has_staged && include_unstaged {
337            let has_changes = run_git(cwd, &["status", "--porcelain"])
338                .map(|s| s.lines().any(|l| !l.trim().is_empty()))
339                .unwrap_or(false);
340            if has_changes {
341                return Err("no staged changes to commit".to_string());
342            }
343        }
344
345        let commit = Command::new("git")
346            .args(["commit", "-m", message])
347            .current_dir(cwd)
348            .no_window()
349            .output()
350            .map_err(|e| format!("failed to run git: {e}"))?;
351        if has_staged && commit.status.success() {
352            committed = true;
353        } else if has_staged {
354            return Err(String::from_utf8_lossy(&commit.stderr).trim().to_string());
355        }
356    }
357
358    let mut pushed = false;
359    if action != "commit" {
360        // Push to the configured upstream. When there is no tracking branch git
361        // exits non-zero with a helpful message — surface it verbatim.
362        let push = Command::new("git")
363            .args(["push"])
364            .current_dir(cwd)
365            .no_window()
366            .output()
367            .map_err(|e| format!("failed to run git: {e}"))?;
368        if !push.status.success() {
369            return Err(String::from_utf8_lossy(&push.stderr).trim().to_string());
370        }
371        pushed = true;
372    }
373
374    let commit = run_git(cwd, &["rev-parse", "--short", "HEAD"]);
375
376    Ok(CommitPushOutcome {
377        success: true,
378        committed,
379        pushed,
380        commit,
381    })
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn parse_ahead_behind_normal() {
390        assert_eq!(parse_ahead_behind(Some("3\t1")), (3, 1));
391    }
392
393    #[test]
394    fn parse_ahead_behind_none() {
395        assert_eq!(parse_ahead_behind(None), (0, 0));
396    }
397
398    #[test]
399    fn parse_ahead_behind_no_upstream() {
400        assert_eq!(parse_ahead_behind(Some("")), (0, 0));
401    }
402
403    #[test]
404    fn untracked_paths_picks_only_untracked_rows() {
405        let porcelain = " M src/lib.rs\nA  src/new.rs\n?? notes.md\n?? src/scratch.rs\n";
406        assert_eq!(
407            untracked_paths(porcelain),
408            vec!["notes.md".to_string(), "src/scratch.rs".to_string()]
409        );
410    }
411
412    #[test]
413    fn untracked_paths_unquotes_git_quoting() {
414        assert_eq!(untracked_paths("?? \"a\\tb.txt\"\n"), vec!["a\tb.txt"]);
415    }
416
417    #[test]
418    fn untracked_insertions_counts_every_line_of_a_new_file() {
419        let dir = std::env::temp_dir().join(format!(
420            "ryu-untracked-{}-{:?}",
421            std::process::id(),
422            std::thread::current().id()
423        ));
424        std::fs::create_dir_all(&dir).unwrap();
425        // Three lines, no trailing newline — git counts the last one too.
426        std::fs::write(dir.join("new.txt"), b"a\nb\nc").unwrap();
427        // Binary content contributes nothing, exactly like a numstat "-" row.
428        std::fs::write(dir.join("blob.bin"), b"a\0b\n").unwrap();
429
430        let counted = untracked_insertions(
431            dir.to_str().unwrap(),
432            &["new.txt".to_string(), "blob.bin".to_string()],
433        );
434        std::fs::remove_dir_all(&dir).ok();
435
436        assert_eq!(counted, 3);
437    }
438}