Skip to main content

ssh_commander_core/tools/
git.rs

1//! Git deploy-state — pull a quick snapshot of a repo on a remote host:
2//! current branch, HEAD sha, ahead/behind vs upstream, dirty file count,
3//! and last commit summary.
4//!
5//! Single round-trip: `git -C <path> status --porcelain=v2 --branch` plus
6//! `git log -1 --format=%H%x09%an%x09%ar%x09%s`, joined with `; ` so we
7//! pay one ssh exec.
8
9use crate::ssh::SshClient;
10use crate::tools::ToolsError;
11
12#[derive(Debug, Clone)]
13pub struct GitStatus {
14    pub repo_path: String,
15    pub branch: Option<String>,
16    pub head: Option<String>,
17    pub upstream: Option<String>,
18    pub ahead: u32,
19    pub behind: u32,
20    pub dirty_files: u32,
21    pub untracked_files: u32,
22    pub last_commit_sha: Option<String>,
23    pub last_commit_author: Option<String>,
24    pub last_commit_age: Option<String>,
25    pub last_commit_subject: Option<String>,
26}
27
28/// Fetch deploy-state for `repo_path` on the given SSH connection.
29pub async fn git_status(client: &SshClient, repo_path: &str) -> Result<GitStatus, ToolsError> {
30    // Light shell-quoting: only single-quote the path. Reject paths with
31    // single quotes outright — repository paths are user-provided so we
32    // don't trust them, and `git -C` needs a literal directory anyway.
33    if repo_path.contains('\'') {
34        return Err(ToolsError::Parse(
35            "repo path contains a single quote".into(),
36        ));
37    }
38
39    let cmd = format!(
40        "git -C '{path}' status --porcelain=v2 --branch 2>&1 ; \
41         echo '--LOG--' ; \
42         git -C '{path}' log -1 --format='%H%x09%an%x09%ar%x09%s' 2>&1",
43        path = repo_path
44    );
45
46    let out = client
47        .execute_command_full(&cmd)
48        .await
49        .map_err(|e| ToolsError::SshExec(e.to_string()))?;
50
51    let combined = out.combined();
52    parse(repo_path, &combined)
53}
54
55fn parse(repo_path: &str, output: &str) -> Result<GitStatus, ToolsError> {
56    // Split the two halves at our literal sentinel.
57    let (status_block, log_block) = match output.split_once("--LOG--") {
58        Some((a, b)) => (a, b.trim()),
59        None => (output, ""),
60    };
61
62    // If `git` produced "fatal: not a git repository", reject early so
63    // the UI can show a clean message instead of zeroed fields.
64    if status_block.contains("fatal: not a git repository") || status_block.contains("fatal: ") {
65        let first_line = status_block.lines().next().unwrap_or("git error").trim();
66        return Err(ToolsError::RemoteCommand {
67            exit: None,
68            message: first_line.to_string(),
69        });
70    }
71
72    let mut branch: Option<String> = None;
73    let mut head: Option<String> = None;
74    let mut upstream: Option<String> = None;
75    let mut ahead: u32 = 0;
76    let mut behind: u32 = 0;
77    let mut dirty_files: u32 = 0;
78    let mut untracked_files: u32 = 0;
79
80    for line in status_block.lines() {
81        if let Some(rest) = line.strip_prefix("# branch.head ") {
82            let v = rest.trim();
83            if v != "(detached)" {
84                branch = Some(v.to_string());
85            }
86        } else if let Some(rest) = line.strip_prefix("# branch.oid ") {
87            let v = rest.trim();
88            if v != "(initial)" {
89                head = Some(v.to_string());
90            }
91        } else if let Some(rest) = line.strip_prefix("# branch.upstream ") {
92            upstream = Some(rest.trim().to_string());
93        } else if let Some(rest) = line.strip_prefix("# branch.ab ") {
94            // Format: "+<ahead> -<behind>"
95            for part in rest.split_whitespace() {
96                if let Some(n) = part.strip_prefix('+') {
97                    ahead = n.parse().unwrap_or(0);
98                } else if let Some(n) = part.strip_prefix('-') {
99                    behind = n.parse().unwrap_or(0);
100                }
101            }
102        } else if line.starts_with("? ") {
103            untracked_files += 1;
104        } else if line.starts_with("1 ") || line.starts_with("2 ") || line.starts_with("u ") {
105            dirty_files += 1;
106        }
107    }
108
109    let mut last_commit_sha = None;
110    let mut last_commit_author = None;
111    let mut last_commit_age = None;
112    let mut last_commit_subject = None;
113    if !log_block.is_empty() && !log_block.starts_with("fatal:") {
114        let first_line = log_block.lines().next().unwrap_or("");
115        let mut parts = first_line.splitn(4, '\t');
116        last_commit_sha = parts
117            .next()
118            .map(|s| s.to_string())
119            .filter(|s| !s.is_empty());
120        last_commit_author = parts
121            .next()
122            .map(|s| s.to_string())
123            .filter(|s| !s.is_empty());
124        last_commit_age = parts
125            .next()
126            .map(|s| s.to_string())
127            .filter(|s| !s.is_empty());
128        last_commit_subject = parts
129            .next()
130            .map(|s| s.to_string())
131            .filter(|s| !s.is_empty());
132    }
133
134    Ok(GitStatus {
135        repo_path: repo_path.to_string(),
136        branch,
137        head,
138        upstream,
139        ahead,
140        behind,
141        dirty_files,
142        untracked_files,
143        last_commit_sha,
144        last_commit_author,
145        last_commit_age,
146        last_commit_subject,
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn parses_clean_repo() {
156        let sample = "\
157# branch.oid abc123def
158# branch.head main
159# branch.upstream origin/main
160# branch.ab +0 -0
161--LOG--
162abc123def\tAlice\t2 hours ago\tFix the thing
163";
164        let s = parse("/srv/app", sample).unwrap();
165        assert_eq!(s.branch.as_deref(), Some("main"));
166        assert_eq!(s.head.as_deref(), Some("abc123def"));
167        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
168        assert_eq!(s.ahead, 0);
169        assert_eq!(s.behind, 0);
170        assert_eq!(s.dirty_files, 0);
171        assert_eq!(s.last_commit_subject.as_deref(), Some("Fix the thing"));
172    }
173
174    #[test]
175    fn parses_dirty_repo_with_ahead_behind() {
176        let sample = "\
177# branch.oid abc
178# branch.head feat
179# branch.upstream origin/feat
180# branch.ab +3 -1
1811 .M N... 100644 100644 100644 aaa bbb file1.txt
1822 R. N... 100644 100644 100644 ccc ddd R100 file2.txt\tfile2-old.txt
183? newfile.txt
184? other.txt
185--LOG--
186deadbeef\tBob\t1 day ago\tWIP
187";
188        let s = parse(".", sample).unwrap();
189        assert_eq!(s.ahead, 3);
190        assert_eq!(s.behind, 1);
191        assert_eq!(s.dirty_files, 2);
192        assert_eq!(s.untracked_files, 2);
193    }
194
195    #[test]
196    fn rejects_not_a_repo() {
197        let sample = "fatal: not a git repository (or any of the parent directories): .git";
198        assert!(parse("/tmp", sample).is_err());
199    }
200}