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/// Total added/removed lines for the working tree vs HEAD (staged + unstaged),
26/// summed from `git diff HEAD --numstat`. Binary files (numstat "-") are skipped.
27fn query_diff_totals(cwd: &str) -> (u32, u32) {
28    let numstat = run_git(cwd, &["diff", "HEAD", "--numstat"]).unwrap_or_default();
29    let mut insertions = 0u32;
30    let mut deletions = 0u32;
31    for line in numstat.lines() {
32        let mut cols = line.split('\t');
33        let adds = cols.next().and_then(|c| c.parse::<u32>().ok());
34        let dels = cols.next().and_then(|c| c.parse::<u32>().ok());
35        if let (Some(a), Some(d)) = (adds, dels) {
36            insertions += a;
37            deletions += d;
38        }
39    }
40    (insertions, deletions)
41}
42
43fn run_git(cwd: &str, args: &[&str]) -> Option<String> {
44    let out = Command::new("git")
45        .args(args)
46        .current_dir(cwd)
47        .no_window()
48        .output()
49        .ok()?;
50    if out.status.success() {
51        Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
52    } else {
53        None
54    }
55}
56
57/// Compute the working-tree state for `cwd` (branch, ahead/behind, dirty, diff
58/// totals). Returns `is_repo:false` when `cwd` is not a git repository.
59pub fn query_git_state(cwd: &str) -> GitState {
60    // Confirm this is actually a git repo.
61    let branch = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
62    let is_repo = branch.is_some();
63
64    if !is_repo {
65        return GitState {
66            is_repo: false,
67            branch: None,
68            ahead: 0,
69            behind: 0,
70            dirty: false,
71            changed_files_count: 0,
72            insertions: 0,
73            deletions: 0,
74        };
75    }
76
77    // Dirty state from porcelain output — one line per changed file.
78    let porcelain = run_git(cwd, &["status", "--porcelain"]).unwrap_or_default();
79    let changed: Vec<&str> = porcelain.lines().filter(|l| !l.is_empty()).collect();
80    let dirty = !changed.is_empty();
81
82    // Ahead / behind relative to the upstream branch. Fails gracefully when no
83    // tracking branch is configured — defaults to 0/0.
84    let ahead_behind = run_git(cwd, &["rev-list", "--count", "--left-right", "@{u}...HEAD"]);
85    let (behind, ahead) = parse_ahead_behind(ahead_behind.as_deref());
86
87    let (insertions, deletions) = query_diff_totals(cwd);
88
89    GitState {
90        is_repo: true,
91        branch,
92        ahead,
93        behind,
94        dirty,
95        changed_files_count: changed.len(),
96        insertions,
97        deletions,
98    }
99}
100
101/// Parse `git rev-list --count --left-right @{u}...HEAD` output: "<behind>\t<ahead>".
102fn parse_ahead_behind(raw: Option<&str>) -> (u32, u32) {
103    let Some(s) = raw else {
104        return (0, 0);
105    };
106    let mut parts = s.split_whitespace();
107    let behind = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
108    let ahead = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
109    (behind, ahead)
110}
111
112/// Shaped `GET /api/git/branches` result: local branches plus the current one.
113#[derive(serde::Serialize)]
114pub struct GitBranches {
115    pub is_repo: bool,
116    pub current: Option<String>,
117    pub branches: Vec<String>,
118}
119
120/// List local branches plus the currently checked-out one for `cwd`. Returns
121/// `is_repo:false` when `cwd` is not a git repository.
122pub fn list_branches(cwd: &str) -> GitBranches {
123    let current = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
124    if current.is_none() {
125        return GitBranches {
126            is_repo: false,
127            current: None,
128            branches: Vec::new(),
129        };
130    }
131
132    let raw = run_git(cwd, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
133    let branches: Vec<String> = raw
134        .lines()
135        .map(|l| l.trim().to_string())
136        .filter(|l| !l.is_empty())
137        .collect();
138
139    GitBranches {
140        is_repo: true,
141        current,
142        branches,
143    }
144}
145
146/// Switch `cwd` to an existing local branch via `git switch`.
147///
148/// The branch is validated against the actual branch list to reject typos and
149/// argument injection (a name beginning with `-`). Returns the raw git stderr on
150/// failure so the caller can surface it (e.g. uncommitted-changes conflicts).
151pub fn checkout_branch(cwd: &str, branch: &str) -> Result<String, String> {
152    // Only switch to a branch git itself reports — guards against typos and any
153    // argument-injection (e.g. a name beginning with '-').
154    let known = list_branches(cwd);
155    if !known.is_repo {
156        return Err("not a git repository".to_string());
157    }
158    if !known.branches.iter().any(|b| b == branch) {
159        return Err(format!("branch '{branch}' not found"));
160    }
161
162    let out = Command::new("git")
163        .args(["switch", branch])
164        .current_dir(cwd)
165        .no_window()
166        .output()
167        .map_err(|e| format!("failed to run git: {e}"))?;
168
169    if out.status.success() {
170        Ok(branch.to_string())
171    } else {
172        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
173    }
174}
175
176/// Create a new branch off the current HEAD and switch to it (`git switch -c`).
177///
178/// Guards against argument injection (a name beginning with `-`) and obvious bad
179/// input; git validates the full ref-name grammar itself and errors cleanly.
180/// Returns the raw git stderr on failure (e.g. the branch already exists).
181pub fn create_branch(cwd: &str, branch: &str) -> Result<String, String> {
182    if !list_branches(cwd).is_repo {
183        return Err("not a git repository".to_string());
184    }
185    // Guard against argument injection (a name beginning with '-') and obvious bad
186    // input; git validates the full ref-name grammar itself and errors cleanly.
187    let name = branch.trim();
188    if name.is_empty()
189        || name.starts_with('-')
190        || name.contains("..")
191        || name.chars().any(|c| c.is_whitespace() || c.is_control())
192    {
193        return Err(format!("'{branch}' is not a valid branch name"));
194    }
195
196    let out = Command::new("git")
197        .args(["switch", "-c", name])
198        .current_dir(cwd)
199        .no_window()
200        .output()
201        .map_err(|e| format!("failed to run git: {e}"))?;
202
203    if out.status.success() {
204        Ok(name.to_string())
205    } else {
206        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
207    }
208}
209
210/// Shaped `POST /api/git/commit-push` result: what the action actually did.
211#[derive(serde::Serialize)]
212pub struct CommitPushOutcome {
213    pub success: bool,
214    pub committed: bool,
215    pub pushed: bool,
216    pub commit: Option<String>,
217}
218
219/// Commit, push, or do both for `cwd`. `action` is one of `commit`,
220/// `commit-push`, or `push` (validated by the caller). When `include_unstaged`
221/// is set, stages everything before committing. Returns the raw git stderr on
222/// any failure.
223pub fn run_git_action(
224    cwd: &str,
225    message: &str,
226    action: &str,
227    include_unstaged: bool,
228) -> Result<CommitPushOutcome, String> {
229    // Confirm this is a git repo before touching the working tree.
230    if run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).is_none() {
231        return Err("not a git repository".to_string());
232    }
233
234    if action != "push" && include_unstaged {
235        // Stage everything. A failure here is fatal (e.g. corrupt index).
236        let add = Command::new("git")
237            .args(["add", "-A"])
238            .current_dir(cwd)
239            .no_window()
240            .output()
241            .map_err(|e| format!("failed to run git: {e}"))?;
242        if !add.status.success() {
243            return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
244        }
245    }
246
247    let mut committed = false;
248    if action != "push" {
249        let staged_args = ["diff", "--cached", "--name-only"];
250        let has_staged = run_git(cwd, &staged_args)
251            .map(|s| s.lines().any(|l| !l.trim().is_empty()))
252            .unwrap_or(false);
253
254        if !has_staged && include_unstaged {
255            let has_changes = run_git(cwd, &["status", "--porcelain"])
256                .map(|s| s.lines().any(|l| !l.trim().is_empty()))
257                .unwrap_or(false);
258            if has_changes {
259                return Err("no staged changes to commit".to_string());
260            }
261        }
262
263        let commit = Command::new("git")
264            .args(["commit", "-m", message])
265            .current_dir(cwd)
266            .no_window()
267            .output()
268            .map_err(|e| format!("failed to run git: {e}"))?;
269        if has_staged && commit.status.success() {
270            committed = true;
271        } else if has_staged {
272            return Err(String::from_utf8_lossy(&commit.stderr).trim().to_string());
273        }
274    }
275
276    let mut pushed = false;
277    if action != "commit" {
278        // Push to the configured upstream. When there is no tracking branch git
279        // exits non-zero with a helpful message — surface it verbatim.
280        let push = Command::new("git")
281            .args(["push"])
282            .current_dir(cwd)
283            .no_window()
284            .output()
285            .map_err(|e| format!("failed to run git: {e}"))?;
286        if !push.status.success() {
287            return Err(String::from_utf8_lossy(&push.stderr).trim().to_string());
288        }
289        pushed = true;
290    }
291
292    let commit = run_git(cwd, &["rev-parse", "--short", "HEAD"]);
293
294    Ok(CommitPushOutcome {
295        success: true,
296        committed,
297        pushed,
298        commit,
299    })
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn parse_ahead_behind_normal() {
308        assert_eq!(parse_ahead_behind(Some("3\t1")), (3, 1));
309    }
310
311    #[test]
312    fn parse_ahead_behind_none() {
313        assert_eq!(parse_ahead_behind(None), (0, 0));
314    }
315
316    #[test]
317    fn parse_ahead_behind_no_upstream() {
318        assert_eq!(parse_ahead_behind(Some("")), (0, 0));
319    }
320}