git_wrapper/
lib.rs

1// Copyright (c) 2021 Bahtiar `kalkin` Gadimov <bahtiar@gadimov.de>
2//
3// This file is part of git-wrapper.
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Lesser General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU Lesser General Public License for more details.
14//
15// You should have received a copy of the GNU Lesser General Public License
16// along with this program. If not, see <http://www.gnu.org/licenses/>.
17
18//! A wrapper around [git(1)](https://git-scm.com/docs/git) inspired by
19//! [`GitPython`](https://github.com/gitpython-developers/GitPython).
20
21pub use posix_errors::{PosixError, EACCES, EINVAL, ENOENT};
22use std::collections::HashMap;
23use std::ffi::OsStr;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::process::Output;
27
28mod bare_repo;
29pub use crate::bare_repo::*;
30
31/// Experimental stuff
32pub mod x;
33
34macro_rules! cmd {
35    ($args:expr) => {
36        Command::new("git").args($args).output()
37    };
38    ($name:expr, $args:expr) => {
39        Command::new("git").arg($name).args($args).output()
40    };
41}
42
43/// Wrapper around [git-ls-remote(1)](https://git-scm.com/docs/git-ls-remote)
44///
45/// # Errors
46///
47/// Will return [`PosixError`] if command exits with an error code.
48#[inline]
49pub fn ls_remote(args: &[&str]) -> Result<Output, PosixError> {
50    let result = cmd!("ls-remote", args);
51
52    if let Ok(value) = result {
53        return Ok(value);
54    }
55
56    Err(PosixError::from(result.unwrap_err()))
57}
58
59/// Returns all tags from a remote
60///
61/// # Errors
62///
63/// Will return [`PosixError`] if command exits with an error code.
64#[inline]
65pub fn tags_from_remote(url: &str) -> Result<Vec<String>, PosixError> {
66    let mut vec = Vec::new();
67    let output = ls_remote(&["--refs", "--tags", url])?;
68    if output.status.success() {
69        let tmp = String::from_utf8(output.stdout).expect("Expected UTF-8");
70        for s in tmp.lines() {
71            let mut split = s.splitn(3, '/');
72            split.next();
73            split.next();
74            let split_result = split.next();
75            if let Some(value) = split_result {
76                vec.push(String::from(value));
77            }
78        }
79        Ok(vec)
80    } else {
81        Err(PosixError::from(output))
82    }
83}
84
85/// Failed to read config
86#[allow(missing_docs)]
87#[derive(thiserror::Error, Debug)]
88pub enum ConfigReadError {
89    #[error("Invalid section key in config {0}")]
90    InvalidSectionOrKey(String),
91    #[error("Invalid config file {0}")]
92    InvalidConfigFile(String),
93    #[error("{0}")]
94    Failure(String, i32),
95}
96
97/// Failed to change configuration file
98#[allow(missing_docs)]
99#[derive(thiserror::Error, Debug)]
100pub enum ConfigSetError {
101    #[error("{0}")]
102    InvalidSectionOrKey(String),
103    #[error("{0}")]
104    InvalidConfigFile(String),
105    #[error("{0}")]
106    WriteFailed(String),
107    #[error("{0}")]
108    Failure(String, i32),
109}
110
111/// # Errors
112///
113/// Throws [`ConfigSetError`] on errors
114///
115/// # Panics
116///
117/// When git-config(1) execution fails
118#[inline]
119pub fn config_file_set(file: &Path, key: &str, value: &str) -> Result<(), ConfigSetError> {
120    let args = &["--file", file.to_str().expect("UTF-8 encoding"), key, value];
121    let mut cmd = Command::new("git");
122    cmd.arg("config").args(args);
123    let out = cmd.output().expect("Failed to execute git-config(1)");
124    if out.status.success() {
125        Ok(())
126    } else {
127        let msg = String::from_utf8(out.stdout).expect("UTF-8 encoding");
128        match out.status.code().unwrap_or(1) {
129            1 => Err(ConfigSetError::InvalidSectionOrKey(msg)),
130            3 => Err(ConfigSetError::InvalidConfigFile(msg)),
131            4 => Err(ConfigSetError::WriteFailed(msg)),
132            code => Err(ConfigSetError::Failure(msg, code)),
133        }
134    }
135}
136
137/// Return all `.gitsubtrees` files in the working directory.
138///
139/// Uses [git-ls-files(1)](https://git-scm.com/docs/git-ls-files)
140///
141/// # Errors
142///
143/// Will return [`PosixError`] if command exits with an error code.
144/// Figure out the default branch for given remote.
145///
146/// # Errors
147///
148/// Will return [`PosixError`] if command exits with an error code.
149/// TODO Return a custom error type
150#[inline]
151pub fn resolve_head(remote: &str) -> Result<String, PosixError> {
152    let proc =
153        cmd!("ls-remote", vec!["--symref", remote, "HEAD"]).expect("Failed to execute git command");
154    if proc.status.success() {
155        let stdout = String::from_utf8(proc.stdout).expect("UTF-8 encoding");
156        let mut lines = stdout.lines();
157        let first_line = lines.next().expect("Failed to parse HEAD from remote");
158        let mut split = first_line
159            .split('\t')
160            .next()
161            .expect("Failed to parse HEAD from remote")
162            .splitn(3, '/');
163        split.next();
164        split.next();
165        return Ok(split
166            .next()
167            .expect("Failed to parse default branch")
168            .to_owned());
169    }
170
171    Err(PosixError::from(proc))
172}
173
174enum RemoteDir {
175    Fetch,
176    Push,
177}
178
179struct RemoteLine {
180    name: String,
181    url: String,
182    dir: RemoteDir,
183}
184
185/// Represents a git remote
186#[allow(missing_docs)]
187#[derive(Clone, Debug, Eq, PartialEq)]
188pub struct Remote {
189    pub name: String,
190    pub push: Option<String>,
191    pub fetch: Option<String>,
192}
193
194fn cwd() -> Result<PathBuf, RepoError> {
195    if let Ok(result) = std::env::current_dir() {
196        Ok(result)
197    } else {
198        Err(RepoError::FailAccessCwd)
199    }
200}
201
202/// A path which is canonicalized and exists.
203#[derive(Debug, Clone)]
204pub struct AbsoluteDirPath(PathBuf);
205impl AsRef<OsStr> for AbsoluteDirPath {
206    #[inline]
207    fn as_ref(&self) -> &OsStr {
208        self.0.as_os_str()
209    }
210}
211
212impl TryFrom<&Path> for AbsoluteDirPath {
213    type Error = RepoError;
214
215    #[inline]
216    fn try_from(value: &Path) -> Result<Self, Self::Error> {
217        let path_buf;
218        if value.is_absolute() {
219            path_buf = value.to_path_buf();
220        } else if let Ok(p) = value.canonicalize() {
221            path_buf = p;
222        } else {
223            return Err(RepoError::AbsolutionError(value.to_path_buf()));
224        }
225
226        Ok(Self(path_buf))
227    }
228}
229
230trait GenericRepository {
231    /// Return config value for specified key
232    ///
233    /// # Errors
234    ///
235    /// When given invalid key or an invalid config file is read.
236    ///
237    /// # Panics
238    ///
239    /// Will panic if git exits with an unexpected error code. Expected codes are 0, 1 & 3.
240    #[inline]
241    fn gen_config(&self, key: &str) -> Result<String, ConfigReadError> {
242        let out = self
243            .gen_git()
244            .arg("config")
245            .arg(key)
246            .output()
247            .expect("Failed to execute git-config(1)");
248        if out.status.success() {
249            Ok(String::from_utf8(out.stdout)
250                .expect("UTF-8 encoding")
251                .trim()
252                .to_owned())
253        } else {
254            match out.status.code().unwrap_or(3) {
255                1 => Err(ConfigReadError::InvalidSectionOrKey(key.to_owned())),
256                3 => {
257                    let msg = String::from_utf8_lossy(out.stderr.as_ref()).to_string();
258                    Err(ConfigReadError::InvalidConfigFile(msg))
259                }
260                code => {
261                    let msg = String::from_utf8_lossy(out.stderr.as_ref());
262                    Err(ConfigReadError::Failure(msg.to_string(), code))
263                }
264            }
265        }
266    }
267
268    /// Returns a prepared git `Command` struct
269    /// TODO move to generic repo trait
270    #[must_use]
271    fn gen_git(&self) -> Command;
272}
273
274/// The main repository object.
275///
276/// This wrapper allows to keep track of optional *git-dir* and *work-tree* directories when
277/// executing commands. This functionality was needed for `glv` & `git-stree` project.
278#[derive(Clone, Debug)]
279pub struct Repository {
280    /// GIT_DIR
281    git_dir: AbsoluteDirPath,
282    /// WORK_TREE
283    work_tree: AbsoluteDirPath,
284}
285
286/// Error during repository instantiation
287#[allow(missing_docs)]
288#[derive(thiserror::Error, Debug, PartialEq, Eq)]
289pub enum RepoError {
290    #[error("GIT_DIR Not found")]
291    GitDirNotFound,
292    #[error("Bare repository")]
293    BareRepo,
294    #[error("Invalid directory: `{0}`")]
295    InvalidDirectory(PathBuf),
296    #[error("Failed to canonicalize the path buffer: `{0}`")]
297    AbsolutionError(PathBuf),
298    #[error("Failed to access current working directory")]
299    FailAccessCwd,
300}
301
302impl From<RepoError> for PosixError {
303    #[inline]
304    fn from(e: RepoError) -> Self {
305        let msg = format!("{}", e);
306        match e {
307            RepoError::GitDirNotFound | RepoError::InvalidDirectory(_) => Self::new(ENOENT, msg),
308            RepoError::AbsolutionError(_) => Self::new(EINVAL, msg),
309            RepoError::FailAccessCwd => Self::new(EACCES, msg),
310            RepoError::BareRepo => Self::new(EINVAL, format!("{}", e)),
311        }
312    }
313}
314
315fn search_git_dir(start: &Path) -> Result<AbsoluteDirPath, RepoError> {
316    let path;
317    if start.is_absolute() {
318        path = start.to_path_buf();
319    } else {
320        match start.canonicalize() {
321            Ok(path_buf) => path = path_buf,
322            Err(_) => return Err(RepoError::InvalidDirectory(start.to_path_buf())),
323        }
324    }
325
326    if let (Ok(head_path), Ok(objects_path)) = (
327        path.join("HEAD").canonicalize(),
328        path.join("objects").canonicalize(),
329    ) {
330        if head_path.is_file() && objects_path.is_dir() {
331            return AbsoluteDirPath::try_from(path.as_path());
332        }
333    }
334
335    for parent in path.ancestors() {
336        let candidate = parent.join(".git");
337        if candidate.is_dir() && candidate.exists() {
338            return candidate.as_path().try_into();
339        }
340    }
341    Err(RepoError::GitDirNotFound)
342}
343
344fn work_tree_from_git_dir(git_dir: &AbsoluteDirPath) -> Result<AbsoluteDirPath, RepoError> {
345    let mut cmd = Command::new("git");
346    cmd.arg("--git-dir");
347    cmd.arg(git_dir.0.as_os_str());
348    cmd.args(&["rev-parse", "--is-bare-repository"]);
349    let output = cmd.output().expect("failed to execute rev-parse");
350    if output.status.success() {
351        let tmp = String::from_utf8_lossy(&output.stdout);
352        if tmp.trim() == "true" {
353            return Err(RepoError::BareRepo);
354        }
355    }
356
357    match git_dir.0.parent() {
358        Some(dir) => Ok(AbsoluteDirPath::try_from(dir)?),
359        None => Err(RepoError::BareRepo),
360    }
361}
362
363fn git_dir_from_work_tree(work_tree: &AbsoluteDirPath) -> Result<AbsoluteDirPath, RepoError> {
364    let result = work_tree.0.join(".git");
365    result.as_path().try_into()
366}
367
368/// Invalid git reference was provided
369#[allow(missing_docs)]
370#[derive(thiserror::Error, Debug, PartialEq, Eq)]
371#[error("Invalid git reference {0}")]
372pub struct InvalidRefError(String);
373
374/// Getters
375impl Repository {
376    /// # Panics
377    ///
378    /// Panics of executing git-diff(1) fails
379    #[must_use]
380    #[inline]
381    pub fn is_clean(&self) -> bool {
382        let output = self
383            .git()
384            .args(&["diff", "--quiet", "HEAD"])
385            .output()
386            .expect("Failed to execute git-diff(1)");
387        output.status.success()
388    }
389
390    /// # Panics
391    ///
392    /// Panics of executing git-rev-parse(1) fails
393    #[must_use]
394    #[inline]
395    pub fn is_shallow(&self) -> bool {
396        let out = self
397            .git()
398            .args(&["rev-parse", "--is-shallow-repository"])
399            .output()
400            .expect("Failed to execute git-rev-parse(1)");
401        String::from_utf8_lossy(&out.stdout).trim() != "false"
402    }
403
404    /// Returns a `HashMap` of git remotes
405    #[must_use]
406    #[inline]
407    pub fn remotes(&self) -> Option<HashMap<String, Remote>> {
408        let args = &["remote", "-v"];
409        let mut cmd = self.git();
410        let out = cmd
411            .args(args)
412            .output()
413            .expect("Failed to execute git-remote(1)");
414        if !out.status.success() {
415            return None;
416        }
417
418        let text = String::from_utf8_lossy(&out.stdout);
419        let mut my_map: HashMap<String, Remote> = HashMap::new();
420        let mut remote_lines: Vec<RemoteLine> = vec![];
421        for line in text.lines() {
422            let mut split = line.trim().split('\t');
423            let name = split.next().expect("Remote name").to_owned();
424            let rest = split.next().expect("Remote rest");
425            let mut rest_split = rest.split(' ');
426            let url = rest_split.next().expect("Remote url").to_owned();
427            let dir = if rest_split.next().expect("Remote direction") == "(fetch)" {
428                RemoteDir::Fetch
429            } else {
430                RemoteDir::Push
431            };
432            remote_lines.push(RemoteLine { name, url, dir });
433        }
434        for remote_line in remote_lines {
435            let mut remote = my_map.remove(&remote_line.name).unwrap_or(Remote {
436                name: remote_line.name.clone(),
437                push: None,
438                fetch: None,
439            });
440            match remote_line.dir {
441                RemoteDir::Fetch => remote.fetch = Some(remote_line.url.clone()),
442                RemoteDir::Push => remote.push = Some(remote_line.url.clone()),
443            }
444            my_map.insert(remote_line.name.clone(), remote);
445        }
446
447        Some(my_map)
448    }
449
450    /// Returns the HEAD commit id if ref HEAD exists
451    // TODO return a Result with custom error type
452    //
453    /// # Panics
454    ///
455    /// Panics when fails to resolve HEAD
456    #[must_use]
457    #[inline]
458    pub fn head(&self) -> String {
459        let args = &["rev-parse", "HEAD"];
460        let mut cmd = self.git();
461        let out = cmd
462            .args(args)
463            .output()
464            .expect("Failed to execute git-rev-parse(1)");
465        assert!(
466            out.status.success(),
467            "git rev-parse returned unexpected error"
468        );
469        String::from_utf8_lossy(&out.stdout).trim().to_owned()
470    }
471
472    /// Return path to git `WORK_TREE`
473    ///
474    /// TODO move to generic repo trait
475    /// TODO Remove optional
476    #[must_use]
477    #[inline]
478    pub fn work_tree(&self) -> Option<PathBuf> {
479        Some(self.work_tree.0.clone())
480    }
481
482    /// Return true if the repo is sparse
483    #[must_use]
484    #[inline]
485    pub fn is_sparse(&self) -> bool {
486        let path = self.git_dir_path().join("info").join("sparse-checkout");
487        path.exists()
488    }
489
490    /// TODO move to generic repo trait
491    const fn git_dir(&self) -> &AbsoluteDirPath {
492        &self.git_dir
493    }
494
495    const fn git_dir_path(&self) -> &PathBuf {
496        &self.git_dir.0
497    }
498
499    /// # Errors
500    ///
501    /// Will return [`InvalidRefError`] if invalid reference provided
502    #[inline]
503    pub fn short_ref(&self, long_ref: &str) -> Result<String, InvalidRefError> {
504        let args = vec!["rev-parse", "--short", long_ref];
505        let mut cmd = self.git();
506        let out = cmd
507            .args(args)
508            .output()
509            .expect("Failed to execute git-commit(1)");
510        if !out.status.success() {
511            return Err(InvalidRefError(long_ref.to_owned()));
512        }
513
514        let short_ref = String::from_utf8_lossy(&out.stderr).to_string();
515        Ok(short_ref)
516    }
517}
518
519/// Constructors
520impl Repository {
521    /// # Errors
522    ///
523    /// Will return [`RepoError`] when fails to find repository
524    #[inline]
525    pub fn discover(path: &Path) -> Result<Self, RepoError> {
526        let git_dir = search_git_dir(path)?;
527        let work_tree = work_tree_from_git_dir(&git_dir)?;
528        Ok(Self { git_dir, work_tree })
529    }
530
531    /// # Errors
532    ///
533    /// Will return [`RepoError`] when fails to find repository
534    #[inline]
535    pub fn default() -> Result<Self, RepoError> {
536        Self::from_args(None, None, None)
537    }
538
539    /// # Panics
540    ///
541    /// When git execution fails
542    ///
543    /// # Errors
544    ///
545    /// Returns a string output when something goes horrible wrong
546    #[inline]
547    pub fn create(path: &Path) -> Result<Self, String> {
548        let mut cmd = Command::new("git");
549        let out = cmd
550            .arg("init")
551            .current_dir(&path)
552            .output()
553            .expect("Executed git-init(1)");
554
555        if out.status.success() {
556            let work_tree = path.try_into().map_err(|e| format!("{}", e))?;
557            let git_dir = path
558                .join(".git")
559                .as_path()
560                .try_into()
561                .map_err(|e| format!("{}", e))?;
562            Ok(Self { git_dir, work_tree })
563        } else {
564            Err(String::from_utf8_lossy(&out.stderr).to_string())
565        }
566    }
567
568    /// # Errors
569    ///
570    /// Will return [`RepoError`] when fails to find repository
571    #[inline]
572    pub fn from_args(
573        change: Option<&str>,
574        git: Option<&str>,
575        work: Option<&str>,
576    ) -> Result<Self, RepoError> {
577        if (change, git, work) == (None, None, None) {
578            let git_dir = if let Ok(gd) = std::env::var("GIT_DIR") {
579                AbsoluteDirPath::try_from(gd.as_ref())?
580            } else {
581                search_git_dir(&cwd()?)?
582            };
583
584            let work_tree = if let Ok(wt) = std::env::var("GIT_WORK_TREE") {
585                AbsoluteDirPath::try_from(wt.as_ref())?
586            } else {
587                work_tree_from_git_dir(&git_dir)?
588            };
589
590            Ok(Self { git_dir, work_tree })
591        } else {
592            let root = change.map_or_else(PathBuf::new, PathBuf::from);
593            match (git, work) {
594                (Some(g_dir), None) => {
595                    let git_dir = root.join(g_dir).as_path().try_into()?;
596                    let work_tree = work_tree_from_git_dir(&git_dir)?;
597                    Ok(Self { git_dir, work_tree })
598                }
599                (None, Some(w_dir)) => {
600                    let work_tree = root.join(w_dir).as_path().try_into()?;
601                    let git_dir = git_dir_from_work_tree(&work_tree)?;
602                    Ok(Self { git_dir, work_tree })
603                }
604                (Some(g_dir), Some(w_dir)) => {
605                    let git_dir = root.join(g_dir).as_path().try_into()?;
606                    let work_tree = root.join(w_dir).as_path().try_into()?;
607                    Ok(Self { git_dir, work_tree })
608                }
609                (None, None) => {
610                    let git_dir = search_git_dir(&root)?;
611                    let work_tree = work_tree_from_git_dir(&git_dir)?;
612                    Ok(Self { git_dir, work_tree })
613                }
614            }
615        }
616    }
617}
618
619/// Failed to add subtree
620#[allow(missing_docs)]
621#[derive(thiserror::Error, Debug, PartialEq, Eq)]
622pub enum SubtreeAddError {
623    #[error("Bare repository")]
624    BareRepository,
625    #[error("Working tree dirty")]
626    WorkTreeDirty,
627    #[error("{0}")]
628    Failure(String, i32),
629}
630
631impl From<SubtreeAddError> for PosixError {
632    #[inline]
633    fn from(err: SubtreeAddError) -> Self {
634        match err {
635            SubtreeAddError::BareRepository | SubtreeAddError::WorkTreeDirty => {
636                Self::new(EINVAL, format!("{}", err))
637            }
638            SubtreeAddError::Failure(msg, code) => Self::new(code, msg),
639        }
640    }
641}
642
643/// Failed to pull changes from remote in to subtree
644#[allow(missing_docs)]
645#[derive(thiserror::Error, Debug, PartialEq, Eq)]
646pub enum SubtreePullError {
647    #[error("Working tree dirty")]
648    WorkTreeDirty,
649    #[error("{0}")]
650    Failure(String, i32),
651}
652
653/// Failed to push changes from subtree to remote
654#[allow(missing_docs)]
655#[derive(thiserror::Error, Debug, PartialEq, Eq)]
656pub enum SubtreePushError {
657    #[error("{0}")]
658    Failure(String, i32),
659}
660
661/// Failed to split subtree
662#[allow(missing_docs)]
663#[derive(thiserror::Error, Debug, PartialEq, Eq)]
664pub enum SubtreeSplitError {
665    #[error("Work tree is dirty")]
666    WorkTreeDirty,
667    #[error("{0}")]
668    Failure(String, i32),
669}
670
671/// Failure to stage
672#[allow(missing_docs)]
673#[derive(thiserror::Error, Debug, PartialEq, Eq)]
674pub enum StagingError {
675    #[error("`{0}`")]
676    Failure(String, i32),
677    #[error("File does not exist: `{0}`")]
678    FileDoesNotExist(PathBuf),
679}
680
681impl From<StagingError> for PosixError {
682    #[inline]
683    fn from(e: StagingError) -> Self {
684        let msg = format!("{}", e);
685        match e {
686            StagingError::FileDoesNotExist(_) => Self::new(ENOENT, msg),
687            StagingError::Failure(_, code) => Self::new(code, msg),
688        }
689    }
690}
691
692/// Error during stashing operation
693#[allow(missing_docs)]
694#[derive(thiserror::Error, Debug)]
695pub enum StashingError {
696    #[error("Failed to stash changes in GIT_WORK_TREE")]
697    Save(i32, String),
698    #[error("Failed to pop stashed changes in GIT_WORK_TREE")]
699    Pop(i32, String),
700}
701
702/// Error during committing
703#[allow(missing_docs)]
704#[derive(thiserror::Error, Debug)]
705pub enum CommitError {
706    #[error("`{0}`")]
707    Failure(String, i32),
708}
709
710/// Failed to find reference on remote
711#[derive(thiserror::Error, Debug)]
712pub enum RefSearchError {
713    /// Thrown when `git-ls-remote(1)` fails to execute.
714    #[error("{0}")]
715    Failure(String),
716    /// Generic IO error
717    #[error("{0}")]
718    IOError(#[from] std::io::Error),
719    /// Failed to find reference
720    #[error("Failed to find reference {0}")]
721    NotFound(String),
722    /// `git-ls-remote(1)` returned garbage on `STDOUT`
723    #[error("Failed to parse git-ls-remote(1) output: {0}")]
724    ParsingFailure(String),
725}
726
727impl From<RefSearchError> for PosixError {
728    #[inline]
729    fn from(err: RefSearchError) -> Self {
730        match err {
731            RefSearchError::Failure(msg) => Self::new(ENOENT, msg),
732            RefSearchError::IOError(e) => e.into(),
733            RefSearchError::NotFound(s) => Self::new(ENOENT, s),
734            RefSearchError::ParsingFailure(e) => Self::new(EINVAL, e),
735        }
736    }
737}
738
739/// Functions
740impl Repository {
741    /// Return config value for specified key
742    ///
743    /// # Errors
744    ///
745    /// See [`CommitError`]
746    ///
747    /// # Panics
748    ///
749    /// When `git-commit(1)` fails to execute
750    #[inline]
751    pub fn commit(&self, message: &str) -> Result<(), CommitError> {
752        let out = self
753            .git()
754            .args(&["commit", "-m", message])
755            .output()
756            .expect("Executed git-commit(1)");
757        if out.status.code().unwrap_or(1) != 0 {
758            let msg = String::from_utf8_lossy(out.stderr.as_ref()).to_string();
759            let code = out.status.code().unwrap_or(1);
760            return Err(CommitError::Failure(msg, code));
761        }
762        Ok(())
763    }
764
765    /// # Errors
766    ///
767    /// See [`CommitError`]
768    #[inline]
769    pub fn commit_extended(
770        &self,
771        message: &str,
772        allow_empty: bool,
773        no_verify: bool,
774    ) -> Result<(), CommitError> {
775        let mut cmd = self.git();
776        cmd.args(&["commit", "--quiet", "--no-edit"]);
777
778        if allow_empty {
779            cmd.arg("--allow-empty");
780        }
781
782        if no_verify {
783            cmd.arg("--no-verify");
784        }
785
786        cmd.args(&["--message", message]);
787
788        let out = cmd.output().expect("Failed to execute git-commit(1)");
789        if out.status.code().expect("Expected exit code") != 0 {
790            let msg = String::from_utf8_lossy(out.stderr.as_ref()).to_string();
791            let code = out.status.code().unwrap_or(1);
792            return Err(CommitError::Failure(msg, code));
793        }
794        Ok(())
795    }
796    /// Read file from workspace or use `git-show(1)` if bare repository
797    ///
798    /// # Panics
799    ///
800    /// When UTF-8 encoding path fails
801    ///
802    /// # Errors
803    ///
804    /// When fails throws [`std::io::Error`]
805    /// TODO move to generic repo trait
806    #[inline]
807    pub fn hack_read_file(&self, path: &Path) -> std::io::Result<Vec<u8>> {
808        let absolute_path = self.work_tree.0.join(path);
809        std::fs::read(absolute_path)
810    }
811
812    /// Returns true if the `first` commit is an ancestor of the `second` commit.
813    #[must_use]
814    #[inline]
815    pub fn is_ancestor(&self, first: &str, second: &str) -> bool {
816        let args = vec!["merge-base", "--is-ancestor", first, second];
817        let mut cmd = self.git();
818        cmd.args(args);
819        let proc = cmd.output().expect("Failed to run git-merge-base(1)");
820        proc.status.success()
821    }
822
823    /// # Errors
824    ///
825    /// See [`RefSearchError`]
826    #[inline]
827    pub fn remote_ref_to_id(&self, remote: &str, git_ref: &str) -> Result<String, RefSearchError> {
828        let proc = self.git().args(&["ls-remote", remote, git_ref]).output()?;
829        if !proc.status.success() {
830            let msg = String::from_utf8_lossy(proc.stderr.as_ref()).to_string();
831            return Err(RefSearchError::Failure(msg));
832        }
833        let stdout = String::from_utf8_lossy(&proc.stdout);
834        if let Some(first_line) = stdout.lines().next() {
835            if let Some(id) = first_line.split('\t').next() {
836                return Ok(id.to_owned());
837            }
838            return Err(RefSearchError::ParsingFailure(first_line.to_owned()));
839        }
840
841        Err(RefSearchError::NotFound(git_ref.to_owned()))
842    }
843
844    /// # Errors
845    ///
846    /// When fails will return a String describing the issue.
847    ///
848    /// # Panics
849    ///
850    /// When git-sparse-checkout(1) execution fails
851    #[inline]
852    pub fn sparse_checkout_add(&self, pattern: &str) -> Result<(), String> {
853        let out = self
854            .git()
855            .args(["sparse-checkout", "add"])
856            .arg(pattern)
857            .output()
858            .expect("Failed to execute git sparse-checkout");
859
860        if out.status.success() {
861            Ok(())
862        } else {
863            Err(String::from_utf8_lossy(out.stderr.as_ref()).to_string())
864        }
865    }
866
867    /// # Errors
868    ///
869    /// See [`StagingError`]
870    ///
871    /// # Panics
872    ///
873    /// Panics if fails to execute `git-add(1)`
874    #[inline]
875    pub fn stage(&self, path: &Path) -> Result<(), StagingError> {
876        let relative_path = if path.is_absolute() {
877            path.strip_prefix(self.work_tree().expect("Non bare repo"))
878                .expect("Stripped path prefix")
879        } else {
880            path
881        };
882
883        let file = relative_path.as_os_str();
884        let out = self
885            .git()
886            .args(&["add", "--"])
887            .arg(file)
888            .output()
889            .expect("Executed git-add(1)");
890        match out.status.code().unwrap_or(1) {
891            0 => Ok(()),
892            128 => Err(StagingError::FileDoesNotExist(relative_path.to_path_buf())),
893            e => {
894                let msg = String::from_utf8_lossy(&out.stdout).to_string();
895                Err(StagingError::Failure(msg, e))
896            }
897        }
898    }
899
900    /// Stash staged, unstaged and untracked files (keeps ignored files).
901    ///
902    /// # Errors
903    ///
904    /// See [`StashingError`]
905    #[inline]
906    pub fn stash_almost_all(&self, message: &str) -> Result<(), StashingError> {
907        let mut cmd = self.git();
908        cmd.arg("stash");
909        cmd.arg("--quiet");
910        cmd.args(&["--include-untracked", "-m", message]);
911
912        let out = cmd.output().expect("Failed to execute git-stash(1)");
913        if !out.status.success() {
914            let stderr = String::from_utf8_lossy(&out.stderr).to_string();
915            let code = out.status.code().unwrap_or(1);
916            return Err(StashingError::Save(code, stderr));
917        }
918        Ok(())
919    }
920
921    /// Pop stashed changes
922    ///
923    /// # Errors
924    ///
925    /// See [`StashingError`]
926    #[inline]
927    pub fn stash_pop(&self) -> Result<(), StashingError> {
928        let mut cmd = self.git();
929        let out = cmd
930            .args(&["stash", "pop", "--quiet", "--index"])
931            .output()
932            .expect("Failed to execute git-stash(1)");
933
934        if !out.status.success() {
935            let stderr = String::from_utf8_lossy(&out.stderr).to_string();
936            let code = out.status.code().unwrap_or(1);
937            return Err(StashingError::Pop(code, stderr));
938        }
939        Ok(())
940    }
941
942    /// # Errors
943    ///
944    /// Fails if current repo is bare or dirty. In error cases see the provided string.
945    ///
946    /// # Panics
947    ///
948    /// When git-subtree(1) execution fails
949    #[inline]
950    pub fn subtree_add(
951        &self,
952        url: &str,
953        prefix: &str,
954        revision: &str,
955        message: &str,
956    ) -> Result<(), SubtreeAddError> {
957        if !self.is_clean() {
958            return Err(SubtreeAddError::WorkTreeDirty);
959        }
960
961        let args = vec!["-q", "-P", prefix, url, revision, "-m", message];
962        let mut cmd = self.git();
963        cmd.arg("subtree").arg("add").args(args);
964        let out = cmd.output().expect("Failed to execute git-subtree(1)");
965        if out.status.success() {
966            Ok(())
967        } else {
968            let msg = String::from_utf8_lossy(out.stderr.as_ref()).to_string();
969            let code = out.status.code().unwrap_or(1);
970            Err(SubtreeAddError::Failure(msg, code))
971        }
972    }
973
974    /// # Errors
975    ///
976    /// Fails if current repo is bare or dirty. In error cases see the provided string.
977    ///
978    /// # Panics
979    ///
980    /// When git-subtree(1) execution fails
981    #[inline]
982    pub fn subtree_split(&self, prefix: &str) -> Result<(), SubtreeSplitError> {
983        if !self.is_clean() {
984            return Err(SubtreeSplitError::WorkTreeDirty);
985        }
986
987        let args = vec!["-P", prefix, "--rejoin", "HEAD"];
988        let mut cmd = self.git();
989        cmd.arg("subtree").arg("split").args(args);
990        let result = cmd
991            .spawn()
992            .expect("Failed to execute git-subtree(1)")
993            .wait();
994        match result {
995            Ok(code) => {
996                if code.success() {
997                    Ok(())
998                } else {
999                    Err(SubtreeSplitError::Failure(
1000                        "git-subtree split failed".to_owned(),
1001                        1,
1002                    ))
1003                }
1004            }
1005            Err(e) => {
1006                let msg = format!("{}", e);
1007                Err(SubtreeSplitError::Failure(msg, 1))
1008            }
1009        }
1010    }
1011
1012    /// # Errors
1013    ///
1014    /// Fails if current repo is bare or dirty. In error cases see the provided string.
1015    ///
1016    /// # Panics
1017    ///
1018    /// When git-subtree(1) execution fails
1019    #[inline]
1020    pub fn subtree_pull(
1021        &self,
1022        remote: &str,
1023        prefix: &str,
1024        git_ref: &str,
1025        message: &str,
1026    ) -> Result<(), SubtreePullError> {
1027        if !self.is_clean() {
1028            return Err(SubtreePullError::WorkTreeDirty);
1029        }
1030        let args = vec!["-q", "-P", prefix, remote, git_ref, "-m", message];
1031        let mut cmd = self.git();
1032        cmd.arg("subtree").arg("pull").args(args);
1033        let out = cmd.output().expect("Failed to execute git-subtree(1)");
1034        if out.status.success() {
1035            Ok(())
1036        } else {
1037            let msg = String::from_utf8_lossy(out.stderr.as_ref()).to_string();
1038            let code = out.status.code().unwrap_or(1);
1039            Err(SubtreePullError::Failure(msg, code))
1040        }
1041    }
1042
1043    /// # Errors
1044    ///
1045    /// Fails if current repo is bare. In other error cases see the provided message string.
1046    #[inline]
1047    pub fn subtree_push(
1048        &self,
1049        remote: &str,
1050        prefix: &str,
1051        git_ref: &str,
1052    ) -> Result<(), SubtreePushError> {
1053        let args = vec!["subtree", "push", "-q", "-P", prefix, remote, git_ref];
1054        let mut cmd = self.git();
1055        cmd.args(args);
1056        let out = cmd.output().expect("Failed to execute git-subtree(1)");
1057        if out.status.success() {
1058            Ok(())
1059        } else {
1060            let msg = String::from_utf8_lossy(out.stderr.as_ref()).to_string();
1061            let code = out.status.code().unwrap_or(1);
1062            Err(SubtreePushError::Failure(msg, code))
1063        }
1064    }
1065}
1066
1067/// Failed to resolve given value to a commit id
1068#[allow(missing_docs)]
1069#[derive(thiserror::Error, Debug)]
1070pub enum InvalidCommitishError {
1071    #[error("Invalid reference or commit id: `{0}`")]
1072    One(String),
1073    #[error("One or Multiple invalid reference or commit ids: `{0:?}`")]
1074    Multiple(Vec<String>),
1075    #[error("{0}")]
1076    Failure(String, i32),
1077}
1078
1079/// Commit Functions
1080impl Repository {
1081    ///  Find best common ancestor between to commits.
1082    ///
1083    /// # Errors
1084    ///
1085    /// Will return `InvalidCommitishError::Multiple` when one or multiple provided ids do not
1086    /// exist
1087    ///
1088    /// # Panics
1089    ///
1090    /// When exit code of git-merge-base(1) is not 0 or 128
1091    #[inline]
1092    pub fn merge_base(&self, ids: &[&str]) -> Result<Option<String>, InvalidCommitishError> {
1093        let output = self
1094            .git()
1095            .arg("merge-base")
1096            .args(ids)
1097            .output()
1098            .expect("Executing git-merge-base(1)");
1099        if output.status.success() {
1100            let tmp = String::from_utf8_lossy(&output.stdout);
1101            if tmp.is_empty() {
1102                return Ok(None);
1103            }
1104            let result = tmp.trim_end();
1105            return Ok(Some(result.to_owned()));
1106        }
1107        match output.status.code().expect("Getting status code") {
1108            128 => {
1109                let tmp = ids.to_vec();
1110                let e_ids = tmp.iter().map(ToString::to_string).collect();
1111                Err(InvalidCommitishError::Multiple(e_ids))
1112            }
1113            1 => Ok(None),
1114            code => {
1115                let msg = String::from_utf8_lossy(&output.stdout);
1116                Err(InvalidCommitishError::Failure(msg.to_string(), code))
1117            }
1118        }
1119    }
1120
1121    /// Returns a prepared git `Command` struct
1122    /// TODO move to generic repo trait
1123    #[must_use]
1124    #[inline]
1125    pub fn git(&self) -> Command {
1126        let mut cmd = Command::new("git");
1127        let git_dir = self.git_dir().0.to_str().expect("Convert to string");
1128        cmd.env("GIT_DIR", git_dir);
1129        cmd.env("GIT_WORK_TREE", &self.work_tree.0);
1130        cmd.current_dir(&self.work_tree.0);
1131        cmd
1132    }
1133}
1134
1135impl GenericRepository for Repository {
1136    fn gen_git(&self) -> Command {
1137        self.git()
1138    }
1139}
1140
1141/// Exports NAME & EMAIL variables so git don't complain if no user is setup
1142#[inline]
1143pub fn setup_test_author() {
1144    std::env::set_var("GIT_AUTHOR_NAME", "Max Musterman");
1145    std::env::set_var("GIT_AUTHOR_EMAIL", "max@example.com");
1146    std::env::set_var("GIT_COMMITTER_NAME", "Max Musterman");
1147    std::env::set_var("GIT_COMMITTER_EMAIL", "max@example.com");
1148}
1149
1150#[cfg(test)]
1151mod test {
1152
1153    mod repository_initialization {
1154        use crate::{RepoError, Repository};
1155        use tempfile::TempDir;
1156
1157        #[test]
1158        fn git_dir_not_found() {
1159            let tmp_dir = TempDir::new().unwrap();
1160            let discovery_path = tmp_dir.path();
1161            let err = Repository::discover(discovery_path)
1162                .expect_err("Fail to find repo in an empty directory");
1163            assert!(err == RepoError::GitDirNotFound);
1164        }
1165
1166        #[test]
1167        fn normal_repo() {
1168            let tmp_dir = TempDir::new().unwrap();
1169            let repo_path = tmp_dir.path();
1170            let _repo = Repository::create(repo_path).unwrap();
1171        }
1172    }
1173
1174    mod is_clean {
1175        use crate::Repository;
1176        use tempfile::TempDir;
1177
1178        #[test]
1179        fn unstaged() {
1180            let tmp_dir = TempDir::new().unwrap();
1181            let repo_path = tmp_dir.path();
1182            let repo = Repository::create(repo_path).expect("Created repository");
1183
1184            let readme = repo_path.join("README.md");
1185            std::fs::File::create(&readme).unwrap();
1186            std::fs::write(&readme, "# README").unwrap();
1187            assert!(!repo.is_clean(), "Repo is unclean if sth. is unstaged");
1188        }
1189
1190        #[test]
1191        fn staged() {
1192            let tmp_dir = TempDir::new().unwrap();
1193            let repo_path = tmp_dir.path();
1194            let repo = Repository::create(repo_path).expect("Created repository");
1195
1196            let readme = repo_path.join("README.md");
1197            std::fs::File::create(&readme).unwrap();
1198            repo.stage(&readme).unwrap();
1199            assert!(!repo.is_clean(), "Repo is unclean if sth. is staged");
1200        }
1201    }
1202
1203    mod config {
1204        use crate::BareRepository;
1205        use tempfile::TempDir;
1206
1207        #[test]
1208        fn config() {
1209            let tmp_dir = TempDir::new().unwrap();
1210            let repo_path = tmp_dir.path();
1211            let repo = BareRepository::create(repo_path).expect("Created bare repository");
1212            let actual = repo.config("core.bare").unwrap();
1213            assert_eq!(actual, "true".to_owned(), "Expected true");
1214
1215            tmp_dir.close().unwrap();
1216        }
1217    }
1218
1219    mod sparse_checkout {
1220        use crate::Repository;
1221        use std::process::Command;
1222        use tempfile::TempDir;
1223
1224        #[test]
1225        fn is_sparse() {
1226            let tmp_dir = TempDir::new().unwrap();
1227            let repo_path = tmp_dir.path();
1228            let repo = Repository::create(repo_path).expect("Created repository");
1229            let mut cmd = Command::new("git");
1230            let out = cmd
1231                .args(&["sparse-checkout", "init"])
1232                .current_dir(repo_path)
1233                .output()
1234                .unwrap();
1235            assert!(out.status.success(), "Try to make repository sparse");
1236            assert!(repo.is_sparse(), "Not sparse repository");
1237        }
1238
1239        #[test]
1240        fn not_sparse() {
1241            let tmp_dir = TempDir::new().unwrap();
1242            let repo_path = tmp_dir.path();
1243            let repo = Repository::create(repo_path).expect("Created repository");
1244            assert!(!repo.is_sparse(), "Not sparse repository");
1245        }
1246
1247        #[test]
1248        fn add() {
1249            let tmp_dir = TempDir::new().unwrap();
1250            let repo_path = tmp_dir.path();
1251            let repo = Repository::create(repo_path).expect("Created repository");
1252            repo.git()
1253                .args(["sparse-checkout", "init"])
1254                .output()
1255                .unwrap();
1256            let actual = repo.sparse_checkout_add("foo/bar");
1257            assert!(actual.is_ok(), "Expected successfull execution");
1258
1259            tmp_dir.close().unwrap();
1260        }
1261    }
1262
1263    mod subtree_add {
1264        use crate::{setup_test_author, Repository, SubtreeAddError};
1265        use tempfile::TempDir;
1266
1267        #[test]
1268        fn dirty_work_tree() {
1269            let tmp_dir = TempDir::new().unwrap();
1270            let repo_path = tmp_dir.path();
1271            let repo = Repository::create(repo_path).expect("Created repository");
1272            let err = repo
1273                .subtree_add("https://example.com/foo/bar", "bar", "HEAD", "Some Message")
1274                .expect_err("Expected an error");
1275            assert_eq!(err, SubtreeAddError::WorkTreeDirty);
1276            tmp_dir.close().unwrap();
1277        }
1278
1279        #[test]
1280        fn successfull() {
1281            let tmp_dir = TempDir::new().unwrap();
1282            let repo_path = tmp_dir.path();
1283            setup_test_author();
1284            let repo = Repository::create(repo_path).expect("Created repository");
1285            let readme = repo_path.join("README.md");
1286            std::fs::File::create(&readme).unwrap();
1287            std::fs::write(&readme, "# README").unwrap();
1288            repo.stage(&readme).unwrap();
1289            repo.commit("Test").unwrap();
1290            let actual = repo.subtree_add(
1291                "https://github.com/kalkin/file-expert",
1292                "bar",
1293                "HEAD",
1294                "Some Message",
1295            );
1296            assert!(actual.is_ok(), "Failure to add subtree");
1297        }
1298    }
1299
1300    mod subtree_pull {
1301        use crate::{setup_test_author, Repository, SubtreePullError};
1302        use tempfile::TempDir;
1303
1304        #[test]
1305        fn dirty_work_tree() {
1306            let tmp_dir = TempDir::new().unwrap();
1307            let repo_path = tmp_dir.path();
1308            let repo = Repository::create(repo_path).expect("Created repository");
1309            let err = repo
1310                .subtree_pull("https://example.com/foo/bar", "bar", "HEAD", "Some Message")
1311                .expect_err("Expected an error");
1312            assert_eq!(err, SubtreePullError::WorkTreeDirty);
1313        }
1314
1315        #[test]
1316        fn successfull() {
1317            let tmp_dir = TempDir::new().unwrap();
1318            let repo_path = tmp_dir.path();
1319            setup_test_author();
1320            let repo = Repository::create(repo_path).expect("Created repository");
1321            let readme = repo_path.join("README.md");
1322            std::fs::File::create(&readme).unwrap();
1323            std::fs::write(&readme, "# README").unwrap();
1324            repo.stage(&readme).unwrap();
1325            repo.commit("Test").unwrap();
1326            repo.subtree_add(
1327                "https://github.com/kalkin/file-expert",
1328                "bar",
1329                "v0.10.1",
1330                "Some Message",
1331            )
1332            .unwrap();
1333
1334            let actual = repo.subtree_pull(
1335                "https://github.com/kalkin/file-expert",
1336                "bar",
1337                "v0.13.1",
1338                "Some message",
1339            );
1340            assert!(actual.is_ok(), "Failure to pull subtree");
1341        }
1342    }
1343
1344    mod remote_ref_resolution {
1345        use crate::RefSearchError;
1346        use crate::Repository;
1347        use tempfile::TempDir;
1348
1349        #[test]
1350        fn not_found() {
1351            let tmp_dir = TempDir::new().unwrap();
1352            let repo_path = tmp_dir.path();
1353            let repo = Repository::create(repo_path).expect("Created repository");
1354            let result =
1355                repo.remote_ref_to_id("https://github.com/kalkin/file-expert", "v230.40.50");
1356            assert!(result.is_err());
1357            #[allow(clippy::shadow_unrelated)]
1358            {
1359                let _expected =
1360                    RefSearchError::NotFound("Failed to find reference v230.40.50".to_owned());
1361                assert!(
1362                    matches!(result.unwrap_err(), _expected),
1363                    "should not find v230.40.50"
1364                );
1365            }
1366        }
1367
1368        #[test]
1369        fn failure() {
1370            let tmp_dir = TempDir::new().unwrap();
1371            let repo_path = tmp_dir.path();
1372            let repo = Repository::create(repo_path).expect("Created repository");
1373            let result = repo.remote_ref_to_id("https://example.com/asd/foo", "v230.40.50");
1374            assert!(result.is_err());
1375            let actual = matches!(result.unwrap_err(), RefSearchError::Failure(_));
1376            assert!(actual, "should not find any repo");
1377        }
1378
1379        #[test]
1380        fn successfull_search() {
1381            let tmp_dir = TempDir::new().unwrap();
1382            let repo_path = tmp_dir.path();
1383            let repo = Repository::create(repo_path).expect("Created repository");
1384            let result = repo.remote_ref_to_id("https://github.com/kalkin/file-expert", "v0.9.0");
1385            assert!(result.is_ok());
1386            let actual = result.unwrap();
1387            let expected = "24f624a0268f6cbcfc163abef5f3acbc6c11085e".to_owned();
1388            assert_eq!(expected, actual, "Find commit id for v0.9.0");
1389        }
1390    }
1391}