ssh_commander_core/tools/
git.rs1use 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
28pub async fn git_status(client: &SshClient, repo_path: &str) -> Result<GitStatus, ToolsError> {
30 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 let (status_block, log_block) = match output.split_once("--LOG--") {
58 Some((a, b)) => (a, b.trim()),
59 None => (output, ""),
60 };
61
62 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 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}