Skip to main content

dkdc_sh/
git.rs

1//! Git command abstractions (sync).
2
3use std::path::Path;
4use std::process::Command;
5
6use crate::{Error, require};
7
8/// Run a git command in a directory and return stdout.
9pub fn cmd(dir: &Path, args: &[&str]) -> Result<String, Error> {
10    cmd_with_env(dir, args, &[])
11}
12
13/// Run a git command with extra environment variables.
14///
15/// When `GIT_ASKPASS` is present in `env`, credential helpers are disabled
16/// to prevent interception by system keychains.
17pub fn cmd_with_env(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<String, Error> {
18    require("git")?;
19
20    let has_askpass = env.iter().any(|(k, _)| *k == "GIT_ASKPASS");
21
22    let mut command = Command::new("git");
23    if has_askpass {
24        command.args(["-c", "credential.helper="]);
25    }
26    command.args(args).current_dir(dir);
27    for (k, v) in env {
28        command.env(k, v);
29    }
30    let output = command.output()?;
31
32    if !output.status.success() {
33        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
34        return Err(Error::CommandFailed {
35            cmd: format!("git {}", args.first().unwrap_or(&"")),
36            detail: stderr,
37        });
38    }
39
40    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
41}
42
43/// Shallow-clone a repo (single branch, depth 1).
44pub fn clone_shallow(url: &str, dest: &Path, branch: &str) -> Result<(), Error> {
45    clone_shallow_with_env(url, dest, branch, &[])
46}
47
48/// Shallow-clone a repo with extra environment variables.
49pub fn clone_shallow_with_env(
50    url: &str,
51    dest: &Path,
52    branch: &str,
53    env: &[(&str, &str)],
54) -> Result<(), Error> {
55    require("git")?;
56
57    let has_askpass = env.iter().any(|(k, _)| *k == "GIT_ASKPASS");
58
59    let mut command = Command::new("git");
60    if has_askpass {
61        command.args(["-c", "credential.helper="]);
62    }
63    command.args([
64        "clone",
65        "--depth",
66        "1",
67        "--branch",
68        branch,
69        url,
70        &dest.to_string_lossy(),
71    ]);
72    for (k, v) in env {
73        command.env(k, v);
74    }
75    let output = command.output()?;
76
77    if !output.status.success() {
78        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
79        return Err(Error::CommandFailed {
80            cmd: "git clone".to_string(),
81            detail: stderr,
82        });
83    }
84
85    Ok(())
86}
87
88/// Clone from a local repo directory (fast, shares objects via hardlinks).
89pub fn clone_local(source: &Path, dest: &Path, branch: &str) -> Result<(), Error> {
90    require("git")?;
91
92    let output = Command::new("git")
93        .args([
94            "clone",
95            "--branch",
96            branch,
97            "--single-branch",
98            &source.to_string_lossy(),
99            &dest.to_string_lossy(),
100        ])
101        .output()?;
102
103    if !output.status.success() {
104        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
105        return Err(Error::CommandFailed {
106            cmd: "git clone (local)".to_string(),
107            detail: stderr,
108        });
109    }
110
111    Ok(())
112}
113
114/// Create and switch to a new branch.
115pub fn checkout_new_branch(dir: &Path, branch: &str) -> Result<(), Error> {
116    cmd(dir, &["checkout", "-b", branch])?;
117    Ok(())
118}
119
120/// Set a git config key in a repo.
121pub fn config_set(dir: &Path, key: &str, value: &str) -> Result<(), Error> {
122    cmd(dir, &["config", key, value])?;
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::fs;
130
131    /// Create a temporary git repo for testing.
132    fn temp_repo() -> tempfile::TempDir {
133        let dir = tempfile::tempdir().unwrap();
134        Command::new("git")
135            .args(["init"])
136            .current_dir(dir.path())
137            .output()
138            .unwrap();
139        Command::new("git")
140            .args(["config", "user.email", "test@test.com"])
141            .current_dir(dir.path())
142            .output()
143            .unwrap();
144        Command::new("git")
145            .args(["config", "user.name", "Test"])
146            .current_dir(dir.path())
147            .output()
148            .unwrap();
149        // Create an initial commit so HEAD exists
150        fs::write(dir.path().join("README.md"), "# test").unwrap();
151        Command::new("git")
152            .args(["add", "."])
153            .current_dir(dir.path())
154            .output()
155            .unwrap();
156        Command::new("git")
157            .args(["commit", "-m", "init"])
158            .current_dir(dir.path())
159            .output()
160            .unwrap();
161        dir
162    }
163
164    #[test]
165    fn test_cmd_status() {
166        let repo = temp_repo();
167        let output = cmd(repo.path(), &["status", "--short"]).unwrap();
168        assert!(output.is_empty()); // clean repo
169    }
170
171    #[test]
172    fn test_cmd_invalid_dir() {
173        let result = cmd(Path::new("/nonexistent_dir_12345"), &["status"]);
174        assert!(result.is_err());
175    }
176
177    #[test]
178    fn test_cmd_invalid_subcommand() {
179        let repo = temp_repo();
180        let result = cmd(repo.path(), &["not-a-real-subcommand"]);
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn test_config_set_and_read() {
186        let repo = temp_repo();
187        config_set(repo.path(), "user.name", "TestUser").unwrap();
188        let output = cmd(repo.path(), &["config", "user.name"]).unwrap();
189        assert_eq!(output.trim(), "TestUser");
190    }
191
192    #[test]
193    fn test_checkout_new_branch() {
194        let repo = temp_repo();
195        checkout_new_branch(repo.path(), "feature-test").unwrap();
196        let output = cmd(repo.path(), &["branch", "--show-current"]).unwrap();
197        assert_eq!(output.trim(), "feature-test");
198    }
199
200    #[test]
201    fn test_clone_local() {
202        let repo = temp_repo();
203        let dest = tempfile::tempdir().unwrap();
204        let dest_path = dest.path().join("cloned");
205        // Get current branch name
206        let branch = cmd(repo.path(), &["branch", "--show-current"])
207            .unwrap()
208            .trim()
209            .to_string();
210        clone_local(repo.path(), &dest_path, &branch).unwrap();
211        assert!(dest_path.join(".git").exists());
212    }
213
214    #[test]
215    fn test_cmd_with_env() {
216        let repo = temp_repo();
217        // GIT_AUTHOR_NAME env var doesn't affect `status`, but we verify the call succeeds
218        let output = cmd_with_env(
219            repo.path(),
220            &["status", "--short"],
221            &[("GIT_AUTHOR_NAME", "X")],
222        )
223        .unwrap();
224        assert!(output.is_empty());
225    }
226
227    #[test]
228    fn test_cmd_with_env_askpass_disables_credential_helper() {
229        let repo = temp_repo();
230        // When GIT_ASKPASS is provided, the command should include
231        // `-c credential.helper=` to disable system keychains.
232        // We verify by checking that the `-c` override appears as the
233        // last entry in credential.helper's config list (empty value
234        // resets the credential helper list in git's credential lookup).
235        let output = cmd_with_env(
236            repo.path(),
237            &["config", "--get-all", "credential.helper"],
238            &[("GIT_ASKPASS", "/bin/echo")],
239        )
240        .unwrap();
241        // The last line should be empty (the `-c credential.helper=` override).
242        // In git's credential resolution, an empty entry resets the list,
243        // effectively disabling all previously configured helpers.
244        let lines: Vec<&str> = output.lines().collect();
245        assert!(
246            !lines.is_empty(),
247            "credential.helper should have at least one entry"
248        );
249        assert_eq!(
250            lines.last().unwrap(),
251            &"",
252            "last credential.helper entry should be empty (disabling helpers), got: {lines:?}"
253        );
254    }
255
256    #[test]
257    fn test_cmd_with_env_no_askpass_preserves_credential_helper() {
258        let repo = temp_repo();
259        // Set a credential helper in the repo
260        config_set(repo.path(), "credential.helper", "store").unwrap();
261        // Without GIT_ASKPASS, the configured credential.helper should be preserved
262        let output = cmd_with_env(
263            repo.path(),
264            &["config", "credential.helper"],
265            &[("GIT_AUTHOR_NAME", "X")],
266        )
267        .unwrap();
268        assert_eq!(output.trim(), "store");
269    }
270
271    #[test]
272    fn test_cmd_with_env_askpass_env_is_passed_through() {
273        let repo = temp_repo();
274        // Verify GIT_ASKPASS env var doesn't interfere with normal git operations.
275        // We use `status` which always succeeds in a valid repo.
276        let askpass_script = "/usr/bin/true";
277        let output = cmd_with_env(repo.path(), &["status"], &[("GIT_ASKPASS", askpass_script)]);
278        assert!(output.is_ok());
279    }
280}