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    // Most-recently-committed first, not git's default alphabetical order. The
215    // list is NOT paged — `checkout_branch` re-lists to validate its argument, so
216    // a server-side limit would make any branch past the cut unreachable — but a
217    // client that shows only the head of a long list should be showing the
218    // branches actually in play, not the ones that happen to start with "a".
219    let raw = run_git(
220        cwd,
221        &["branch", "--sort=-committerdate", "--format=%(refname:short)"],
222    )
223    .unwrap_or_default();
224    let branches: Vec<String> = raw
225        .lines()
226        .map(|l| l.trim().to_string())
227        .filter(|l| !l.is_empty())
228        .collect();
229
230    GitBranches {
231        is_repo: true,
232        current,
233        branches,
234    }
235}
236
237/// Switch `cwd` to an existing local branch via `git switch`.
238///
239/// The branch is validated against the actual branch list to reject typos and
240/// argument injection (a name beginning with `-`). Returns the raw git stderr on
241/// failure so the caller can surface it (e.g. uncommitted-changes conflicts).
242pub fn checkout_branch(cwd: &str, branch: &str) -> Result<String, String> {
243    // Only switch to a branch git itself reports — guards against typos and any
244    // argument-injection (e.g. a name beginning with '-').
245    let known = list_branches(cwd);
246    if !known.is_repo {
247        return Err("not a git repository".to_string());
248    }
249    if !known.branches.iter().any(|b| b == branch) {
250        return Err(format!("branch '{branch}' not found"));
251    }
252
253    let out = Command::new("git")
254        .args(["switch", branch])
255        .current_dir(cwd)
256        .no_window()
257        .output()
258        .map_err(|e| format!("failed to run git: {e}"))?;
259
260    if out.status.success() {
261        Ok(branch.to_string())
262    } else {
263        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
264    }
265}
266
267/// Create a new branch off the current HEAD and switch to it (`git switch -c`).
268///
269/// Guards against argument injection (a name beginning with `-`) and obvious bad
270/// input; git validates the full ref-name grammar itself and errors cleanly.
271/// Returns the raw git stderr on failure (e.g. the branch already exists).
272pub fn create_branch(cwd: &str, branch: &str) -> Result<String, String> {
273    if !list_branches(cwd).is_repo {
274        return Err("not a git repository".to_string());
275    }
276    // Guard against argument injection (a name beginning with '-') and obvious bad
277    // input; git validates the full ref-name grammar itself and errors cleanly.
278    let name = branch.trim();
279    if name.is_empty()
280        || name.starts_with('-')
281        || name.contains("..")
282        || name.chars().any(|c| c.is_whitespace() || c.is_control())
283    {
284        return Err(format!("'{branch}' is not a valid branch name"));
285    }
286
287    let out = Command::new("git")
288        .args(["switch", "-c", name])
289        .current_dir(cwd)
290        .no_window()
291        .output()
292        .map_err(|e| format!("failed to run git: {e}"))?;
293
294    if out.status.success() {
295        Ok(name.to_string())
296    } else {
297        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
298    }
299}
300
301/// Shaped `POST /api/git/commit-push` result: what the action actually did.
302#[derive(serde::Serialize)]
303pub struct CommitPushOutcome {
304    pub success: bool,
305    pub committed: bool,
306    pub pushed: bool,
307    pub commit: Option<String>,
308}
309
310/// Commit, push, or do both for `cwd`. `action` is one of `commit`,
311/// `commit-push`, or `push` (validated by the caller). When `include_unstaged`
312/// is set, stages everything before committing. Returns the raw git stderr on
313/// any failure.
314pub fn run_git_action(
315    cwd: &str,
316    message: &str,
317    action: &str,
318    include_unstaged: bool,
319) -> Result<CommitPushOutcome, String> {
320    // Confirm this is a git repo before touching the working tree.
321    if run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).is_none() {
322        return Err("not a git repository".to_string());
323    }
324
325    if action != "push" && include_unstaged {
326        // Stage everything. A failure here is fatal (e.g. corrupt index).
327        let add = Command::new("git")
328            .args(["add", "-A"])
329            .current_dir(cwd)
330            .no_window()
331            .output()
332            .map_err(|e| format!("failed to run git: {e}"))?;
333        if !add.status.success() {
334            return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
335        }
336    }
337
338    let mut committed = false;
339    if action != "push" {
340        let staged_args = ["diff", "--cached", "--name-only"];
341        let has_staged = run_git(cwd, &staged_args)
342            .map(|s| s.lines().any(|l| !l.trim().is_empty()))
343            .unwrap_or(false);
344
345        if !has_staged && include_unstaged {
346            let has_changes = run_git(cwd, &["status", "--porcelain"])
347                .map(|s| s.lines().any(|l| !l.trim().is_empty()))
348                .unwrap_or(false);
349            if has_changes {
350                return Err("no staged changes to commit".to_string());
351            }
352        }
353
354        let commit = Command::new("git")
355            .args(["commit", "-m", message])
356            .current_dir(cwd)
357            .no_window()
358            .output()
359            .map_err(|e| format!("failed to run git: {e}"))?;
360        if has_staged && commit.status.success() {
361            committed = true;
362        } else if has_staged {
363            return Err(String::from_utf8_lossy(&commit.stderr).trim().to_string());
364        }
365    }
366
367    let mut pushed = false;
368    if action != "commit" {
369        // Push to the configured upstream. When there is no tracking branch git
370        // exits non-zero with a helpful message — surface it verbatim.
371        let push = Command::new("git")
372            .args(["push"])
373            .current_dir(cwd)
374            .no_window()
375            .output()
376            .map_err(|e| format!("failed to run git: {e}"))?;
377        if !push.status.success() {
378            return Err(String::from_utf8_lossy(&push.stderr).trim().to_string());
379        }
380        pushed = true;
381    }
382
383    let commit = run_git(cwd, &["rev-parse", "--short", "HEAD"]);
384
385    Ok(CommitPushOutcome {
386        success: true,
387        committed,
388        pushed,
389        commit,
390    })
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn parse_ahead_behind_normal() {
399        assert_eq!(parse_ahead_behind(Some("3\t1")), (3, 1));
400    }
401
402    #[test]
403    fn parse_ahead_behind_none() {
404        assert_eq!(parse_ahead_behind(None), (0, 0));
405    }
406
407    #[test]
408    fn parse_ahead_behind_no_upstream() {
409        assert_eq!(parse_ahead_behind(Some("")), (0, 0));
410    }
411
412    #[test]
413    fn untracked_paths_picks_only_untracked_rows() {
414        let porcelain = " M src/lib.rs\nA  src/new.rs\n?? notes.md\n?? src/scratch.rs\n";
415        assert_eq!(
416            untracked_paths(porcelain),
417            vec!["notes.md".to_string(), "src/scratch.rs".to_string()]
418        );
419    }
420
421    #[test]
422    fn untracked_paths_unquotes_git_quoting() {
423        assert_eq!(untracked_paths("?? \"a\\tb.txt\"\n"), vec!["a\tb.txt"]);
424    }
425
426    #[test]
427    fn untracked_insertions_counts_every_line_of_a_new_file() {
428        let dir = std::env::temp_dir().join(format!(
429            "ryu-untracked-{}-{:?}",
430            std::process::id(),
431            std::thread::current().id()
432        ));
433        std::fs::create_dir_all(&dir).unwrap();
434        // Three lines, no trailing newline — git counts the last one too.
435        std::fs::write(dir.join("new.txt"), b"a\nb\nc").unwrap();
436        // Binary content contributes nothing, exactly like a numstat "-" row.
437        std::fs::write(dir.join("blob.bin"), b"a\0b\n").unwrap();
438
439        let counted = untracked_insertions(
440            dir.to_str().unwrap(),
441            &["new.txt".to_string(), "blob.bin".to_string()],
442        );
443        std::fs::remove_dir_all(&dir).ok();
444
445        assert_eq!(counted, 3);
446    }
447}