codex_git/
lib.rs

1/*! codex-git is a simplified wrapper for [git2]. Most of the code is in this file. */
2
3//#![feature(backtrace)]
4use ansi_term::Colour::*;
5use anyhow::anyhow;
6use anyhow::Context;
7use getset::{CopyGetters, Getters, Setters};
8use git2::IndexAddOption;
9use git2::{
10    build::RepoBuilder, Commit, Cred, CredentialType, Direction, FetchOptions, Index, ObjectType,
11    Oid, PushOptions, RemoteCallbacks, Repository, Signature, Tree,
12};
13use git2_credentials::CredentialHandler;
14use log::{error, trace};
15use serde::Deserialize;
16use std::fmt;
17use std::path::PathBuf;
18use thiserror::Error;
19mod pull;
20
21#[cfg(test)]
22mod tests;
23/// error for this crate
24#[derive(Error, Debug)]
25pub enum CodexGitError {
26    #[error("git error")]
27    Git {
28        #[from]
29        source: git2::Error,
30        //  backtrace: Backtrace,
31    },
32    #[error("IO error")]
33    IO(#[from] std::io::Error),
34    #[error("RON error")]
35    Ron(#[from] ron::Error),
36    #[error(transparent)]
37    Other(#[from] anyhow::Error),
38    #[error("codex git error")]
39    CodexGit,
40    #[error("utf8 error")]
41    Utf8Error(std::str::Utf8Error),
42}
43/// results for this crate
44pub type Result<T> = std::result::Result<T, CodexGitError>;
45/// None or error
46pub type NullResult = Result<()>;
47
48/// tracing macro
49macro_rules! git_trace {
50    () => {  };
51    ($($arg:tt)*) => {
52        trace!("{} ({}:{})", Black.on(Cyan).paint(format!($($arg)*)), std::file!(), std::line!());
53    };
54}
55
56/** An `SshKeys` stores the SSH keys for the remote repository. */
57#[derive(Debug, Default, Clone, Setters, Deserialize)]
58#[getset(set = "pub")]
59pub struct SshKeys {
60    /// public key
61    public: String,
62    /// private key
63    private: String,
64}
65//impl SshKeys {}
66/** `User` is a git user (user name and email)*/
67#[derive(Debug, Default, Clone, Deserialize)]
68pub struct User {
69    name: String,
70    email: String,
71}
72impl User {
73    pub fn new(name: &str, email: &str) -> Self {
74        Self {
75            name: name.to_string(),
76            email: email.to_string(),
77        }
78    }
79}
80
81/** A `CodexRepoConfig` is the parameters for making a [CodexRepository].  */
82#[derive(Clone, Setters, Default, Deserialize, Debug)]
83pub struct CodexRepoConfig {
84    /// user name and email for Git commits
85    #[getset(set = "pub")]
86    user: User,
87    /// URL for the remote repository
88    #[getset(set = "pub")]
89    remote_url: String,
90    /// where to put the files on disk (excluding the repo name)
91    #[getset(set = "pub")]
92    path: PathBuf,
93    /// paths to add automatically
94    #[getset(set = "pub")]
95    #[serde(default, skip)]
96    auto_add: Vec<String>,
97    /// SSH keys for the remote
98    #[getset(set = "pub")]
99    #[serde(default, skip_serializing)]
100    ssh_keys: SshKeys,
101    /// print more messages
102    #[serde(default)]
103    verbose: bool,
104}
105impl CodexRepoConfig {
106    /** `repo_name` is the name of the repository */
107    pub fn repo_name(&self) -> Result<String> {
108        let parts = self.remote_url.split("/");
109        Ok(parts.last().ok_or(CodexGitError::CodexGit)?.to_string())
110    }
111    /** `full_path` is the full path of the head of the repository on disk including the [Self::repo_name()] */
112    pub fn full_path(&self) -> Result<PathBuf> {
113        Ok(PathBuf::from(format!(
114            "{}/{}",
115            self.path.to_string_lossy(),
116            self.repo_name()?
117        )))
118    }
119    /** `has_repository` detects whether a [CodexRepository] exists for this [CodexRepoConfig]. */
120    pub fn has_repository(&self) -> Result<bool> {
121        let repo_head = self.full_path()?;
122        if !repo_head.exists() {
123            git_trace!("repo does not exist {:?}", &repo_head);
124            Ok(false)
125        } else if !repo_head.is_dir() {
126            error!("repo is not dir {:?}", &repo_head);
127            Ok(false)
128        } else {
129            git_trace!("repo dir exists {:?}", &repo_head);
130            Ok(true)
131        }
132    }
133    /** `delete_repo` deletes the repository. */
134    pub fn delete_repo(&self) -> Result<()> {
135        std::fs::remove_dir_all(self.full_path()?)?;
136        Ok(())
137    }
138    /** `clone_repo` creates a [CodexRepository] and clones the repository from the remote (a Git clone, not a Rust clone). */
139    pub fn clone_repo(&mut self) -> Result<CodexRepository> {
140        git_trace!("cloning repo {:?} to {:?}", &self.remote_url, &self.path);
141        let fetch_options = self.fetch_options()?;
142        let repo = RepoBuilder::new()
143            .bare(false)
144            .fetch_options(fetch_options)
145            .clone(&self.remote_url, &self.full_path()?)?;
146        git_trace!("repo cloned");
147        Ok(CodexRepository::new(repo, self))
148    }
149    /** `open` opens an existing [CodexRepository]. */
150    pub fn open(&self) -> Result<CodexRepository> {
151        git_trace!("opening existing repo {:?}", &self.full_path()?);
152        let repo = Repository::open(self.full_path()?)?;
153        // git_trace!("repo opened");
154        Ok(CodexRepository::new(repo, self))
155    }
156    /** `fetch_options` retrieves fetch options */
157    fn fetch_options(&self) -> Result<FetchOptions> {
158        let mut fo = FetchOptions::new();
159        fo.remote_callbacks(self.callbacks()?);
160        Ok(fo)
161    }
162    /** `callbacks` sets callbacks for calls to git2 that use SSH */
163    fn callbacks(&self) -> Result<RemoteCallbacks> {
164        let mut cb = RemoteCallbacks::new();
165        let git_config = git2::Config::open_default()?;
166        let mut ch = CredentialHandler::new(git_config);
167        let mut try_count: i8 = 0;
168        const MAX_TRIES: i8 = 5;
169        cb.credentials(move |url, username, allowed| {
170            if allowed.contains(CredentialType::SSH_MEMORY) {
171                git_trace!("trying ssh memory credential");
172                let username = username.expect("no user name");
173                //                git_trace!("user name is {}, using key option", &username);
174                let cred_res = Cred::ssh_key_from_memory(
175                    username,
176                    Some(&self.ssh_keys.public),
177                    &self.ssh_keys.private,
178                    None,
179                );
180                git_trace!("try to find ssh memory credential");
181                match &cred_res {
182                    Err(e) => {
183                        error!("error found in credential from memory {:?}", e);
184                    }
185                    Ok(_cr) => {
186                    }
187                }
188                return cred_res;
189            }
190            git_trace!("look for credential {:?} ({} tries)", allowed, try_count);
191            try_count += 1;
192            if try_count > MAX_TRIES {
193                error!("too many tries for ssh key");
194                std::panic::panic_any("too many ssh tries".to_string());
195            }
196            ch.try_next_credential(url, username, allowed)
197        });
198
199        // Print out our transfer progress.
200        if self.verbose {
201            cb.transfer_progress(|stats| {
202                if stats.received_objects() == stats.total_objects() {
203                    git_trace!(
204                        "Resolving deltas {}/{} ",
205                        stats.indexed_deltas(),
206                        stats.total_deltas()
207                    );
208                } else if stats.total_objects() > 0 {
209                    git_trace!(
210                        "Received {}/{} objects ({}) in {} bytes ",
211                        stats.received_objects(),
212                        stats.total_objects(),
213                        stats.indexed_objects(),
214                        stats.received_bytes()
215                    );
216                }
217                true
218            });
219            cb.sideband_progress(|msg| {
220                if msg.len() == 0 {
221                    return true;
222                }
223                git_trace!(
224                    "git: {}",
225                    std::str::from_utf8(msg).unwrap_or_else(|err| {
226                        error!("bad git utf8 message {:?}", &err);
227                        "bad msg"
228                    })
229                );
230                true
231            });
232        }
233        Ok(cb)
234    }
235}
236/** An `FetchStatus` describes the result of a fetch */
237#[derive(Default, Getters, CopyGetters)]
238pub struct FetchStatus {
239    #[getset(get_copy = "pub")]
240    is_changed: bool,
241    #[getset(get = "pub")]
242    index: Option<Index>,
243}
244impl FetchStatus {
245    /** `has_conflict`  */
246    pub fn has_conflict(&self) -> bool {
247        if let Some(i) = &self.index {
248            i.has_conflicts()
249        } else {
250            false
251        }
252    }
253}
254impl fmt::Display for FetchStatus {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        write!(f, "status (")?;
257        if let Some(i) = &self.index {
258            write!(f, "{} entries", i.len())?;
259            if self.has_conflict() {
260                write!(
261                    f,
262                    " {} conflicts",
263                    i.conflicts().expect("bad conflicts").count()
264                )?;
265            }
266        }
267        write!(f, ")")
268    }
269}
270impl fmt::Debug for FetchStatus {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        let ix_sz = if let Some(i) = &self.index {
273            i.len()
274        } else {
275            0
276        };
277        let mut cc = Vec::<String>::new();
278        let confict_msg = if self.has_conflict() {
279            if let Some(ix) = &self.index {
280                for conflict in ix.conflicts().expect("bad conflicts") {
281                    if let Ok(c) = conflict {
282                        let p = if let Some(our) = c.our {
283                            std::str::from_utf8(&our.path).expect("bad utf").to_string()
284                        } else {
285                            "?".to_string()
286                        };
287                        cc.push(p);
288                    }
289                }
290            }
291            format!("conflicts [{}]", cc.join(" "))
292        } else {
293            "".to_string()
294        };
295        write!(
296            f,
297            "{} {} {} changes",
298            &confict_msg,
299            if self.is_changed {
300                "changed"
301            } else {
302                "unchanged"
303            },
304            &ix_sz
305        )?;
306        if f.alternate() {
307            if let Some(ix) = &self.index {
308                write!(f, " [")?;
309                for ie in ix.iter() {
310                    write!(f, "{} ", std::str::from_utf8(&ie.path).expect("bad utf"))?;
311                }
312                write!(f, "]")?;
313            }
314        }
315        Ok(())
316    }
317}
318/** A `CodexRepository` is a Git [Repository] that is managed by this crate. It tracks whether the repository needs to be committed or pushed. */
319pub struct CodexRepository {
320    /// the underlying Git repository
321    repo: Repository,
322    /// configuration options
323    config: CodexRepoConfig,
324    /// repo has uncommitted changes
325    needs_commit: bool,
326    /// repo has unpushed commits
327    needs_push: bool,
328    /// added files
329    added: Vec<String>,
330}
331impl fmt::Display for CodexRepository {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        write!(
334            f,
335            "{} commit, {} push",
336            if self.needs_commit {
337                "needs"
338            } else {
339                "does not need"
340            },
341            if self.needs_push {
342                "needs"
343            } else {
344                "does not need"
345            }
346        )
347    }
348}
349impl Drop for CodexRepository {
350    fn drop(&mut self) {
351        git_trace!("at end (dropping repo), committing and pushing repo if required");
352        self.commit_and_push().unwrap_or_else(|err| {
353            error!("drop error: {:?}", &err);
354            panic!("drop error")
355        });
356        // git_trace!("dropping.");
357    }
358}
359impl fmt::Debug for CodexRepository {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        write!(f, "(CodexRepository)",)
362    }
363}
364impl CodexRepository {
365    /// initialise the repository based on the [CodexRepoConfig].
366    pub fn new(repo: Repository, config: &CodexRepoConfig) -> Self {
367        Self {
368            repo,
369            config: config.clone(),
370            needs_commit: false,
371            needs_push: false,
372            added: vec![],
373        }
374    }
375    /// fetches data from the remote and merges if necessary
376    pub fn fetch(&mut self) -> Result<()> {
377        let remote_name = "origin";
378        let remote_branch = "main";
379        // let repo = Repository::open(".")?;
380        let mut remote = self.repo.find_remote(remote_name)?;
381        let fetch_commit = pull::do_fetch(
382            &self.repo,
383            &[remote_branch],
384            &mut remote,
385            self.config.callbacks()?,
386        )?;
387        pull::do_merge(&self.repo, &remote_branch, fetch_commit)?;
388        Ok(())
389    }
390    /** `commit_and_push` commits changes and pushes them */
391    pub fn commit_and_push(&mut self) -> Result<()> {
392        self.commit().context(format!(
393            "error in commit ({} commit)",
394            if self.needs_commit {
395                "needs"
396            } else {
397                "does not need"
398            }
399        ))?;
400        self.push(false).context(format!(
401            "error in push ({} push)",
402            if self.needs_push {
403                "needs"
404            } else {
405                "does not need"
406            }
407        ))?;
408        Ok(())
409    }
410    /** `commit` commits any changes to the local repository. */
411    pub fn commit(&mut self) -> NullResult {
412        if !self.needs_commit {
413            git_trace!("no changes, do not need commit");
414            return Ok(());
415        }
416        // TODO does this help?
417        git_trace!("adding all from: {:?}", self.config.auto_add);
418        let mut index = self.repo.index().context("cannot get the Index file")?;
419        let mut paths = vec![];
420        index.add_all(
421            self.config.auto_add.iter(),
422            IndexAddOption::DEFAULT,
423            Some(&mut |path, spec| {
424                paths.push(format!("{:?}", &path));
425                git_trace!(
426                    "adding for commit {:?} for {}",
427                    &path,
428                    std::str::from_utf8(spec).unwrap()
429                );
430                0
431            }),
432        )?;
433        index.write().context("writing index for commit")?;
434        //        git_trace!("committing");
435        {
436            let tree = self.repo.find_tree(self.repo.index()?.write_tree()?)?;
437            let our_commit = self.our_commit()?;
438            let _oid = self.write_commit(
439                tree,
440                &format!(
441                    "commit changes {} {}",
442                    paths.join(" "),
443                    self.added.join(" ")
444                ),
445                &[&our_commit],
446            )?;
447        }
448        self.added.clear();
449        self.needs_commit = false;
450        self.needs_push = true;
451        //  git_trace!("committed");
452        Ok(())
453    }
454    /** `write_commit` writes out a commit */
455    fn write_commit(
456        &self,
457        new_tree: Tree<'_>,
458        message: &str,
459        parent_commits: &[&Commit<'_>],
460    ) -> Result<Oid> {
461        let update_ref = if parent_commits.len() > 0 {
462            Some("HEAD")
463        } else {
464            None
465        };
466        let user = Signature::now(&self.config.user.name, &self.config.user.email)?;
467        let commit_oid = self.repo.commit(
468            update_ref,     //  point HEAD to our new commit
469            &user,          // author
470            &user,          // committer
471            message,        // commit message
472            &new_tree,      // tree
473            parent_commits, // parents
474        )?;
475        Ok(commit_oid)
476    }
477    /** latest local commit for fetch */
478    fn our_commit(&self) -> Result<Commit<'_>> {
479        Ok(self.last_commit()?.ok_or_else(|| (anyhow!("no commit")))?)
480    }
481    /** `last_commit` finds the most recent commit or None */
482    fn last_commit(&self) -> Result<Option<Commit>> {
483        let head = self.repo.head()?.resolve()?.peel(ObjectType::Commit)?;
484        Ok(Some(
485            head.into_commit().map_err(|_e| anyhow!("not a commit"))?,
486        ))
487    }
488    /** `add` adds a file to the index */
489    pub fn add(&mut self, path: PathBuf) -> NullResult {
490        git_trace!("adding {:?}", &path);
491        self.repo.index()?.add_path(&path)?;
492        self.needs_commit = true;
493        self.added.push(path.to_string_lossy().to_string());
494        Ok(())
495    }
496    /** `push` tries to push any local changes to the remote. */
497    pub fn push(&mut self, force: bool) -> NullResult {
498        if !self.needs_push {
499            git_trace!("no commits, do not need push");
500            return Ok(());
501        }
502        git_trace!("pushing to remote");
503        let mut remote = self.repo.find_remote("origin")?;
504        let cb = self.config.callbacks()?;
505        remote.connect_auth(Direction::Push, Some(cb), None)?;
506        let mut push_options = PushOptions::new();
507        let cb = self.config.callbacks()?;
508        push_options.remote_callbacks(cb);
509        let force_marker = if force { "+" } else { "" };
510        let refspec = format!(
511            "{}refs/heads/{}:refs/heads/{}",
512            force_marker, "main", "main"
513        );
514        remote.push(&[refspec.as_str()], Some(&mut push_options))?;
515        self.needs_push = false;
516        git_trace!("pushed");
517        Ok(())
518    }
519}
520/* This Source Code Form is subject to the terms of the Mozilla Public
521 * License, v. 2.0. If a copy of the MPL was not distributed with this
522 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */