gitai/remote/common/
fetch.rs

1use std::borrow::Cow;
2use std::path::Path;
3use std::process::Command;
4
5use cause::Cause;
6use cause::cause;
7use git2::Repository;
8use regex::Regex;
9use temp_dir::TempDir;
10
11use super::ErrorType;
12use super::ErrorType::{
13    GitCheckoutChangeDirectory, GitCheckoutCommand, GitCheckoutCommandExitStatus, GitCloneCommand,
14    GitFetchCommand, GitFetchCommandExitStatus, GitLsRemoteCommand, GitLsRemoteCommandExitStatus,
15    GitLsRemoteCommandStdoutDecode, GitLsRemoteCommandStdoutRegex, TempDirCreation,
16};
17use super::Method;
18use super::Parsed;
19
20pub fn fetch_target_to_tempdir(prefix: &str, parsed: &Parsed) -> Result<TempDir, Cause<ErrorType>> {
21    let tempdir = TempDir::with_prefix(prefix).map_err(|e| cause!(TempDirCreation).src(e))?;
22
23    std::env::set_current_dir(tempdir.path())
24        .map_err(|e| cause!(GitCheckoutChangeDirectory).src(e))?;
25
26    git_clone(prefix, tempdir.path(), parsed)?;
27
28    let method = match parsed.mtd.as_ref() {
29        Some(Method::Partial) => git_checkout_partial,
30        Some(Method::ShallowNoSparse) => git_checkout_shallow_no_sparse,
31        Some(Method::Shallow) | None => git_checkout_shallow_with_sparse,
32    };
33
34    method(prefix, tempdir.path(), parsed)?;
35
36    Ok(tempdir)
37}
38
39fn git_clone(prefix: &str, path: &Path, parsed: &Parsed) -> Result<(), Cause<ErrorType>> {
40    println!("  - {prefix}clone --no-checkout: {}", parsed.url);
41
42    std::env::set_current_dir(path).map_err(|e| cause!(GitCloneCommand).src(e))?;
43
44    Repository::clone(&parsed.url, ".").map_err(|e| cause!(GitCloneCommand).src(e))?;
45
46    Ok(())
47}
48
49fn git_checkout_partial(
50    prefix: &str,
51    path: &Path,
52    parsed: &Parsed,
53) -> Result<(), Cause<ErrorType>> {
54    let rev = identify_commit_hash(path, parsed)?;
55    let rev = if let Some(r) = rev {
56        println!("  - {prefix}checkout partial: {} ({})", r, parsed.rev);
57        r
58    } else {
59        println!("  - {prefix}checkout partial: {}", parsed.rev);
60        parsed.rev.clone()
61    };
62
63    let out = Command::new("git")
64        .args([
65            "-C",
66            path.to_str().expect("Failed to convert path to string for git checkout; path contains invalid Unicode characters"),
67            "checkout",
68            "--progress",
69            rev.as_ref(),
70            "--",
71            parsed.src.as_ref(),
72        ])
73        .output()
74        .map_err(|e| cause!(GitCheckoutCommand).src(e))?;
75
76    handle_git_output(out, "git checkout", GitCheckoutCommandExitStatus)
77}
78
79fn git_checkout_shallow_no_sparse(
80    prefix: &str,
81    path: &Path,
82    parsed: &Parsed,
83) -> Result<(), Cause<ErrorType>> {
84    git_checkout_shallow_core(prefix, path, parsed, false)
85}
86
87fn git_checkout_shallow_with_sparse(
88    prefix: &str,
89    path: &Path,
90    parsed: &Parsed,
91) -> Result<(), Cause<ErrorType>> {
92    git_checkout_shallow_core(prefix, path, parsed, true)
93}
94
95fn git_checkout_shallow_core(
96    prefix: &str,
97    path: &Path,
98    parsed: &Parsed,
99    use_sparse: bool,
100) -> Result<(), Cause<ErrorType>> {
101    let rev = identify_commit_hash(path, parsed)?;
102    let no_sparse = if use_sparse { "" } else { " (no sparse)" };
103    let rev = if let Some(r) = rev {
104        println!(
105            "  - {prefix}checkout shallow{no_sparse}: {r} ({})",
106            parsed.rev
107        );
108        r
109    } else {
110        println!("  - {prefix}checkout shallow{no_sparse}: {}", parsed.rev);
111        parsed.rev.clone()
112    };
113
114    if use_sparse {
115        // Make a kind of absolute path from repository root for sparse checkout.
116        let sparse_path: Cow<'_, str> = if parsed.src.starts_with('/') {
117            parsed.src.as_str().into()
118        } else {
119            format!("/{}", &parsed.src).into()
120        };
121
122        let out = Command::new("git")
123            .args([
124                "-C",
125                path.to_str()
126                    .expect("Failed to convert path to string for sparse checkout; path contains invalid Unicode characters"),
127                "sparse-checkout",
128                "set",
129                "--no-cone",
130                &sparse_path,
131            ])
132            .output();
133
134        let output = out.expect("Failed to execute git sparse-checkout command. Ensure 'git' is installed and in your PATH, and you have necessary permissions.");
135
136        if !output.status.success() {
137            // sparse-checkout command is optional, even if it failed,
138            // subsequent sequence will be performed without any problem.
139            println!("    - {prefix}Could not activate sparse-checkout feature.");
140            println!("    - {prefix}Your git client might not support this feature.");
141
142            // Print stderr for more context, as the command did run but failed.
143            let stderr = String::from_utf8_lossy(&output.stderr);
144            if !stderr.trim().is_empty() {
145                println!("    - {prefix}  stderr: {}", stderr.trim());
146            }
147        }
148    }
149
150    let out = Command::new("git")
151        .args([
152            "-C",
153            path.to_str().expect("Failed to convert path to string for git fetch; path contains invalid Unicode characters"),
154            "fetch",
155            "--depth",
156            "1",
157            "--progress",
158            "origin",
159            rev.as_ref(),
160        ])
161        .output()
162        .map_err(|e| cause!(GitFetchCommand).src(e))?;
163
164    if !out.status.success() {
165        let error = String::from_utf8(out.stderr)
166            .unwrap_or("Could not get even a error output of git fetch command".to_string());
167        return Err(cause!(GitFetchCommandExitStatus, error));
168    }
169
170    let out = Command::new("git")
171        .args([
172            "-C",
173            path.to_str().expect("Failed to convert path to string for git checkout; path contains invalid Unicode characters"),
174            "checkout",
175            "--progress",
176            "FETCH_HEAD",
177        ])
178        .output()
179        .map_err(|e| cause!(GitCheckoutCommand).src(e))?;
180
181    handle_git_output(out, "git checkout", GitCheckoutCommandExitStatus)
182}
183
184fn handle_git_output(
185    out: std::process::Output,
186    command_name: &str,
187    error_variant: ErrorType,
188) -> Result<(), Cause<ErrorType>> {
189    if out.status.success() {
190        Ok(())
191    } else {
192        let error = String::from_utf8(out.stderr).unwrap_or(format!(
193            "Could not get even a error output of {command_name} command"
194        ));
195        Err(cause!(error_variant, error))
196    }
197}
198
199fn identify_commit_hash(path: &Path, parsed: &Parsed) -> Result<Option<String>, Cause<ErrorType>> {
200    let out = Command::new("git")
201        .args([
202            "-C",
203            path.to_str().expect("Failed to convert path to string for git ls-remote; path contains invalid Unicode characters"),
204            "ls-remote",
205            "--heads",
206            "--tags",
207            parsed.url.as_ref(),
208        ])
209        .output()
210        .map_err(|e| cause!(GitLsRemoteCommand).src(e))?;
211
212    if !out.status.success() {
213        let error = String::from_utf8(out.stderr)
214            .unwrap_or("Could not get even a error output of git ls-remote command".to_string());
215        return Err(cause!(GitLsRemoteCommandExitStatus).msg(error));
216    }
217
218    let stdout =
219        String::from_utf8(out.stdout).map_err(|e| cause!(GitLsRemoteCommandStdoutDecode).src(e))?;
220    let lines = stdout.lines();
221
222    let re_in_line = Regex::new(&format!(
223        "^((?:[0-9a-fA-F]){{40}})\\s+(.*{})(\\^\\{{\\}})?$",
224        regex::escape(parsed.rev.as_ref())
225    ))
226    .map_err(|e| cause!(GitLsRemoteCommandStdoutRegex).src(e))?;
227
228    let matched = lines.filter_map(|l| {
229        let cap = re_in_line.captures(l)?;
230        let hash = cap.get(1)?.as_str().to_owned();
231        let name = cap.get(2)?.as_str().to_owned();
232
233        // Check whether the name is same as `parsed.rev` without doubt,
234        // since current regex match method might have some ambiguity.
235        // (e.g. if `.` included in 'parsed.rev')
236        if !name.contains(&parsed.rev) {
237            return None;
238        }
239
240        let wrongness = usize::from(cap.get(3).is_some());
241
242        Some((hash, name, wrongness))
243    });
244    let identified = matched.min_by(|l, r| l.2.cmp(&r.2));
245
246    if let Some((rev, _, _)) = identified {
247        Ok(Some(rev))
248    } else {
249        // There is no items among refs/heads and refs/tags.
250        // `parsed.rev` must be a commit hash value or at least part of that.
251        Ok(None)
252    }
253}