Skip to main content

termesh_git/
real.rs

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        // `--no-optional-locks` keeps a passive refresh from taking `.git/index.lock`: we
49        // refresh on every coalesced filesystem batch, and the developer may be running
50        // `git add -p` or a rebase in the managed terminal at the same time (ADR-0010 ยง7).
51        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    // `diff` refreshes the index too, so it takes the same passive-read stance as `status`.
181    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        // Git only accepts this as a global flag; after the subcommand it is a hard error,
291        // so the position is part of the contract, not formatting.
292        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}