xvc_core/util/
git.rs

1//! Git operations for Xvc repositories
2use std::{ffi::OsString, path::PathBuf, str::FromStr};
3
4use crate::XvcRoot;
5use subprocess::Exec;
6use xvc_logging::{debug, XvcOutputSender};
7
8use crate::{Error, Result};
9use std::path::Path;
10
11use xvc_walker::{build_ignore_patterns, AbsolutePath, IgnoreRules};
12
13use crate::GIT_DIR;
14
15use super::xvcignore::COMMON_IGNORE_PATTERNS;
16/// Check whether a path is inside a Git repository.
17/// It returns `None` if not, otherwise returns the closest directory with `.git`.
18/// It works by checking `.git` directories in parents, until no more parent left.
19pub fn inside_git(path: &Path) -> Option<PathBuf> {
20    let mut pb = PathBuf::from(path)
21        .canonicalize()
22        .expect("Cannot canonicalize the path. Possible symlink loop.");
23    loop {
24        if pb.join(GIT_DIR).is_dir() {
25            return Some(pb);
26        } else if pb.parent().is_none() {
27            return None;
28        } else {
29            pb.pop();
30        }
31    }
32}
33
34/// Returns [xvc_walker::IgnoreRules] for `.gitignore`
35/// It's used to check whether a path is already ignored by Git.
36pub fn build_gitignore(git_root: &AbsolutePath) -> Result<IgnoreRules> {
37    let rules = build_ignore_patterns(
38        COMMON_IGNORE_PATTERNS,
39        git_root,
40        ".gitignore".to_owned().as_ref(),
41    )?;
42
43    Ok(rules)
44}
45
46/// Find the absolute path to the git executable to run
47/// TODO: This must be cached. It makes a which request every time a command runs
48pub fn get_absolute_git_command(git_command: &str) -> Result<String> {
49    let git_cmd_path = PathBuf::from(git_command);
50    let git_cmd = if git_cmd_path.is_absolute() {
51        git_command.to_string()
52    } else {
53        let cmd_path = which::which(git_command)?;
54        cmd_path.to_string_lossy().to_string()
55    };
56    Ok(git_cmd)
57}
58
59/// Run a git command with a specific git binary
60pub fn exec_git(git_command: &str, xvc_directory: &str, args_str_vec: &[&str]) -> Result<String> {
61    let mut args = vec!["-C", xvc_directory];
62    args.extend(args_str_vec);
63    let args: Vec<OsString> = args
64        .iter()
65        .map(|s| OsString::from_str(s).unwrap())
66        .collect();
67    let proc_res = Exec::cmd(git_command).args(&args).capture()?;
68
69    match proc_res.exit_status {
70        subprocess::ExitStatus::Exited(0) => Ok(proc_res.stdout_str()),
71        subprocess::ExitStatus::Exited(_) => Err(Error::GitProcessError {
72            stdout: proc_res.stdout_str(),
73            stderr: proc_res.stderr_str(),
74        }),
75        subprocess::ExitStatus::Signaled(_)
76        | subprocess::ExitStatus::Other(_)
77        | subprocess::ExitStatus::Undetermined => Err(Error::GitProcessError {
78            stdout: proc_res.stdout_str(),
79            stderr: proc_res.stderr_str(),
80        }),
81    }
82}
83
84/// Get files tracked by git
85///
86/// NOTE: Assumptions for this function:
87/// - No submodules
88pub fn get_git_tracked_files(git_command: &str, xvc_directory: &str) -> Result<Vec<String>> {
89    let git_ls_files_out = exec_git(
90        git_command,
91        xvc_directory,
92        // XXX: When core.quotepath is in its default value, all UTF-8 paths are converted to octal
93        // strings and we lose the ability to match them. We supply a one off config value to set
94        // it to off.
95        &["-c", "core.quotepath=off", "ls-files", "--full-name"],
96    )?;
97    let git_ls_files_out = git_ls_files_out
98        .lines()
99        .map(|s| s.to_string())
100        .collect::<Vec<String>>();
101    Ok(git_ls_files_out)
102}
103
104/// Stash user's staged files to avoid committing them before auto-commit
105pub fn stash_user_staged_files(
106    output_snd: &XvcOutputSender,
107    git_command: &str,
108    xvc_directory: &str,
109) -> Result<String> {
110    // Do we have user staged files?
111    let git_diff_staged_out = exec_git(
112        git_command,
113        xvc_directory,
114        &["diff", "--name-only", "--cached"],
115    )?;
116
117    // If so stash them
118    if !git_diff_staged_out.trim().is_empty() {
119        debug!(
120            output_snd,
121            "Stashing user staged files: {git_diff_staged_out}"
122        );
123        let stash_out = exec_git(git_command, xvc_directory, &["stash", "push", "--staged"])?;
124        debug!(output_snd, "Stashed user staged files: {stash_out}");
125    }
126
127    Ok(git_diff_staged_out)
128}
129
130/// Unstash user's staged files after auto-commit
131pub fn unstash_user_staged_files(
132    output_snd: &XvcOutputSender,
133    git_command: &str,
134    xvc_directory: &str,
135) -> Result<()> {
136    let res_git_stash_pop = exec_git(git_command, xvc_directory, &["stash", "pop", "--index"])?;
137    debug!(
138        output_snd,
139        "Unstashed user staged files: {res_git_stash_pop}"
140    );
141    Ok(())
142}
143
144/// Checkout a git branch or tag before running an Xvc command
145pub fn git_checkout_ref(
146    output_snd: &XvcOutputSender,
147    xvc_root: &XvcRoot,
148    from_ref: &str,
149) -> Result<()> {
150    let xvc_directory = xvc_root.as_path().to_str().unwrap();
151    let git_command_option = xvc_root.config().get_str("git.command")?.option;
152    let git_command = get_absolute_git_command(&git_command_option)?;
153
154    let git_diff_staged_out = stash_user_staged_files(output_snd, &git_command, xvc_directory)?;
155    exec_git(&git_command, xvc_directory, &["checkout", from_ref])?;
156
157    if !git_diff_staged_out.trim().is_empty() {
158        debug!("Unstashing user staged files: {git_diff_staged_out}");
159        unstash_user_staged_files(output_snd, &git_command, xvc_directory)?;
160    }
161    Ok(())
162}
163
164/// This receives `xvc_root` ownership because as a final operation, it must drop the root to
165/// record the last entity counter before commit.
166pub fn handle_git_automation(
167    output_snd: &XvcOutputSender,
168    xvc_root: &XvcRoot,
169    to_branch: Option<&str>,
170    xvc_cmd: &str,
171) -> Result<()> {
172    let xvc_root_dir = xvc_root.as_path().to_path_buf();
173    let xvc_root_str = xvc_root_dir.to_str().unwrap();
174    let use_git = xvc_root.config().get_bool("git.use_git")?.option;
175    let auto_commit = xvc_root.config().get_bool("git.auto_commit")?.option;
176    let auto_stage = xvc_root.config().get_bool("git.auto_stage")?.option;
177    let git_command_str = xvc_root.config().get_str("git.command")?.option;
178    let git_command = get_absolute_git_command(&git_command_str)?;
179    let xvc_dir = xvc_root.xvc_dir().clone();
180    let xvc_dir_str = xvc_dir.to_str().unwrap();
181
182    if use_git {
183        if auto_commit {
184            git_auto_commit(
185                output_snd,
186                &git_command,
187                xvc_root_str,
188                xvc_dir_str,
189                xvc_cmd,
190                to_branch,
191            )?;
192        } else if auto_stage {
193            git_auto_stage(output_snd, &git_command, xvc_root_str, xvc_dir_str)?;
194        }
195    }
196
197    Ok(())
198}
199
200/// Commit `.xvc` directory after Xvc operations
201pub fn git_auto_commit(
202    output_snd: &XvcOutputSender,
203    git_command: &str,
204    xvc_root_str: &str,
205    xvc_dir_str: &str,
206    xvc_cmd: &str,
207    to_branch: Option<&str>,
208) -> Result<()> {
209    debug!(output_snd, "Using Git: {git_command}");
210
211    let git_diff_staged_out = stash_user_staged_files(output_snd, git_command, xvc_root_str)?;
212
213    if let Some(branch) = to_branch {
214        debug!(output_snd, "Checking out branch {branch}");
215        exec_git(git_command, xvc_root_str, &["checkout", "-b", branch])?;
216    }
217
218    // Add and commit `.xvc`
219    match exec_git(
220        git_command,
221        xvc_root_str,
222        // We check the output of the git add command to see if there were any files added.
223        // "--verbose" is required to get the output we need.
224        &[
225            "add",
226            "--verbose",
227            xvc_dir_str,
228            "*.gitignore",
229            "*.xvcignore",
230        ],
231    ) {
232        Ok(git_add_output) => {
233            if git_add_output.trim().is_empty() {
234                debug!(output_snd, "No files to commit");
235                return Ok(());
236            } else {
237                match exec_git(
238                    git_command,
239                    xvc_root_str,
240                    &[
241                        "commit",
242                        "-m",
243                        &format!("Xvc auto-commit after '{xvc_cmd}'"),
244                    ],
245                ) {
246                    Ok(res_git_commit) => {
247                        debug!(output_snd, "Committing .xvc/ to git: {res_git_commit}");
248                    }
249                    Err(e) => {
250                        debug!(output_snd, "Error committing .xvc/ to git: {e}");
251                        return Err(e);
252                    }
253                }
254            }
255        }
256        Err(e) => {
257            debug!(output_snd, "Error adding .xvc/ to git: {e}");
258            return Err(e);
259        }
260    }
261
262    // Pop the stash if there were files we stashed
263
264    if !git_diff_staged_out.trim().is_empty() {
265        debug!(
266            output_snd,
267            "Unstashing user staged files: {git_diff_staged_out}"
268        );
269        unstash_user_staged_files(output_snd, git_command, xvc_root_str)?;
270    }
271    Ok(())
272}
273
274/// runs `git add .xvc *.gitignore *.xvcignore` to stage the files after Xvc operations
275pub fn git_auto_stage(
276    output_snd: &XvcOutputSender,
277    git_command: &str,
278    xvc_root_str: &str,
279    xvc_dir_str: &str,
280) -> Result<()> {
281    let res_git_add = exec_git(
282        git_command,
283        xvc_root_str,
284        &["add", xvc_dir_str, "*.gitignore", "*.xvcignore"],
285    )?;
286    debug!(output_snd, "Staging .xvc/ to git: {res_git_add}");
287    Ok(())
288}
289
290/// Run `git check-ignore` to check if a path is ignored by Git
291pub fn git_ignored(git_command: &str, xvc_root_str: &str, path: &str) -> Result<bool> {
292    let command_res = exec_git(git_command, xvc_root_str, &["check-ignore", path])?;
293
294    if command_res.trim().is_empty() {
295        Ok(false)
296    } else {
297        Ok(true)
298    }
299}
300
301/// Return all tags and branches from a repository using Gix
302///
303/// TODO: We can add prefix listing if there is a performance issue for large repos here
304pub fn gix_list_references(repo_path: &Path) -> Result<Vec<String>> {
305    // We use map error because gix::discover::Error is a large struct
306    let repo = gix::discover(repo_path).map_err(|e| Error::GixError {
307        cause: e.to_string(),
308    })?;
309    let mut refs = Vec::new();
310
311    let ref_platform = repo.references()?;
312    ref_platform.all().map(|all| {
313        all.for_each(|reference| {
314            if let Ok(reference) = reference {
315                if let Some((_, name)) = reference.name().category_and_short_name() {
316                    refs.push(name.to_string());
317                }
318            }
319        });
320        Ok(refs)
321    })?
322}
323
324/// List local branches in a Git repository
325pub fn gix_list_branches(repo_path: &Path) -> Result<Vec<String>> {
326    // We use map error because gix::discover::Error is a large struct
327    let repo = gix::discover(repo_path).map_err(|e| Error::GixError {
328        cause: e.to_string(),
329    })?;
330    let mut refs = Vec::new();
331
332    let ref_platform = repo.references()?;
333    ref_platform.local_branches().map(|all| {
334        all.for_each(|reference| {
335            if let Ok(reference) = reference {
336                if let Some((_, name)) = reference.name().category_and_short_name() {
337                    refs.push(name.to_string());
338                }
339            }
340        });
341        Ok(refs)
342    })?
343}
344
345#[cfg(test)]
346mod test {
347    use super::*;
348    use std::fs;
349    use test_case::test_case;
350    use xvc_test_helper::*;
351    use xvc_walker::MatchResult as M;
352
353    #[test_case("myfile.txt" , ".gitignore", "/myfile.txt" => matches M::Ignore ; "myfile.txt")]
354    #[test_case("mydir/myfile.txt" , "mydir/.gitignore", "myfile.txt" => matches M::Ignore ; "mydir/myfile.txt")]
355    #[test_case("mydir/myfile.txt" , ".gitignore", "/mydir/myfile.txt" => matches M::Ignore ; "from root dir")]
356    #[test_case("mydir/myfile.txt" , ".gitignore", ""  => matches M::NoMatch ; "non ignore")]
357    #[test_case("mydir/myfile.txt" , ".gitignore", "mydir/**" => matches M::Ignore ; "ignore dir star 2")]
358    #[test_case("mydir/myfile.txt" , ".gitignore", "mydir/*" => matches M::Ignore ; "ignore dir star")]
359    #[test_case("mydir/yourdir/myfile.txt" , "mydir/.gitignore", "yourdir/*" => matches M::Ignore ; "ignore deep dir star")]
360    #[test_case("mydir/yourdir/myfile.txt" , "mydir/.gitignore", "yourdir/**" => matches M::Ignore ; "ignore deep dir star 2")]
361    #[test_case("mydir/myfile.txt" , "another-dir/.gitignore", "another-dir/myfile.txt" => matches M::NoMatch ; "non ignore from dir")]
362    fn test_gitignore(path: &str, gitignore_path: &str, ignore_line: &str) -> M {
363        test_logging(log::LevelFilter::Trace);
364        let git_root = temp_git_dir();
365        let path = git_root.join(PathBuf::from(path));
366        let gitignore_path = git_root.join(PathBuf::from(gitignore_path));
367        if let Some(ignore_dir) = gitignore_path.parent() {
368            fs::create_dir_all(ignore_dir).unwrap();
369        }
370        fs::write(&gitignore_path, format!("{}\n", ignore_line)).unwrap();
371
372        let gitignore = build_ignore_patterns("", &git_root, ".gitignore").unwrap();
373
374        gitignore.check(&path)
375    }
376}