1use std::ffi::{OsStr, OsString};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output};
5
6use termesh_core::{
7 GitBranch, GitDiffTarget, GitFailure, GitFailureKind, GitFileDiff, GitOperation,
8 GitRepositorySnapshot, GitResult,
9};
10
11use crate::{bounded_context_diff, bounded_diff, parse_status, GitService};
12
13const DIFF_LIMIT: usize = 256 * 1024;
14const ERROR_LIMIT: usize = 16 * 1024;
15
16#[derive(Debug, Default)]
17pub struct RealGitService;
18
19impl RealGitService {
20 pub fn new() -> Self {
21 Self
22 }
23
24 fn roots(&self, workspace: &Path) -> GitResult<(PathBuf, PathBuf, PathBuf)> {
25 let root = run_checked(workspace, &["rev-parse", "--show-toplevel"])?;
26 let repository_root = PathBuf::from(text(&root.stdout).trim_end());
27 if repository_root.as_os_str().is_empty() {
28 return Err(failure(
29 GitFailureKind::InvalidOutput,
30 "Git reported an empty repository root",
31 ));
32 }
33 let prefix = run_checked(workspace, &["rev-parse", "--show-prefix"])?;
34 let prefix = PathBuf::from(text(&prefix.stdout).trim_end());
35 let workspace_root = if prefix.as_os_str().is_empty() {
36 repository_root.clone()
37 } else {
38 repository_root.join(&prefix)
39 };
40 let scope = if prefix.as_os_str().is_empty() { ".".into() } else { prefix };
41 Ok((repository_root, workspace_root, scope))
42 }
43}
44
45impl GitService for RealGitService {
46 fn snapshot(&mut self, workspace: &Path) -> GitResult<GitRepositorySnapshot> {
47 let (repository_root, workspace_root, scope) = self.roots(workspace)?;
48 let status = run_checked(
52 &repository_root,
53 &["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z"],
54 )?;
55 let (branch, files) = parse_status(&repository_root, &status.stdout)?;
56 let worktree_args = diff_args(false, scope.as_os_str());
57 let index_args = diff_args(true, scope.as_os_str());
58 let worktree = run_os(&repository_root, &worktree_args)?;
59 let index = run_os(&repository_root, &index_args)?;
60 Ok(GitRepositorySnapshot {
61 repository_root,
62 workspace_root,
63 branch,
64 files,
65 context_diff: bounded_context_diff(&index.stdout, &worktree.stdout, DIFF_LIMIT)?,
66 })
67 }
68
69 fn diff(
70 &mut self,
71 workspace: &Path,
72 path: &Path,
73 target: GitDiffTarget,
74 ) -> GitResult<GitFileDiff> {
75 let (repository_root, _, _) = self.roots(workspace)?;
76 let args = diff_args(target == GitDiffTarget::Index, path.as_os_str());
77 let output = run_os(&repository_root, &args)?;
78 bounded_diff(path.to_path_buf(), target, &output.stdout, DIFF_LIMIT)
79 }
80
81 fn branches(&mut self, workspace: &Path) -> GitResult<Vec<GitBranch>> {
82 let (repository_root, _, _) = self.roots(workspace)?;
83 let output = run_checked(
84 &repository_root,
85 &["for-each-ref", "--format=%(HEAD)%00%(refname:short)", "refs/heads"],
86 )?;
87 let mut branches = Vec::new();
88 for line in output.stdout.split(|byte| *byte == b'\n').filter(|line| !line.is_empty()) {
89 let separator = line.iter().position(|byte| *byte == 0).ok_or_else(|| {
90 failure(GitFailureKind::InvalidOutput, "malformed local branch record")
91 })?;
92 let name = std::str::from_utf8(&line[separator + 1..]).map_err(|_| {
93 failure(GitFailureKind::InvalidOutput, "branch name is not valid UTF-8")
94 })?;
95 branches.push(GitBranch { name: name.into(), current: &line[..separator] == b"*" });
96 }
97 branches.sort_by(|left, right| left.name.cmp(&right.name));
98 Ok(branches)
99 }
100
101 fn execute(&mut self, workspace: &Path, operation: &GitOperation) -> GitResult<String> {
102 if matches!(operation, GitOperation::Commit { message } if message.trim().is_empty()) {
103 return Err(failure(GitFailureKind::Command, "commit message cannot be empty"));
104 }
105 if let GitOperation::Checkout { branch } = operation {
106 if !self.branches(workspace)?.iter().any(|item| item.name == *branch) {
107 return Err(failure(GitFailureKind::Command, "branch is not a local branch"));
108 }
109 }
110 let (repository_root, _, _) = self.roots(workspace)?;
111 let head_exists = run_raw(&repository_root, &["rev-parse", "--verify", "HEAD"])
112 .is_ok_and(|output| output.status.success());
113 let args = if matches!(operation, GitOperation::Push) {
114 push_args_for_repository(&repository_root)?
115 } else {
116 operation_args(operation, head_exists)
117 };
118 let output = run_os(&repository_root, &args)?;
119 let summary = text(&output.stdout).trim().to_owned();
120 Ok(if summary.is_empty() { "Git operation completed".into() } else { summary })
121 }
122}
123
124fn push_args_for_repository(repository_root: &Path) -> GitResult<Vec<OsString>> {
125 let branch = run_raw(repository_root, &["symbolic-ref", "--quiet", "--short", "HEAD"])
126 .map_err(|error| {
127 failure(GitFailureKind::Command, &format!("could not inspect Git branch: {error}"))
128 })?;
129 if !branch.status.success() {
130 return Err(failure(GitFailureKind::Command, "cannot publish a detached HEAD"));
131 }
132 let branch = std::str::from_utf8(&branch.stdout)
133 .map_err(|_| failure(GitFailureKind::InvalidOutput, "Git branch is not valid UTF-8"))?
134 .trim_end();
135 if branch.is_empty() {
136 return Err(failure(GitFailureKind::InvalidOutput, "Git reported an empty branch"));
137 }
138
139 let upstream = run_raw(
140 repository_root,
141 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
142 )
143 .map_err(|error| {
144 failure(GitFailureKind::Command, &format!("could not inspect Git upstream: {error}"))
145 })?;
146 let has_upstream = upstream.status.success() && !text(&upstream.stdout).trim().is_empty();
147 Ok(push_args(branch, has_upstream))
148}
149
150fn push_args(branch: &str, has_upstream: bool) -> Vec<OsString> {
151 if has_upstream {
152 vec!["push".into()]
153 } else {
154 vec!["push".into(), "--set-upstream".into(), "origin".into(), branch.into()]
155 }
156}
157
158pub(crate) fn operation_args(operation: &GitOperation, head_exists: bool) -> Vec<OsString> {
159 match operation {
160 GitOperation::Stage { path } => vec!["add".into(), "--".into(), path.as_os_str().into()],
161 GitOperation::Unstage { path } if head_exists => {
162 vec!["restore".into(), "--staged".into(), "--".into(), path.as_os_str().into()]
163 }
164 GitOperation::Unstage { path } => vec![
165 "rm".into(),
166 "--cached".into(),
167 "--ignore-unmatch".into(),
168 "--".into(),
169 path.as_os_str().into(),
170 ],
171 GitOperation::Commit { message } => vec!["commit".into(), "-m".into(), message.into()],
172 GitOperation::Checkout { branch } => vec!["switch".into(), "--".into(), branch.into()],
173 GitOperation::Fetch => vec!["fetch".into()],
174 GitOperation::Pull => vec!["pull".into(), "--ff-only".into()],
175 GitOperation::Push => vec!["push".into()],
176 }
177}
178
179fn diff_args(index: bool, path: &OsStr) -> Vec<OsString> {
180 let mut args: Vec<OsString> =
182 ["--no-optional-locks", "diff", "--no-ext-diff", "--no-color", "--unified=3"]
183 .into_iter()
184 .map(Into::into)
185 .collect();
186 if index {
187 args.push("--cached".into());
188 }
189 args.push("--".into());
190 args.push(path.into());
191 args
192}
193
194fn run_checked(cwd: &Path, args: &[&str]) -> GitResult<Output> {
195 let values: Vec<OsString> = args.iter().map(OsString::from).collect();
196 run_os(cwd, &values)
197}
198
199fn run_os(cwd: &Path, args: &[OsString]) -> GitResult<Output> {
200 let output = Command::new("git")
201 .current_dir(cwd)
202 .env("GIT_TERMINAL_PROMPT", "0")
203 .args(args)
204 .output()
205 .map_err(|error| match error.kind() {
206 io::ErrorKind::NotFound => {
207 failure(GitFailureKind::Unavailable, "Git executable not found")
208 }
209 _ => failure(GitFailureKind::Command, &format!("could not run Git: {error}")),
210 })?;
211 check_output(output)
212}
213
214fn run_raw(cwd: &Path, args: &[&str]) -> io::Result<Output> {
215 Command::new("git").current_dir(cwd).env("GIT_TERMINAL_PROMPT", "0").args(args).output()
216}
217
218fn check_output(output: Output) -> GitResult<Output> {
219 if output.status.success() {
220 return Ok(output);
221 }
222 let message = bounded_error(&output.stderr);
223 let kind = if message.to_ascii_lowercase().contains("not a git repository") {
224 GitFailureKind::NotRepository
225 } else {
226 GitFailureKind::Command
227 };
228 Err(failure(kind, if message.is_empty() { "Git command failed" } else { &message }))
229}
230
231fn bounded_error(bytes: &[u8]) -> String {
232 let text = text(bytes);
233 let mut end = ERROR_LIMIT.min(text.len());
234 while end > 0 && !text.is_char_boundary(end) {
235 end -= 1;
236 }
237 text[..end].trim().into()
238}
239
240fn text(bytes: &[u8]) -> String {
241 String::from_utf8_lossy(bytes).into_owned()
242}
243
244fn failure(kind: GitFailureKind, message: &str) -> GitFailure {
245 GitFailure { kind, message: message.into() }
246}
247
248#[cfg(test)]
249mod tests {
250 use std::ffi::OsString;
251
252 use termesh_core::GitOperation;
253
254 use super::{diff_args, operation_args, push_args};
255
256 fn strings(values: &[&str]) -> Vec<OsString> {
257 values.iter().map(OsString::from).collect()
258 }
259
260 #[test]
261 fn mutations_use_safe_structured_arguments() {
262 assert_eq!(
263 operation_args(&GitOperation::Stage { path: "-odd name.rs".into() }, true),
264 strings(&["add", "--", "-odd name.rs"])
265 );
266 assert_eq!(
267 operation_args(&GitOperation::Unstage { path: "src/lib.rs".into() }, true),
268 strings(&["restore", "--staged", "--", "src/lib.rs"])
269 );
270 assert_eq!(
271 operation_args(&GitOperation::Commit { message: "fix parser".into() }, true),
272 strings(&["commit", "-m", "fix parser"])
273 );
274 assert_eq!(
275 operation_args(&GitOperation::Checkout { branch: "feature/x".into() }, true),
276 strings(&["switch", "--", "feature/x"])
277 );
278 assert_eq!(operation_args(&GitOperation::Fetch, true), strings(&["fetch"]));
279 assert_eq!(operation_args(&GitOperation::Pull, true), strings(&["pull", "--ff-only"]));
280 assert_eq!(operation_args(&GitOperation::Push, true), strings(&["push"]));
281 assert_eq!(push_args("feature/new", true), strings(&["push"]));
282 assert_eq!(
283 push_args("feature/new", false),
284 strings(&["push", "--set-upstream", "origin", "feature/new"]),
285 );
286 }
287
288 #[test]
289 fn passive_reads_pass_no_optional_locks_before_the_subcommand() {
290 assert_eq!(
293 diff_args(true, "src/lib.rs".as_ref()),
294 strings(&[
295 "--no-optional-locks",
296 "diff",
297 "--no-ext-diff",
298 "--no-color",
299 "--unified=3",
300 "--cached",
301 "--",
302 "src/lib.rs",
303 ])
304 );
305 }
306
307 #[test]
308 fn unstage_on_an_unborn_branch_removes_only_the_index_entry() {
309 assert_eq!(
310 operation_args(&GitOperation::Unstage { path: "new.rs".into() }, false),
311 strings(&["rm", "--cached", "--ignore-unmatch", "--", "new.rs"])
312 );
313 }
314}