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().git.command.clone();
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 git_config = xvc_root.config().git.clone();
175    let use_git = git_config.use_git;
176    let auto_commit = git_config.auto_commit;
177    let auto_stage = git_config.auto_stage;
178    let git_command_str = git_config.command.clone();
179    let git_command = get_absolute_git_command(&git_command_str)?;
180    let xvc_dir = xvc_root.xvc_dir().clone();
181    let xvc_dir_str = xvc_dir.to_str().unwrap();
182
183    if use_git {
184        if auto_commit {
185            git_auto_commit(
186                output_snd,
187                &git_command,
188                xvc_root_str,
189                xvc_dir_str,
190                xvc_cmd,
191                to_branch,
192            )?;
193        } else if auto_stage {
194            git_auto_stage(output_snd, &git_command, xvc_root_str, xvc_dir_str)?;
195        }
196    }
197
198    Ok(())
199}
200
201/// Commit `.xvc` directory after Xvc operations
202pub fn git_auto_commit(
203    output_snd: &XvcOutputSender,
204    git_command: &str,
205    xvc_root_str: &str,
206    xvc_dir_str: &str,
207    xvc_cmd: &str,
208    to_branch: Option<&str>,
209) -> Result<()> {
210    debug!(output_snd, "Using Git: {git_command}");
211
212    let git_diff_staged_out = stash_user_staged_files(output_snd, git_command, xvc_root_str)?;
213
214    if let Some(branch) = to_branch {
215        debug!(output_snd, "Checking out branch {branch}");
216        exec_git(git_command, xvc_root_str, &["checkout", "-b", branch])?;
217    }
218
219    // Add and commit `.xvc`
220    match exec_git(
221        git_command,
222        xvc_root_str,
223        // We check the output of the git add command to see if there were any files added.
224        // "--verbose" is required to get the output we need.
225        &[
226            "add",
227            "--verbose",
228            xvc_dir_str,
229            "*.gitignore",
230            "*.xvcignore",
231        ],
232    ) {
233        Ok(git_add_output) => {
234            if git_add_output.trim().is_empty() {
235                debug!(output_snd, "No files to commit");
236                return Ok(());
237            } else {
238                match exec_git(
239                    git_command,
240                    xvc_root_str,
241                    &[
242                        "commit",
243                        "-m",
244                        &format!("Xvc auto-commit after '{xvc_cmd}'"),
245                    ],
246                ) {
247                    Ok(res_git_commit) => {
248                        debug!(output_snd, "Committing .xvc/ to git: {res_git_commit}");
249                    }
250                    Err(e) => {
251                        debug!(output_snd, "Error committing .xvc/ to git: {e}");
252                        return Err(e);
253                    }
254                }
255            }
256        }
257        Err(e) => {
258            debug!(output_snd, "Error adding .xvc/ to git: {e}");
259            return Err(e);
260        }
261    }
262
263    // Pop the stash if there were files we stashed
264
265    if !git_diff_staged_out.trim().is_empty() {
266        debug!(
267            output_snd,
268            "Unstashing user staged files: {git_diff_staged_out}"
269        );
270        unstash_user_staged_files(output_snd, git_command, xvc_root_str)?;
271    }
272    Ok(())
273}
274
275/// runs `git add .xvc *.gitignore *.xvcignore` to stage the files after Xvc operations
276pub fn git_auto_stage(
277    output_snd: &XvcOutputSender,
278    git_command: &str,
279    xvc_root_str: &str,
280    xvc_dir_str: &str,
281) -> Result<()> {
282    let res_git_add = exec_git(
283        git_command,
284        xvc_root_str,
285        &["add", xvc_dir_str, "*.gitignore", "*.xvcignore"],
286    )?;
287    debug!(output_snd, "Staging .xvc/ to git: {res_git_add}");
288    Ok(())
289}
290
291/// Run `git check-ignore` to check if a path is ignored by Git
292pub fn git_ignored(git_command: &str, xvc_root_str: &str, path: &str) -> Result<bool> {
293    let command_res = exec_git(git_command, xvc_root_str, &["check-ignore", path])?;
294
295    if command_res.trim().is_empty() {
296        Ok(false)
297    } else {
298        Ok(true)
299    }
300}
301
302/// Return all tags and branches from a repository using Gix
303///
304/// TODO: We can add prefix listing if there is a performance issue for large repos here
305pub fn gix_list_references(repo_path: &Path) -> Result<Vec<String>> {
306    // We use map error because gix::discover::Error is a large struct
307    let repo = gix::discover(repo_path).map_err(|e| Error::GixError {
308        cause: e.to_string(),
309    })?;
310    let mut refs = Vec::new();
311
312    let ref_platform = repo.references()?;
313    ref_platform.all().map(|all| {
314        all.for_each(|reference| {
315            if let Ok(reference) = reference {
316                if let Some((_, name)) = reference.name().category_and_short_name() {
317                    refs.push(name.to_string());
318                }
319            }
320        });
321        Ok(refs)
322    })?
323}
324
325/// List local branches in a Git repository
326pub fn gix_list_branches(repo_path: &Path) -> Result<Vec<String>> {
327    // We use map error because gix::discover::Error is a large struct
328    let repo = gix::discover(repo_path).map_err(|e| Error::GixError {
329        cause: e.to_string(),
330    })?;
331    let mut refs = Vec::new();
332
333    let ref_platform = repo.references()?;
334    ref_platform.local_branches().map(|all| {
335        all.for_each(|reference| {
336            if let Ok(reference) = reference {
337                if let Some((_, name)) = reference.name().category_and_short_name() {
338                    refs.push(name.to_string());
339                }
340            }
341        });
342        Ok(refs)
343    })?
344}
345
346#[cfg(test)]
347mod test {
348    use super::*;
349    use std::fs;
350    use test_case::test_case;
351    use xvc_test_helper::*;
352    use xvc_walker::MatchResult as M;
353
354    #[test_case("myfile.txt" , ".gitignore", "/myfile.txt" => matches M::Ignore ; "myfile.txt")]
355    #[test_case("mydir/myfile.txt" , "mydir/.gitignore", "myfile.txt" => matches M::Ignore ; "mydir/myfile.txt")]
356    #[test_case("mydir/myfile.txt" , ".gitignore", "/mydir/myfile.txt" => matches M::Ignore ; "from root dir")]
357    #[test_case("mydir/myfile.txt" , ".gitignore", ""  => matches M::NoMatch ; "non ignore")]
358    #[test_case("mydir/myfile.txt" , ".gitignore", "mydir/**" => matches M::Ignore ; "ignore dir star 2")]
359    #[test_case("mydir/myfile.txt" , ".gitignore", "mydir/*" => matches M::Ignore ; "ignore dir star")]
360    #[test_case("mydir/yourdir/myfile.txt" , "mydir/.gitignore", "yourdir/*" => matches M::Ignore ; "ignore deep dir star")]
361    #[test_case("mydir/yourdir/myfile.txt" , "mydir/.gitignore", "yourdir/**" => matches M::Ignore ; "ignore deep dir star 2")]
362    #[test_case("mydir/myfile.txt" , "another-dir/.gitignore", "another-dir/myfile.txt" => matches M::NoMatch ; "non ignore from dir")]
363    fn test_gitignore(path: &str, gitignore_path: &str, ignore_line: &str) -> M {
364        test_logging(log::LevelFilter::Trace);
365        let git_root = temp_git_dir();
366        let path = git_root.join(PathBuf::from(path));
367        let gitignore_path = git_root.join(PathBuf::from(gitignore_path));
368        if let Some(ignore_dir) = gitignore_path.parent() {
369            fs::create_dir_all(ignore_dir).unwrap();
370        }
371        fs::write(&gitignore_path, format!("{}\n", ignore_line)).unwrap();
372
373        let gitignore = build_ignore_patterns("", &git_root, ".gitignore").unwrap();
374
375        gitignore.check(&path)
376    }
377}