git_cmd/
lib.rs

1//! Run git as shell shell and parse its stdout.
2
3mod cmd;
4#[cfg(feature = "test_fixture")]
5pub mod test_fixture;
6
7use std::{collections::HashSet, path::Path, process::Command};
8
9use anyhow::{Context, anyhow};
10use camino::{Utf8Path, Utf8PathBuf};
11use tracing::{Span, debug, instrument, trace, warn};
12
13/// Repository
14#[derive(Debug)]
15pub struct Repo {
16    /// Directory where you want to run git operations
17    directory: Utf8PathBuf,
18    /// Branch name before running any git operation
19    original_branch: String,
20    /// Remote name before running any git operation
21    original_remote: String,
22}
23
24impl Repo {
25    /// Returns an error if the directory doesn't contain any commit
26    #[instrument(skip_all)]
27    pub fn new(directory: impl AsRef<Utf8Path>) -> anyhow::Result<Self> {
28        debug!("initializing directory {:?}", directory.as_ref());
29
30        let (current_remote, current_branch) = Self::get_current_remote_and_branch(&directory)
31            .context("cannot determine current branch")?;
32
33        Ok(Self {
34            directory: directory.as_ref().to_path_buf(),
35            original_branch: current_branch,
36            original_remote: current_remote,
37        })
38    }
39
40    pub fn directory(&self) -> &Utf8Path {
41        &self.directory
42    }
43
44    fn get_current_remote_and_branch(
45        directory: impl AsRef<Utf8Path>,
46    ) -> anyhow::Result<(String, String)> {
47        match git_in_dir(
48            directory.as_ref(),
49            &[
50                "rev-parse",
51                "--abbrev-ref",
52                "--symbolic-full-name",
53                "@{upstream}",
54            ],
55        ) {
56            Ok(output) => output
57                .split_once('/')
58                .map(|(remote, branch)| (remote.to_string(), branch.to_string()))
59                .context("cannot determine current remote and branch"),
60
61            Err(e) => {
62                let err = e.to_string();
63                if err.contains("fatal: no upstream configured for branch") {
64                    let branch = get_current_branch(directory)?;
65                    warn!("no upstream configured for branch {branch}");
66                    Ok(("origin".to_string(), branch))
67                } else if err.contains("fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.") {
68                    Err(anyhow!("git repository does not contain any commit."))
69                } else {
70                    Err(e)
71                }
72            }
73        }
74    }
75
76    /// Check if there are uncommitted changes.
77    pub fn is_clean(&self) -> anyhow::Result<()> {
78        let changes = self.changes_except_typechanges()?;
79        anyhow::ensure!(
80            changes.is_empty(),
81            "the working directory of this project has uncommitted changes. If these files are both committed and in .gitignore, either delete them or remove them from .gitignore. Otherwise, please commit or stash these changes:\n{changes:?}"
82        );
83        Ok(())
84    }
85
86    pub fn checkout_new_branch(&self, branch: &str) -> anyhow::Result<()> {
87        self.git(&["checkout", "-b", branch])?;
88        Ok(())
89    }
90
91    pub fn delete_branch_in_remote(&self, branch: &str) -> anyhow::Result<()> {
92        self.push(&format!(":refs/heads/{branch}"))
93            .with_context(|| format!("can't delete temporary branch {branch}"))
94    }
95
96    pub fn add_all_and_commit(&self, message: &str) -> anyhow::Result<()> {
97        self.git(&["add", "."])?;
98        self.git(&["commit", "-m", message])?;
99        Ok(())
100    }
101
102    /// Get the list of changed files.
103    /// `filter` is applied for each line of `git status --porcelain`.
104    /// Only changes for which `filter` returns true are returned.
105    pub fn changes(&self, filter: impl FnMut(&&str) -> bool) -> anyhow::Result<Vec<String>> {
106        let output = self.git(&["status", "--porcelain"])?;
107        let changed_files = changed_files(&output, filter);
108        Ok(changed_files)
109    }
110
111    /// Get files changed in the current commit
112    pub fn files_of_current_commit(&self) -> anyhow::Result<HashSet<Utf8PathBuf>> {
113        let output = self.git(&["show", "--oneline", "--name-only", "--pretty=format:"])?;
114        let changed_files = output
115            .lines()
116            .map(|l| l.trim())
117            .map(Utf8PathBuf::from)
118            .collect();
119        Ok(changed_files)
120    }
121
122    pub fn changes_except_typechanges(&self) -> anyhow::Result<Vec<String>> {
123        self.changes(|line| !line.starts_with("T "))
124    }
125
126    pub fn add<T: AsRef<str>>(&self, paths: &[T]) -> anyhow::Result<()> {
127        let mut args = vec!["add"];
128        let paths: Vec<&str> = paths.iter().map(|p| p.as_ref()).collect();
129        args.extend(paths);
130        self.git(&args)?;
131        Ok(())
132    }
133
134    pub fn commit(&self, message: &str) -> anyhow::Result<()> {
135        self.git(&["commit", "-m", message])?;
136        Ok(())
137    }
138
139    pub fn commit_signed(&self, message: &str) -> anyhow::Result<()> {
140        self.git(&["commit", "-s", "-m", message])?;
141        Ok(())
142    }
143
144    pub fn push(&self, obj: &str) -> anyhow::Result<()> {
145        self.git(&["push", &self.original_remote, obj])?;
146        Ok(())
147    }
148
149    pub fn fetch(&self, obj: &str) -> anyhow::Result<()> {
150        self.git(&["fetch", &self.original_remote, obj])?;
151        Ok(())
152    }
153
154    pub fn force_push(&self, obj: &str) -> anyhow::Result<()> {
155        // `--force-with-lease` is safer than `--force` because it will not overwrite
156        // changes on the remote that you do not have locally.
157        // In other words, it will only push if no one else has pushed changes to the remote
158        // branch since you last pulled. If someone else has pushed changes, the command will fail,
159        // preventing you from accidentally overwriting someone else's work.
160        self.git(&["push", &self.original_remote, obj, "--force-with-lease"])?;
161        Ok(())
162    }
163
164    #[instrument(skip(self))]
165    pub fn checkout_head(&self) -> anyhow::Result<()> {
166        self.checkout(&self.original_branch)?;
167        Ok(())
168    }
169
170    /// Branch name before running any git operation.
171    /// I.e. when the [`Repo`] was created.
172    pub fn original_branch(&self) -> &str {
173        &self.original_branch
174    }
175
176    #[instrument(skip(self))]
177    fn current_commit(&self) -> anyhow::Result<String> {
178        self.nth_commit(1)
179    }
180
181    #[instrument(skip(self))]
182    fn previous_commit(&self) -> anyhow::Result<String> {
183        self.nth_commit(2)
184    }
185
186    #[instrument(
187        skip(self)
188        fields(
189            nth_commit = tracing::field::Empty,
190        )
191    )]
192    fn nth_commit(&self, nth: usize) -> anyhow::Result<String> {
193        let nth = nth.to_string();
194        let commit_list = self.git(&["--format=%H", "-n", &nth])?;
195        let last_commit = commit_list
196            .lines()
197            .last()
198            .context("repository has no commits")?;
199        Span::current().record("nth_commit", last_commit);
200
201        Ok(last_commit.to_string())
202    }
203
204    /// Run a git command in the repository git directory
205    pub fn git(&self, args: &[&str]) -> anyhow::Result<String> {
206        git_in_dir(&self.directory, args)
207    }
208
209    pub fn stash_pop(&self) -> anyhow::Result<()> {
210        self.git(&["stash", "pop"])?;
211        Ok(())
212    }
213
214    /// Checkout to the latest commit.
215    pub fn checkout_last_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<()> {
216        let previous_commit = self.last_commit_at_paths(paths)?;
217        self.checkout(&previous_commit)?;
218        Ok(())
219    }
220
221    fn last_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<String> {
222        self.nth_commit_at_paths(1, paths)
223            .context("failed to get message of last commit")
224    }
225
226    fn previous_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<String> {
227        self.nth_commit_at_paths(2, paths)
228            .context("failed to get message of previous commit")
229    }
230
231    pub fn checkout_previous_commit_at_paths(&self, paths: &[&Path]) -> anyhow::Result<()> {
232        let commit = self.previous_commit_at_paths(paths)?;
233        self.checkout(&commit)?;
234        Ok(())
235    }
236
237    #[instrument(skip(self))]
238    pub fn checkout(&self, object: &str) -> anyhow::Result<()> {
239        self.git(&["checkout", object])
240            .context("failed to checkout")?;
241        Ok(())
242    }
243
244    /// Adds a detached git worktree at the given path checked out at the given object.
245    pub fn add_worktree(&self, path: impl AsRef<str>, object: &str) -> anyhow::Result<()> {
246        self.git(&["worktree", "add", "--detach", path.as_ref(), object])
247            .context("failed to create git worktree")?;
248
249        Ok(())
250    }
251
252    /// Removes a worktree that was created for this repository at the given path.
253    pub fn remove_worktree(&self, path: impl AsRef<str>) -> anyhow::Result<()> {
254        self.git(&["worktree", "remove", path.as_ref()])
255            .context("failed to remove worktree")?;
256
257        Ok(())
258    }
259
260    /// Get `nth` commit starting from `1`.
261    #[instrument(
262        skip(self)
263        fields(
264            nth_commit = tracing::field::Empty,
265        )
266    )]
267    fn nth_commit_at_paths(&self, nth: usize, paths: &[&Path]) -> anyhow::Result<String> {
268        let nth_str = nth.to_string();
269
270        let git_args = {
271            let mut git_args = vec!["log", "--format=%H", "-n", &nth_str, "--"];
272            for p in paths {
273                let path = p.to_str().expect("invalid path");
274                git_args.push(path);
275            }
276            git_args
277        };
278
279        let commit_list = self.git(&git_args)?;
280        let mut commits = commit_list.lines();
281        let last_commit = commits.nth(nth - 1).context("not enough commits")?;
282
283        Span::current().record("nth_commit", last_commit);
284        debug!("nth_commit found");
285        Ok(last_commit.to_string())
286    }
287
288    pub fn current_commit_message(&self) -> anyhow::Result<String> {
289        self.git(&["log", "-1", "--pretty=format:%B"])
290    }
291
292    pub fn get_author_name(&self, commit_hash: &str) -> anyhow::Result<String> {
293        self.get_commit_info("%an", commit_hash)
294    }
295
296    pub fn get_author_email(&self, commit_hash: &str) -> anyhow::Result<String> {
297        self.get_commit_info("%ae", commit_hash)
298    }
299
300    pub fn get_committer_name(&self, commit_hash: &str) -> anyhow::Result<String> {
301        self.get_commit_info("%cn", commit_hash)
302    }
303
304    pub fn get_committer_email(&self, commit_hash: &str) -> anyhow::Result<String> {
305        self.get_commit_info("%ce", commit_hash)
306    }
307
308    fn get_commit_info(&self, info: &str, commit_hash: &str) -> anyhow::Result<String> {
309        self.git(&["log", "-1", &format!("--pretty=format:{info}"), commit_hash])
310    }
311
312    /// Get the SHA1 of the current HEAD.
313    pub fn current_commit_hash(&self) -> anyhow::Result<String> {
314        self.git(&["log", "-1", "--pretty=format:%H"])
315            .context("can't determine current commit hash")
316    }
317
318    /// Create a git tag
319    pub fn tag(&self, name: &str, message: &str) -> anyhow::Result<String> {
320        self.git(&["tag", "-m", message, name])
321    }
322
323    /// Get the commit hash of the given tag
324    pub fn get_tag_commit(&self, tag: &str) -> Option<String> {
325        self.git(&["rev-list", "-n", "1", tag]).ok()
326    }
327
328    /// Returns all the tags in the repository in an unspecified order.
329    pub fn get_all_tags(&self) -> Vec<String> {
330        match self
331            .git(&["tag", "--list"])
332            .ok()
333            .as_ref()
334            .map(|output| output.trim())
335        {
336            None | Some("") => vec![],
337            Some(output) => output.lines().map(|line| line.to_owned()).collect(),
338        }
339    }
340
341    /// Check if a commit comes before another one.
342    ///
343    /// ## Example
344    ///
345    /// For this git log:
346    /// ```txt
347    /// commit d6ec399b80d44bf9c4391e4a9ead8482faa9bffd
348    /// commit e880d8786cb16aa9a3f258e7503932445d708df7
349    /// ```
350    ///
351    /// `git.is_ancestor("e880d8786cb16aa9a3f258e7503932445d708df7", "d6ec399b80d44bf9c4391e4a9ead8482faa9bffd")` returns true.
352    pub fn is_ancestor(&self, maybe_ancestor_commit: &str, descendant_commit: &str) -> bool {
353        self.git(&[
354            "merge-base",
355            "--is-ancestor",
356            maybe_ancestor_commit,
357            descendant_commit,
358        ])
359        .is_ok()
360    }
361
362    /// Name of the remote when the [`Repo`] was created.
363    pub fn original_remote(&self) -> &str {
364        &self.original_remote
365    }
366
367    /// Url of the remote when the [`Repo`] was created.
368    pub fn original_remote_url(&self) -> anyhow::Result<String> {
369        let param = format!("remote.{}.url", self.original_remote);
370        self.git(&["config", "--get", &param])
371    }
372
373    pub fn tag_exists(&self, tag: &str) -> anyhow::Result<bool> {
374        let output = self
375            .git(&["tag", "-l", tag])
376            .context("cannot determine if git tag exists")?;
377        Ok(output.lines().count() >= 1)
378    }
379
380    pub fn get_branches_of_commit(&self, commit_hash: &str) -> anyhow::Result<Vec<String>> {
381        let output = self.git(&["branch", "--contains", commit_hash])?;
382        let branches = output
383            .lines()
384            .filter_map(|l| l.split_whitespace().last())
385            .map(|s| s.to_string())
386            .collect();
387        Ok(branches)
388    }
389}
390
391pub fn is_file_ignored(repo_path: &Utf8Path, file: &Utf8Path) -> bool {
392    let file = file.as_str();
393
394    git_in_dir(repo_path, &["check-ignore", "--no-index", file]).is_ok()
395}
396
397pub fn is_file_committed(repo_path: &Utf8Path, file: &Utf8Path) -> bool {
398    let file = file.as_str();
399    git_in_dir(repo_path, &["ls-files", "--error-unmatch", file]).is_ok()
400}
401
402fn changed_files(output: &str, filter: impl FnMut(&&str) -> bool) -> Vec<String> {
403    output
404        .lines()
405        .map(|l| l.trim())
406        // filter typechanges
407        .filter(filter)
408        .filter_map(|e| e.rsplit(' ').next())
409        .map(|e| e.to_string())
410        .collect()
411}
412
413#[instrument]
414pub fn git_in_dir(dir: &Utf8Path, args: &[&str]) -> anyhow::Result<String> {
415    let args: Vec<&str> = args.iter().map(|s| s.trim()).collect();
416    let output = Command::new("git")
417        .arg("-C")
418        .arg(dir)
419        .args(&args)
420        .output()
421        .with_context(|| {
422            format!("error while running git in directory `{dir:?}` with args `{args:?}`")
423        })?;
424    trace!("git {:?}: output = {:?}", args, output);
425    let stdout = cmd::string_from_bytes(output.stdout)?;
426    if output.status.success() {
427        Ok(stdout)
428    } else {
429        let mut error =
430            format!("error while running git in directory `{dir:?}` with args `{args:?}");
431        let stderr = cmd::string_from_bytes(output.stderr)?;
432        if !stdout.is_empty() || !stderr.is_empty() {
433            error.push(':');
434        }
435        if !stdout.is_empty() {
436            error.push_str("\n- stdout: ");
437            error.push_str(&stdout);
438        }
439        if !stderr.is_empty() {
440            error.push_str("\n- stderr: ");
441            error.push_str(&stderr);
442        }
443        Err(anyhow!(error))
444    }
445}
446
447/// Get the name of the current branch.
448fn get_current_branch(directory: impl AsRef<Utf8Path>) -> anyhow::Result<String> {
449    git_in_dir(directory.as_ref(), &["rev-parse", "--abbrev-ref", "HEAD"]).map_err(|e| {
450        if e.to_string().contains(
451            "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.",
452        ) {
453            anyhow!("git repository does not contain any commit.")
454        } else {
455            e
456        }
457    })
458}
459
460#[cfg(test)]
461mod tests {
462    use tempfile::tempdir;
463
464    use super::*;
465
466    #[test]
467    fn inexistent_previous_commit_detected() {
468        let repository_dir = tempdir().unwrap();
469        let repo = Repo::init(&repository_dir);
470        let file1 = repository_dir.as_ref().join("file1.txt");
471        repo.checkout_previous_commit_at_paths(&[&file1])
472            .unwrap_err();
473    }
474
475    #[test]
476    fn previous_commit_is_retrieved() {
477        test_logs::init();
478        let repository_dir = tempdir().unwrap();
479        let repo = Repo::init(&repository_dir);
480        let file1 = repository_dir.as_ref().join("file1.txt");
481        let file2 = repository_dir.as_ref().join("file2.txt");
482        {
483            fs_err::write(&file2, b"Hello, file2!-1").unwrap();
484            repo.add_all_and_commit("file2-1").unwrap();
485            fs_err::write(file1, b"Hello, file1!").unwrap();
486            repo.add_all_and_commit("file1").unwrap();
487            fs_err::write(&file2, b"Hello, file2!-2").unwrap();
488            repo.add_all_and_commit("file2-2").unwrap();
489        }
490        repo.checkout_previous_commit_at_paths(&[&file2]).unwrap();
491        assert_eq!(repo.current_commit_message().unwrap(), "file2-1");
492    }
493
494    #[test]
495    fn current_commit_is_retrieved() {
496        test_logs::init();
497        let repository_dir = tempdir().unwrap();
498        let repo = Repo::init(&repository_dir);
499        let file1 = repository_dir.as_ref().join("file1.txt");
500
501        let commit_message = r"feat: my feature
502
503        message
504
505        footer: small note";
506
507        {
508            fs_err::write(file1, b"Hello, file1!").unwrap();
509            repo.add_all_and_commit(commit_message).unwrap();
510        }
511        assert_eq!(repo.current_commit_message().unwrap(), commit_message);
512    }
513
514    #[test]
515    fn clean_project_is_recognized() {
516        test_logs::init();
517        let repository_dir = tempdir().unwrap();
518        let repo = Repo::init(&repository_dir);
519        repo.is_clean().unwrap();
520    }
521
522    #[test]
523    fn dirty_project_is_recognized() {
524        test_logs::init();
525        let repository_dir = tempdir().unwrap();
526        let repo = Repo::init(&repository_dir);
527        let file1 = repository_dir.as_ref().join("file1.txt");
528        fs_err::write(file1, b"Hello, file1!").unwrap();
529        assert!(repo.is_clean().is_err());
530    }
531
532    #[test]
533    fn changes_files_except_typechanges_are_detected() {
534        let git_status_output = r"T CHANGELOG.md
535M README.md
536A  crates
537D  crates/git_cmd/CHANGELOG.md
538";
539        let changed_files = changed_files(git_status_output, |line| !line.starts_with("T "));
540        // `CHANGELOG.md` is ignored because it's a typechange
541        let expected_changed_files = vec!["README.md", "crates", "crates/git_cmd/CHANGELOG.md"];
542        assert_eq!(changed_files, expected_changed_files);
543    }
544
545    #[test]
546    fn existing_tag_is_recognized() {
547        test_logs::init();
548        let repository_dir = tempdir().unwrap();
549        let repo = Repo::init(&repository_dir);
550        let file1 = repository_dir.as_ref().join("file1.txt");
551        {
552            fs_err::write(file1, b"Hello, file1!").unwrap();
553            repo.add_all_and_commit("file1").unwrap();
554        }
555        let version = "v1.0.0";
556        repo.tag(version, "test").unwrap();
557        assert!(repo.tag_exists(version).unwrap());
558    }
559
560    #[test]
561    fn non_existing_tag_is_recognized() {
562        test_logs::init();
563        let repository_dir = tempdir().unwrap();
564        let repo = Repo::init(&repository_dir);
565        let file1 = repository_dir.as_ref().join("file1.txt");
566        {
567            fs_err::write(file1, b"Hello, file1!").unwrap();
568            repo.add_all_and_commit("file1").unwrap();
569        }
570        repo.tag("v1.0.0", "test").unwrap();
571        assert!(!repo.tag_exists("v2.0.0").unwrap());
572    }
573
574    #[test]
575    fn tags_are_retrieved() {
576        test_logs::init();
577        let repository_dir = tempdir().unwrap();
578        let repo = Repo::init(&repository_dir);
579        repo.tag("v1.0.0", "test").unwrap();
580        let file1 = repository_dir.as_ref().join("file1.txt");
581        {
582            fs_err::write(file1, b"Hello, file1!").unwrap();
583            repo.add_all_and_commit("file1").unwrap();
584        }
585        repo.tag("v1.0.1", "test2").unwrap();
586        let tags = repo.get_all_tags();
587        assert_eq!(tags, vec!["v1.0.0", "v1.0.1"]);
588    }
589
590    #[test]
591    fn is_branch_of_commit_detected_correctly() {
592        test_logs::init();
593        let repository_dir = tempdir().unwrap();
594        let repo = Repo::init(&repository_dir);
595        let commit_hash = repo.current_commit_hash().unwrap();
596        let branches = repo.get_branches_of_commit(&commit_hash).unwrap();
597        assert_eq!(branches, vec![repo.original_branch()]);
598    }
599}