jujube_lib/
git.rs

1// Copyright 2020 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::commit::Commit;
16use crate::store::CommitId;
17use crate::transaction::Transaction;
18use thiserror::Error;
19
20#[derive(Error, Debug, PartialEq)]
21pub enum GitImportError {
22    #[error("The repo is not backed by a git repo")]
23    NotAGitRepo,
24    #[error("Unexpected git error when importing refs: {0}")]
25    InternalGitError(#[from] git2::Error),
26}
27
28// Reflect changes made in the underlying Git repo in the Jujube repo.
29pub fn import_refs(tx: &mut Transaction) -> Result<(), GitImportError> {
30    let store = tx.store().clone();
31    let git_repo = store.git_repo().ok_or(GitImportError::NotAGitRepo)?;
32    let git_refs = git_repo.references()?;
33    for git_ref in git_refs {
34        let git_ref = git_ref?;
35        if !(git_ref.is_tag() || git_ref.is_branch() || git_ref.is_remote()) {
36            // Skip other refs (such as notes) and symbolic refs.
37            // TODO: Is it useful to import HEAD (especially if it's detached)?
38            continue;
39        }
40        let git_commit = git_ref.peel_to_commit()?;
41        let id = CommitId(git_commit.id().as_bytes().to_vec());
42        let commit = store.get_commit(&id).unwrap();
43        tx.add_head(&commit);
44    }
45    Ok(())
46}
47
48#[derive(Error, Debug, PartialEq)]
49pub enum GitFetchError {
50    #[error("The repo is not backed by a git repo")]
51    NotAGitRepo,
52    #[error("No git remote named '{0}'")]
53    NoSuchRemote(String),
54    // TODO: I'm sure there are other errors possible, such as transport-level errors.
55    #[error("Unexpected git error when fetching: {0}")]
56    InternalGitError(#[from] git2::Error),
57}
58
59pub fn fetch(tx: &mut Transaction, remote_name: &str) -> Result<(), GitFetchError> {
60    let git_repo = tx.store().git_repo().ok_or(GitFetchError::NotAGitRepo)?;
61    let mut remote =
62        git_repo
63            .find_remote(remote_name)
64            .map_err(|err| match (err.class(), err.code()) {
65                (git2::ErrorClass::Config, git2::ErrorCode::NotFound) => {
66                    GitFetchError::NoSuchRemote(remote_name.to_string())
67                }
68                (git2::ErrorClass::Config, git2::ErrorCode::InvalidSpec) => {
69                    GitFetchError::NoSuchRemote(remote_name.to_string())
70                }
71                _ => GitFetchError::InternalGitError(err),
72            })?;
73    let refspec: &[&str] = &[];
74    remote.fetch(refspec, None, None)?;
75    import_refs(tx).map_err(|err| match err {
76        GitImportError::NotAGitRepo => panic!("git repo somehow became a non-git repo"),
77        GitImportError::InternalGitError(source) => GitFetchError::InternalGitError(source),
78    })?;
79    Ok(())
80}
81
82#[derive(Error, Debug, PartialEq)]
83pub enum GitPushError {
84    #[error("The repo is not backed by a git repo")]
85    NotAGitRepo,
86    #[error("No git remote named '{0}'")]
87    NoSuchRemote(String),
88    #[error("Push is not fast-forwardable'")]
89    NotFastForward,
90    #[error("Remote reject the update'")]
91    RefUpdateRejected,
92    // TODO: I'm sure there are other errors possible, such as transport-level errors,
93    // and errors caused by the remote rejecting the push.
94    #[error("Unexpected git error when pushing: {0}")]
95    InternalGitError(#[from] git2::Error),
96}
97
98pub fn push_commit(
99    commit: &Commit,
100    remote_name: &str,
101    remote_branch: &str,
102) -> Result<(), GitPushError> {
103    let git_repo = commit.store().git_repo().ok_or(GitPushError::NotAGitRepo)?;
104    // Create a temporary ref to work around https://github.com/libgit2/libgit2/issues/3178
105    let temp_ref_name = format!("refs/jj/git-push/{}", commit.id().hex());
106    let mut temp_ref = git_repo.reference(
107        &temp_ref_name,
108        git2::Oid::from_bytes(&commit.id().0).unwrap(),
109        true,
110        "temporary reference for git push",
111    )?;
112    let mut remote =
113        git_repo
114            .find_remote(remote_name)
115            .map_err(|err| match (err.class(), err.code()) {
116                (git2::ErrorClass::Config, git2::ErrorCode::NotFound) => {
117                    GitPushError::NoSuchRemote(remote_name.to_string())
118                }
119                (git2::ErrorClass::Config, git2::ErrorCode::InvalidSpec) => {
120                    GitPushError::NoSuchRemote(remote_name.to_string())
121                }
122                _ => GitPushError::InternalGitError(err),
123            })?;
124    // Need to add "refs/heads/" prefix due to https://github.com/libgit2/libgit2/issues/1125
125    let qualified_remote_branch = format!("refs/heads/{}", remote_branch);
126    let mut callbacks = git2::RemoteCallbacks::new();
127    let mut updated = false;
128    callbacks.credentials(|_url, username_from_url, _allowed_types| {
129        git2::Cred::ssh_key_from_agent(username_from_url.unwrap())
130    });
131    callbacks.push_update_reference(|refname, status| {
132        if refname == qualified_remote_branch && status.is_none() {
133            updated = true;
134        }
135        Ok(())
136    });
137    let refspec = format!("{}:{}", temp_ref_name, qualified_remote_branch);
138    let mut push_options = git2::PushOptions::new();
139    push_options.remote_callbacks(callbacks);
140    remote
141        .push(&[refspec], Some(&mut push_options))
142        .map_err(|err| match (err.class(), err.code()) {
143            (git2::ErrorClass::Reference, git2::ErrorCode::NotFastForward) => {
144                GitPushError::NotFastForward
145            }
146            _ => GitPushError::InternalGitError(err),
147        })?;
148    drop(push_options);
149    temp_ref.delete()?;
150    if updated {
151        Ok(())
152    } else {
153        Err(GitPushError::RefUpdateRejected)
154    }
155}