git_workarea/
git.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use std::borrow::Cow;
8use std::ffi::OsStr;
9use std::fmt::{self, Display};
10use std::io;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use log::debug;
15use thiserror::Error;
16
17use crate::prepare::{GitWorkArea, WorkAreaResult};
18
19/// The Git object id of a commit.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct CommitId(String);
22
23/// Errors which may occur when working with workareas.
24#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum GitError {
27    /// Command preparation failure.
28    #[error("failed to construct 'git {}' command", subcommand)]
29    Subcommand {
30        /// The git subcommand which failed.
31        subcommand: &'static str,
32        /// The root cause of the failure.
33        #[source]
34        source: io::Error,
35    },
36    /// A git error occurred.
37    #[error("git error: '{}'", msg)]
38    Git {
39        /// Message describing what failed.
40        msg: Cow<'static, str>,
41        /// The root cause of the failure (if available).
42        #[source]
43        source: Option<io::Error>,
44    },
45    /// An invalid ref was used.
46    #[error("invalid git ref: '{}'", ref_)]
47    InvalidRef {
48        /// The invalid ref (or description of what was wrong).
49        ref_: Cow<'static, str>,
50    },
51}
52
53impl GitError {
54    /// Convenience method for constructing an error for a git subcommand failure.
55    pub fn subcommand(subcommand: &'static str, source: io::Error) -> Self {
56        GitError::Subcommand {
57            subcommand,
58            source,
59        }
60    }
61
62    pub(crate) fn git<M>(msg: M) -> Self
63    where
64        M: Into<Cow<'static, str>>,
65    {
66        GitError::Git {
67            msg: msg.into(),
68            source: None,
69        }
70    }
71
72    pub(crate) fn git_with_source<M>(msg: M, source: io::Error) -> Self
73    where
74        M: Into<Cow<'static, str>>,
75    {
76        GitError::Git {
77            msg: msg.into(),
78            source: Some(source),
79        }
80    }
81
82    pub(crate) fn invalid_ref<R>(ref_: R) -> Self
83    where
84        R: Into<Cow<'static, str>>,
85    {
86        GitError::InvalidRef {
87            ref_: ref_.into(),
88        }
89    }
90}
91
92pub(crate) type GitResult<T> = Result<T, GitError>;
93
94impl CommitId {
95    /// Create a new `CommitId`.
96    pub fn new<I>(id: I) -> Self
97    where
98        I: Into<String>,
99    {
100        CommitId(id.into())
101    }
102
103    /// The commit as a string reference.
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107}
108
109impl Display for CommitId {
110    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
111        write!(f, "{}", self.as_str())
112    }
113}
114
115/// A context for performing git commands.
116#[derive(Debug, Clone)]
117pub struct GitContext {
118    /// The path to the `.git` directory.
119    ///
120    /// Note that this must not be the path to a checkout. It is treated as a bare repository.
121    gitdir: PathBuf,
122    /// The path to the configuration file.
123    config: Option<PathBuf>,
124}
125
126/// An identity for creating git commits.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Identity {
129    /// The name.
130    pub name: String,
131    /// The email address.
132    pub email: String,
133}
134
135impl Identity {
136    /// Create a new identity.
137    pub fn new<N, E>(name: N, email: E) -> Self
138    where
139        N: Into<String>,
140        E: Into<String>,
141    {
142        Self {
143            name: name.into(),
144            email: email.into(),
145        }
146    }
147}
148
149impl Display for Identity {
150    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
151        write!(f, "{} <{}>", self.name, self.email)
152    }
153}
154
155/// Status of a merge check.
156#[derive(Debug)]
157pub enum MergeStatus {
158    /// The branches do not contain common history.
159    NoCommonHistory,
160    /// The branch has already been merged.
161    AlreadyMerged,
162    /// The branch is mergeable with the given hashes as merge bases.
163    Mergeable(Vec<CommitId>),
164}
165
166impl Display for MergeStatus {
167    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
168        write!(
169            f,
170            "{}",
171            match *self {
172                MergeStatus::NoCommonHistory => "no common history",
173                MergeStatus::AlreadyMerged => "already merged",
174                MergeStatus::Mergeable(_) => "mergeable",
175            },
176        )
177    }
178}
179
180impl GitContext {
181    /// Create a new context for the given directory.
182    pub fn new<P>(gitdir: P) -> Self
183    where
184        P: Into<PathBuf>,
185    {
186        Self {
187            gitdir: gitdir.into(),
188            config: None,
189        }
190    }
191
192    /// Create a new context for the given directory with git configuration.
193    pub fn new_with_config<P, C>(gitdir: P, config: C) -> Self
194    where
195        P: Into<PathBuf>,
196        C: Into<PathBuf>,
197    {
198        Self {
199            gitdir: gitdir.into(),
200            config: Some(config.into()),
201        }
202    }
203
204    /// Run a git command in the context.
205    ///
206    /// This builds a `Command` with the proper environment to operate within the context.
207    pub fn git(&self) -> Command {
208        let mut git = Command::new("git");
209
210        git.env("GIT_DIR", &self.gitdir);
211
212        self.config
213            .as_ref()
214            .map(|config| git.env("GIT_CONFIG", config));
215
216        git
217    }
218
219    /// Fetch references from the given remote.
220    ///
221    /// The remote is interpreted by Git, so it can be a remote or a specific URL.
222    pub fn fetch<R, I, N>(&self, remote: R, refnames: I) -> GitResult<()>
223    where
224        R: AsRef<str>,
225        I: IntoIterator<Item = N>,
226        N: AsRef<OsStr>,
227    {
228        let fetch = self
229            .git()
230            .arg("fetch")
231            .arg(remote.as_ref())
232            .args(refnames.into_iter())
233            .output()
234            .map_err(|err| GitError::subcommand("fetch", err))?;
235        if !fetch.status.success() {
236            return Err(GitError::git(format!(
237                "fetch from {} failed: {}",
238                remote.as_ref(),
239                String::from_utf8_lossy(&fetch.stderr)
240            )));
241        }
242
243        Ok(())
244    }
245
246    /// Fetch a commit from the given remote into a specific local refname.
247    pub fn fetch_into<R, N, T>(&self, remote: R, refname: N, target: T) -> GitResult<()>
248    where
249        R: AsRef<str>,
250        N: AsRef<str>,
251        T: AsRef<str>,
252    {
253        self.fetch(
254            remote,
255            [&format!("{}:{}", refname.as_ref(), target.as_ref())],
256        )
257    }
258
259    /// Fetch a commit from the given remote into a specific local refname, allowing rewinds.
260    pub fn force_fetch_into<R, N, T>(&self, remote: R, refname: N, target: T) -> GitResult<()>
261    where
262        R: AsRef<str>,
263        N: AsRef<str>,
264        T: AsRef<str>,
265    {
266        self.fetch_into(remote, format!("+{}", refname.as_ref()), target)
267    }
268
269    /// Determine the "default branch" for the repository.
270    pub fn default_branch(&self) -> GitResult<Option<String>> {
271        // Read the configuration value provided for this purpose.
272        let default_branch_name = self
273            .git()
274            .arg("config")
275            .arg("--get")
276            .arg("init.defaultBranchName")
277            .output()
278            .map_err(|err| GitError::subcommand("config --get init.defaultBranchName", err))?;
279        if default_branch_name.status.success() {
280            return Ok(Some(
281                String::from_utf8_lossy(&default_branch_name.stdout)
282                    .trim()
283                    .into(),
284            ));
285        }
286
287        // Check `origin/HEAD` if it exists to avoid remote access.
288        let origin_head = self
289            .git()
290            .arg("symbolic-ref")
291            .arg("--short")
292            .arg("refs/remotes/origin/HEAD")
293            .output()
294            .map_err(|err| GitError::subcommand("symbolic-ref origin/HEAD", err))?;
295        if origin_head.status.success() {
296            const ORIGIN_PREFIX: &str = "origin/";
297            let full_refname = String::from_utf8_lossy(&origin_head.stdout);
298            let refname = full_refname.trim();
299            if let Some(on_origin) = refname.strip_prefix(ORIGIN_PREFIX) {
300                return Ok(Some(on_origin.into()));
301            }
302        }
303
304        // Ask the remote directly what its default branch is.
305        let ls_origin_head = self
306            .git()
307            .arg("ls-remote")
308            .arg("--symref")
309            .arg("origin")
310            .arg("HEAD")
311            .output()
312            .map_err(|err| GitError::subcommand("ls-remote --symref origin", err))?;
313        if ls_origin_head.status.success() {
314            const SYMREF_PREFIX: &str = "ref: refs/heads/";
315            const SYMREF_SUFFIX: &str = "\tHEAD";
316            let full_output = String::from_utf8_lossy(&ls_origin_head.stdout);
317            for line in full_output.lines() {
318                if line.starts_with(SYMREF_PREFIX) && line.ends_with(SYMREF_SUFFIX) {
319                    let refname = &line[SYMREF_PREFIX.len()..(line.len() - SYMREF_SUFFIX.len())];
320                    return Ok(Some(refname.into()));
321                }
322            }
323        }
324
325        // Unknown remote; use the local `HEAD` as the default.
326        let head = self
327            .git()
328            .arg("symbolic-ref")
329            .arg("--short")
330            .arg("HEAD")
331            .output()
332            .map_err(|err| GitError::subcommand("symbolic-ref HEAD", err))?;
333        if head.status.success() {
334            return Ok(Some(String::from_utf8_lossy(&head.stdout).trim().into()));
335        }
336
337        Ok(None)
338    }
339
340    /// Create a tree where further work on the given revision can occur.
341    pub fn prepare(&self, rev: &CommitId) -> WorkAreaResult<GitWorkArea> {
342        GitWorkArea::new(self.clone(), rev)
343    }
344
345    /// Reserve a refname for the given commit.
346    ///
347    /// Returns the name of the reserved ref pointing to the given commit and its ID.
348    ///
349    /// The reserved reference is created as `refs/{name}/heads/{id}` where `id` is a unique
350    /// integer (which is also returned).
351    pub fn reserve_ref<N>(&self, name: N, commit: &CommitId) -> GitResult<(String, usize)>
352    where
353        N: AsRef<str>,
354    {
355        let ref_prefix = format!("refs/{}/heads", name.as_ref());
356
357        debug!(target: "git", "reserving ref under {}", ref_prefix);
358
359        loop {
360            let for_each_ref = self
361                .git()
362                .arg("for-each-ref")
363                .arg("--format=%(refname)")
364                .arg("--")
365                .arg(&ref_prefix)
366                .output()
367                .map_err(|err| GitError::subcommand("for-each-ref", err))?;
368            if !for_each_ref.status.success() {
369                return Err(GitError::git(format!(
370                    "listing all {} refs: {}",
371                    ref_prefix,
372                    String::from_utf8_lossy(&for_each_ref.stderr)
373                )));
374            }
375            let refs = String::from_utf8_lossy(&for_each_ref.stdout);
376
377            let nrefs = refs.lines().count();
378            let new_ref = format!("{}/{}", ref_prefix, nrefs);
379
380            debug!(target: "git", "trying to reserve ref {}", new_ref);
381
382            let lock_ref = self
383                .git()
384                .arg("update-ref")
385                .arg(&new_ref)
386                .arg(commit.as_str())
387                .arg("0000000000000000000000000000000000000000")
388                .stdout(Stdio::null())
389                .output()
390                .map_err(|err| GitError::git_with_source("update-ref", err))?;
391
392            if lock_ref.status.success() {
393                debug!(target: "git", "successfully reserved {}", new_ref);
394
395                return Ok((new_ref, nrefs));
396            }
397
398            let err = String::from_utf8_lossy(&lock_ref.stderr);
399            if err.contains("with nonexistent object") {
400                return Err(GitError::invalid_ref("no such commit"));
401            } else if err.contains("not a valid SHA1") {
402                return Err(GitError::invalid_ref("invalid SHA"));
403            }
404        }
405    }
406
407    /// Reserve two refnames for the given commit.
408    ///
409    /// Returns the names of the two reserved refs, the first pointing to the given commit and the
410    /// second available for further work.
411    ///
412    /// The reserved references are created as `refs/{name}/heads/{id}` and
413    /// `refs/{name}/bases/{id}` where the `bases` reference is available to point to an object
414    /// associated with the `heads` reference.
415    ///
416    /// It is assumed that the `bases` refs are aligned with the `heads` references and not used
417    /// for other purposes.
418    pub fn reserve_refs<N>(&self, name: N, commit: &CommitId) -> GitResult<(String, String)>
419    where
420        N: AsRef<str>,
421    {
422        let (new_ref, id) = self.reserve_ref(name.as_ref(), commit)?;
423        let new_base = format!("refs/{}/bases/{}", name.as_ref(), id);
424
425        debug!(target: "git", "successfully reserved {} and {}", new_ref, new_base);
426
427        Ok((new_ref, new_base))
428    }
429
430    /// Check if a topic commit is mergeable into a target branch.
431    pub fn mergeable(&self, base: &CommitId, topic: &CommitId) -> GitResult<MergeStatus> {
432        let merge_base = self
433            .git()
434            .arg("merge-base")
435            .arg("--all") // find all merge bases
436            .arg(base.as_str())
437            .arg(topic.as_str())
438            .output()
439            .map_err(|err| GitError::subcommand("merge-base", err))?;
440        if !merge_base.status.success() {
441            return Ok(MergeStatus::NoCommonHistory);
442        }
443        let bases = String::from_utf8_lossy(&merge_base.stdout);
444        let bases = bases
445            .split_whitespace()
446            .map(CommitId::new)
447            .collect::<Vec<_>>();
448
449        Ok(if Some(topic) == bases.first() {
450            MergeStatus::AlreadyMerged
451        } else {
452            MergeStatus::Mergeable(bases)
453        })
454    }
455
456    /// The path to the git repository.
457    pub fn gitdir(&self) -> &Path {
458        &self.gitdir
459    }
460}