git_shell/
git.rs

1// License: see LICENSE file at root directory of main branch
2
3//! # Git
4
5use core::{
6    iter::Empty,
7    str::FromStr,
8};
9
10use std::{
11    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
12    env,
13    ffi::OsStr,
14    io::{Error, ErrorKind},
15    path::{Path, PathBuf},
16    process::{Command, Stdio},
17};
18
19use {
20    crate::Result,
21    dia_semver::Semver,
22};
23
24mod remote;
25mod status;
26mod work_tree;
27
28pub use self::{
29    remote::*,
30    status::*,
31    work_tree::*,
32};
33
34/// # A `None` of an empty iterator of paths
35///
36/// This is a convenient value for passing to [`Git::new_cmd_for_adding_all_files()`][fn:Git/new_cmd_for_adding_all_files] or similar functions.
37///
38/// [fn:Git/new_cmd_for_adding_all_files]: struct.Git.html#method.new_cmd_for_adding_all_files
39pub const NO_PATHS: Option<Empty<PathBuf>> = None;
40
41const APP: &str = "git";
42
43const CMD_ADD: &str = "add";
44const CMD_BRANCH: &str = "branch";
45const CMD_COMMIT: &str = "commit";
46const CMD_PUSH: &str = "push";
47const CMD_REMOTE: &str = "remote";
48const CMD_STATUS: &str = "status";
49const CMD_TAG: &str = "tag";
50const CMD_WORKTREE: &str = "worktree";
51
52const OPTION_2_HYPHENS: &str = "--";
53const OPTION_ALL: &str = "--all";
54const OPTION_ANNOTATE: &str = "--annotate";
55const OPTION_MESSAGE: &str = "--message";
56const OPTION_SHOW_CURRENT: &str = "--show-current";
57const OPTION_TAGS: &str = "--tags";
58const OPTION_VERBOSE: &str = "--verbose";
59
60const NULL_LINE_BREAK: char = '\0';
61
62/// # Git
63///
64/// This struct holds a path to some repository. The path does not need to be root directory of that repository.
65#[derive(Debug)]
66pub struct Git {
67
68    path: PathBuf,
69
70}
71
72impl Git {
73
74    /// # Makes new instance
75    ///
76    /// If you don't provide a path, current directory will be used.
77    pub fn make<P>(path: Option<P>) -> Result<Self> where P: AsRef<Path> {
78        Ok(Self {
79            path: match path {
80                Some(path) => path.as_ref().to_path_buf(),
81                None => env::current_dir()?,
82            },
83        })
84    }
85
86    /// # Path of this repository
87    ///
88    /// It's *not* always the root directory of the repository.
89    pub fn path(&self) -> &Path {
90        &self.path
91    }
92
93    /// # Makes new command
94    fn new_cmd<S, S2>(&self, cmd: S, args: Option<&[S2]>) -> Command where S: AsRef<OsStr>, S2: AsRef<OsStr> {
95        let mut result = Command::new(APP);
96        result.current_dir(&self.path);
97
98        // Command
99        result.arg(cmd);
100
101        // Arguments
102        if let Some(args) = args {
103            for a in args {
104                result.arg(a);
105            }
106        }
107
108        result
109    }
110
111    /// # Makes new command for adding all files
112    pub fn new_cmd_for_adding_all_files<F, P>(&self, files: Option<F>) -> Command where F: Iterator<Item=P>, P: AsRef<Path> {
113        let mut result = self.new_cmd(CMD_ADD, Some(&[OPTION_ALL]));
114
115        if let Some(files) = files {
116            result.arg(OPTION_2_HYPHENS);
117            for f in files {
118                result.arg(f.as_ref());
119            }
120        }
121
122        result
123    }
124
125    /// # Makes new command for committing all files with a message
126    pub fn new_cmd_for_committing_all_files_with_a_message<S>(&self, msg: S) -> Command where S: AsRef<str> {
127        self.new_cmd(CMD_COMMIT, Some(&[OPTION_ALL, OPTION_MESSAGE, msg.as_ref()]))
128    }
129
130    /// # Makes new command for adding an annotated tag with a message
131    pub fn new_cmd_for_adding_an_annotated_tag_with_a_message<S>(&self, tag: &Semver, msg: S) -> Command where S: AsRef<str> {
132        let tag = tag.to_string();
133        self.new_cmd(CMD_TAG, Some(&[tag.as_str(), OPTION_ANNOTATE, OPTION_MESSAGE, msg.as_ref()]))
134    }
135
136    /// # Makes new command for pushing to a remote
137    pub fn new_cmd_for_pushing_to_a_remote(&self, remote: &Remote) -> Command {
138        self.new_cmd(CMD_PUSH, Some(&[remote.name()]))
139    }
140
141    /// # Makes new command for pushing tags to a remote
142    pub fn new_cmd_for_pushing_tags_to_a_remote(&self, remote: &Remote) -> Command {
143        self.new_cmd(CMD_PUSH, Some(&[remote.name(), OPTION_TAGS]))
144    }
145
146    /// # Makes new command, runs it and returns its output (stdout)
147    fn run_new_cmd<S, S2>(&self, cmd: S, args: Option<&[S2]>) -> Result<String> where S: AsRef<OsStr>, S2: AsRef<OsStr> {
148        self.run_cmd(&mut self.new_cmd(cmd, args))
149    }
150
151    /// # Runs given command and returns its output (stdout)
152    fn run_cmd(&self, cmd: &mut Command) -> Result<String> {
153        cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
154
155        let output = cmd.output()?;
156        if output.status.success() {
157            Ok(String::from_utf8(output.stdout).map_err(|e| Error::new(ErrorKind::Other, e))?)
158        } else {
159            Err(Error::new(ErrorKind::Other, format!("{app} returned: {status}", app=APP, status=output.status)))
160        }
161    }
162
163    /// # Loads current branch
164    pub fn current_branch(&self) -> Result<String> {
165        let output = self.run_new_cmd(CMD_BRANCH, Some(&[OPTION_SHOW_CURRENT]))?;
166        let output = output.trim();
167        if output.lines().count() == 1 {
168            Ok(output.to_string())
169        } else {
170            Err(Error::new(ErrorKind::Other, format!("{app} returned: {output}", app=APP, output=output)))
171        }
172    }
173
174    /// # Finds last version having build metadata in your set
175    pub fn find_last_version_with_build_metadata<S>(&self, build_metadata: &[Option<S>]) -> Result<Option<Semver>> where S: AsRef<str> {
176        let build_metadata = build_metadata.iter().map(|bm| bm.as_ref().map(|bm| bm.as_ref())).collect::<BTreeSet<_>>();
177
178        let output = self.run_new_cmd::<_, &str>(CMD_TAG, None)?;
179
180        let mut result = None;
181        for line in output.lines() {
182            if let Ok(version) = Semver::from_str(line.trim()) {
183                if build_metadata.contains(&version.build_metadata()) {
184                    match result.as_mut() {
185                        None => result = Some(version),
186                        Some(other) => if &version > other {
187                            *other = version;
188                        },
189                    };
190                }
191            }
192        }
193
194        Ok(result)
195    }
196
197    /// # Finds last versions, grouped by build metadata
198    pub fn find_last_versions(&self) -> Result<BTreeMap<Option<String>, Semver>> {
199        let output = self.run_new_cmd::<_, &str>(CMD_TAG, None)?;
200
201        let mut result = BTreeMap::new();
202        for line in output.lines() {
203            if let Ok(new_version) = Semver::from_str(line.trim()) {
204                let current = result.entry(new_version.build_metadata().map(|bm| bm.to_string())).or_insert_with(|| new_version.clone());
205                if &new_version > current {
206                    *current = new_version;
207                }
208            }
209        }
210
211        Ok(result)
212    }
213
214    /// # Loads remotes and sort them
215    pub fn remotes(&self) -> Result<Vec<Remote>> {
216        let output = self.run_new_cmd(CMD_REMOTE, Some(&[OPTION_VERBOSE]))?;
217
218        let mut set = HashSet::with_capacity(9);
219        for line in output.lines() {
220            let mut parts = line.split_whitespace();
221            match (parts.next(), parts.next(), parts.next(), parts.next()) {
222                (Some(name), Some(url), Some("(fetch)" | "(push)"), None) => set.insert(Remote::new(name.to_string(), url.to_string())),
223                _ => continue,
224            };
225        }
226
227        let mut result: Vec<_> = set.into_iter().collect();
228        result.sort();
229        Ok(result)
230    }
231
232    /// # Loads work trees
233    pub fn work_trees(&self) -> Result<HashSet<WorkTree>> {
234        const PREFIX_WORKTREE: &str = "worktree";
235
236        let data = self.run_new_cmd(CMD_WORKTREE, Some(&[work_tree::CMD_LIST, work_tree::OPTION_PORCELAIN, work_tree::OPTION_Z]))?;
237        let mut result = HashSet::with_capacity(data.split(NULL_LINE_BREAK).count() / 4);
238        for line in data.split(NULL_LINE_BREAK).map(|l| l.trim()) {
239            if line.starts_with(PREFIX_WORKTREE) {
240                let mut parts = line.split_whitespace();
241                match (parts.next(), parts.next(), parts.next()) {
242                    (Some(PREFIX_WORKTREE), Some(path), None) => result.insert(WorkTree::make(path)?),
243                    // Git docs say the output is stable, so we can return an error here
244                    _ => return Err(Error::new(ErrorKind::InvalidData, __!("Invalid format: {:?}", line))),
245                };
246            }
247        }
248
249        Ok(result)
250    }
251
252    /// # Loads status
253    pub fn status<F, P>(&self, files: Option<F>) -> Result<HashMap<PathBuf, Status>> where F: Iterator<Item=P>, P: AsRef<Path> {
254        let mut cmd = self.new_cmd(CMD_STATUS, Some(&[OPTION_PORCELAIN, OPTION_Z]));
255
256        if let Some(files) = files {
257            cmd.arg(OPTION_2_HYPHENS);
258            for f in files {
259                cmd.arg(f.as_ref());
260            }
261        }
262
263        let data = self.run_cmd(&mut cmd)?;
264
265        let mut result = HashMap::with_capacity(data.split(NULL_LINE_BREAK).count());
266        for line in data.split(NULL_LINE_BREAK).map(|l| l.trim()) {
267            if let Some(note) = line.split_whitespace().next() {
268                result.insert(PathBuf::from(line[note.len()..].trim()), Status::from_str(note)?);
269            }
270        }
271
272        Ok(result)
273    }
274
275}