Skip to main content

rskit_git/cli/
mod.rs

1//! Git CLI implementation for command-oriented workflows.
2
3pub mod auth;
4mod manage;
5mod read;
6mod write;
7
8use std::path::{Path, PathBuf};
9
10use rskit_errors::AppResult;
11use rskit_process::{ProcessConfig, ProcessResult, ProcessSpec};
12
13use crate::core::Executor;
14use crate::error::GitError;
15use crate::types::Oid;
16
17/// Git CLI command runner rooted at a repository.
18pub struct GitCli {
19    root: PathBuf,
20}
21
22impl GitCli {
23    /// Creates a Git CLI runner rooted at the repository path.
24    pub fn new(root: PathBuf) -> Self {
25        Self { root }
26    }
27
28    /// Returns the repository root.
29    pub fn root(&self) -> &Path {
30        &self.root
31    }
32
33    pub(crate) fn run(&self, args: &[&str]) -> AppResult<Vec<u8>> {
34        let output = self.run_result(args)?;
35        if output.success() && !output.stdout_truncated && !output.stderr_truncated {
36            Ok(output.stdout_bytes)
37        } else {
38            Err(Self::command_failed(args, output))
39        }
40    }
41
42    pub(crate) fn run_result(&self, args: &[&str]) -> AppResult<ProcessResult> {
43        let command = ProcessSpec::new("git")
44            .args(args.iter().copied())
45            .dir(&self.root)
46            .env("GIT_TERMINAL_PROMPT", "0");
47        let config = ProcessConfig::default().with_timeout(None);
48        rskit_process::run(&command, &config)
49    }
50
51    #[allow(dead_code)]
52    pub(crate) fn not_implemented<T>(&self, operation: &'static str) -> AppResult<T> {
53        Err(GitError::NotImplemented { operation }.into())
54    }
55
56    pub(crate) fn command_failed(args: &[&str], output: ProcessResult) -> rskit_errors::AppError {
57        GitError::CommandFailed {
58            args: args.iter().map(|arg| (*arg).to_string()).collect(),
59            exit_code: output.exit_code,
60            stdout: output.stdout.trim().to_string(),
61            stderr: output.stderr.trim().to_string(),
62            stdout_truncated: output.stdout_truncated,
63            stderr_truncated: output.stderr_truncated,
64        }
65        .into()
66    }
67}
68
69pub(crate) fn parse_oid(hex: &str) -> AppResult<Oid> {
70    let hex = hex.trim();
71    if hex.len() != 40 {
72        return Err(GitError::InvalidOid {
73            value: hex.to_string(),
74        }
75        .into());
76    }
77
78    let mut bytes = [0u8; 20];
79    for i in 0..20 {
80        bytes[i] =
81            u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| GitError::InvalidOid {
82                value: hex.to_string(),
83            })?;
84    }
85
86    Ok(Oid::from_bytes(bytes))
87}
88
89impl Executor for GitCli {
90    fn exec(&self, args: &[&str]) -> AppResult<Vec<u8>> {
91        self.run(args)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use std::time::Duration;
98
99    use rskit_process::ProcessResult;
100
101    use super::*;
102
103    #[test]
104    fn new_exposes_root_and_executes_git_commands() {
105        let root = rskit_testutil::test_workspace!("git-cli-root");
106        let cli = GitCli::new(root.path().to_path_buf());
107
108        assert_eq!(cli.root(), root.path());
109        let version = cli.exec(&["--version"]).expect("git version runs");
110        assert!(String::from_utf8_lossy(&version).contains("git version"));
111    }
112
113    #[test]
114    fn command_failed_preserves_diagnostics_and_truncation_flags() {
115        let output = ProcessResult::completed(
116            Some(129),
117            b"usage\n".to_vec(),
118            b"bad args\n".to_vec(),
119            false,
120            false,
121            Duration::ZERO,
122            true,
123            false,
124        );
125
126        let err = GitCli::command_failed(&["bad"], output);
127
128        assert_eq!(err.code(), rskit_errors::ErrorCode::ExternalService);
129        assert!(err.message().contains("external service error"));
130    }
131
132    #[test]
133    fn parse_oid_rejects_wrong_length_and_invalid_hex() {
134        assert!(parse_oid("abc").is_err());
135        assert!(parse_oid("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_err());
136        assert_eq!(
137            parse_oid("0000000000000000000000000000000000000001")
138                .unwrap()
139                .to_string(),
140            "0000000000000000000000000000000000000001"
141        );
142    }
143
144    #[test]
145    fn not_implemented_maps_to_internal_error() {
146        let root = rskit_testutil::test_workspace!("git-cli-not-implemented");
147        let cli = GitCli::new(root.path().to_path_buf());
148
149        let err = cli
150            .not_implemented::<()>("future operation")
151            .expect_err("operation is not implemented");
152
153        assert_eq!(err.code(), rskit_errors::ErrorCode::InvalidInput);
154    }
155}