summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
authorSoniEx2 <endermoneymod@gmail.com>2021-06-14 22:49:07 -0300
committerSoniEx2 <endermoneymod@gmail.com>2021-06-14 22:58:05 -0300
commita67b0812659d1481f4c5be77ce2cf448b0b37b8c (patch)
treef0ce4f55d5af77b2a76a98d77a8ddfca60a61bc3 /src
parent993a8ca85db1564e64550276d61d9723423605aa (diff)
Start rewriting in Rust
Diffstat (limited to 'src')
-rw-r--r--src/git.rs728
-rw-r--r--src/git/tests.rs389
-rw-r--r--src/lib.rs23
-rw-r--r--src/marker.rs23
-rw-r--r--src/test_util.rs19
-rw-r--r--src/util.rs108
6 files changed, 1290 insertions, 0 deletions
diff --git a/src/git.rs b/src/git.rs
new file mode 100644
index 0000000..df9614a
--- /dev/null
+++ b/src/git.rs
@@ -0,0 +1,728 @@
+// This file is part of GAnarchy - decentralized development hub
+// Copyright (C) 2021  Soni L.
+// 
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+// 
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+// 
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+//! This module provides some abstractions over git.
+//!
+//! Having this module allows easily(-ish) replacing the git backend, for
+//! example from calling the git CLI directly to using a git library.
+
+use std::collections::BTreeSet;
+use std::error;
+use std::ffi::{OsStr, OsString};
+use std::fmt;
+use std::fs;
+use std::io;
+use std::path::Path;
+use std::path::PathBuf;
+//use std::process;
+use std::process::{Command, Output};
+
+use impl_trait::impl_trait;
+
+use crate::util::NamePurpose;
+use crate::marker::Initializer;
+
+#[cfg(test)]
+mod tests;
+
+/// Represents a local git repo.
+pub struct Git {
+    path: PathBuf,
+    pending_branches: Option<BTreeSet<String>>,
+    sha256: bool,
+}
+
+/// Error returned by operations on a git repo.
+#[derive(Debug)]
+pub struct GitError {
+    inner: GitErrorInner,
+    command: Vec<OsString>,
+}
+
+#[derive(Debug)]
+enum GitErrorInner {
+    IoError(io::Error),
+    Output(Output),
+}
+
+/// Helper for tracking args to a Command.
+struct Args {
+    inner: Command,
+    args: Vec<OsString>,
+}
+
+impl_trait! {
+    impl Args {
+        /// Creates a new Args for the given command.
+        pub fn new_cmd<S: AsRef<OsStr>>(cmd: S) -> Self {
+            let cmd = cmd.as_ref();
+            Self {
+                inner: Command::new(cmd),
+                args: vec![cmd.into()],
+            }
+        }
+
+        /// Adds a single arg to the Command.
+        pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
+            let arg = arg.as_ref();
+            self.inner.arg(arg);
+            self.args.push(arg.into());
+            self
+        }
+
+        // /// Adds multiple args to the Command.
+        // pub fn args<I, S>(&mut self, args: I) -> &mut Self
+        // where I: IntoIterator<Item=S>, S: AsRef<OsStr> {
+        //     for arg in args {
+        //         self.arg(arg);
+        //     }
+        //     self
+        // }
+
+        // impl trait Into<Result<Output, GitError>> {
+        //     fn into(self) -> Result<Output, GitError> {
+        //         todo!()
+        //     }
+        // }
+    }
+}
+
+/// RAII transaction guard for merging forked repos in with_work_repos.
+struct Merger<'a>(&'a mut Git, Vec<Git>);
+
+impl From<io::Error> for GitErrorInner {
+    fn from(e: io::Error) -> Self {
+        Self::IoError(e)
+    }
+}
+
+impl From<Output> for GitErrorInner {
+    fn from(e: Output) -> Self {
+        Self::Output(e)
+    }
+}
+
+impl_trait! {
+    impl GitError {
+        /// Creates a new GitError for the given command.
+        fn new(inner: impl Into<GitErrorInner>, cmd: Vec<OsString>) -> Self {
+            Self {
+                inner: inner.into(),
+                command: cmd,
+            }
+        }
+
+        impl trait fmt::Display {
+            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+                write!(f, "Error running")?;
+                for part in &self.command {
+                    let part = part.to_str().unwrap_or("[not UTF-8]");
+                    write!(f, " {}", part)?;
+                }
+                match &self.inner {
+                    GitErrorInner::IoError(e) => {
+                        write!(f, ", caused by: {}", e)
+                    },
+                    GitErrorInner::Output(e) => {
+                        let out = std::str::from_utf8(&e.stdout);
+                        let out = out.unwrap_or("[not UTF-8]");
+                        let err = std::str::from_utf8(&e.stderr);
+                        let err = err.unwrap_or("[not UTF-8]");
+                        write!(f, "\nstdout:\n{}", out)?;
+                        write!(f, "\nstderr:\n{}", err)
+                    },
+                }
+            }
+        }
+
+        impl trait error::Error {
+            fn source(&self) -> Option<&(dyn error::Error + 'static)> {
+                match self.inner {
+                    GitErrorInner::IoError(ref error) => {
+                        Some(error)
+                    }
+                    GitErrorInner::Output(_) => None,
+                }
+            }
+        }
+    }
+}
+
+impl_trait! {
+    impl<'a> Merger<'a> {
+        /// Returns a shared, immutable reference to the main repo.
+        fn main(&self) -> &Git {
+            &*self.0
+        }
+
+        /// Merges the work repos back into the main repo.
+        /// 
+        /// # Panics
+        ///
+        /// Panics if there are branches in conflict.
+        fn merge(mut self) -> Result<(), GitError> {
+            // check for conflicts first!
+            let mut branches = BTreeSet::<&String>::new();
+            for work in &*self {
+                for branch in work.pending_branches.as_ref().unwrap() {
+                    if !branches.insert(branch) {
+                        panic!("Branch {} is in conflict!", branch);
+                    }
+                }
+            }
+            drop(branches);
+
+            for mut repo in std::mem::take(&mut self.1) {
+                // TODO clean up
+                let repo_id = repo.path.file_name().unwrap().to_str().unwrap()
+                    .strip_prefix("ganarchy-fetch-").unwrap()
+                    .strip_suffix(".git").unwrap()
+                    .to_owned();
+                let pending = repo.pending_branches.take().unwrap();
+                for branch in pending {
+                    let len = branch.len();
+                    let fetch_head = branch + "-" + &repo_id;
+                    let branch = &fetch_head[..len];
+                    // First collect the work branch into a fetch head
+                    self.0.fetch_work(&repo, &fetch_head, branch)?;
+                    // If that succeeds, delete the work branch to free up disk
+                    repo.rm_branch(branch)?;
+                    // We have all the objects in the main repo and we probably
+                    // have enough disk, so just replace the fetch head into
+                    // the main branch and hope nothing errors.
+                    self.0.replace(&fetch_head, branch)?;
+                }
+                repo.delete()?;
+            }
+            Ok(())
+        }
+
+        /// Accesses the work repos.
+        impl trait std::ops::Deref {
+            type Target = Vec<Git>;
+
+            fn deref(&self) -> &Vec<Git> {
+                &self.1
+            }
+        }
+
+        /// Accesses the work repos.
+        impl trait std::ops::DerefMut {
+            fn deref_mut(&mut self) -> &mut Vec<Git> {
+                &mut self.1
+            }
+        }
+
+        /// Cleans up (deletes) the work repos, if not panicking.
+        impl trait Drop {
+            fn drop(&mut self) {
+                if !std::thread::panicking() {
+                    for repo in std::mem::take(&mut self.1) {
+                        repo.delete().unwrap();
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// Initializer operations on the `Git` struct.
+impl Git {
+    /// Creates a new instance of the `Git` struct, with the path as given.
+    pub fn at_path<T: AsRef<Path>>(_: Initializer, path: T) -> Option<Git> {
+        let path = path.as_ref();
+        let filename = path.file_name()?.to_str()?;
+        // TODO SHA-2
+        NamePurpose::CacheRepo.is_fit(filename).then(|| Git {
+            path: path.into(),
+            pending_branches: None,
+            sha256: false,
+        })
+    }
+}
+
+/// Operations on a git repo.
+///
+/// # Race conditions
+///
+/// These operate on the filesystem. Calling them from multiple threads
+/// can result in data corruption.
+impl Git {
+    /// Creates the given number of work repos, and calls the closure to run
+    /// operations on them.
+    ///
+    /// The operations can be done on the individual repos, and they'll be
+    /// merged into the main repo as this function returns.
+    ///
+    /// If the callback fails, the work repos will be deleted. If the function
+    /// succeeds, the work repos will be merged back into the main repo.
+    ///
+    /// # Panics
+    ///
+    /// Panics if a merge conflict is detected. Specifically, if two work repos
+    /// modify the same work branch. Also panics if this isn't a cache repo.
+    ///
+    /// # "Poisoning"
+    ///
+    /// If this method unwinds, the underlying git repos, if any, will not be
+    /// deleted. Instead, future calls to this method will return a GitError.
+    pub fn with_work_repos<F, R>(&mut self, count: usize, f: F)
+        -> Result<R, GitError>
+    where F: FnOnce(&mut [Git]) -> Result<R, GitError> {
+        assert!(self.is_cache_repo());
+        // create some Git structs
+        let mut work_repos = Vec::with_capacity(count);
+        for id in 0..count {
+            let mut new_path = self.path.clone();
+            new_path.set_file_name(format!("ganarchy-fetch-{}.git", id));
+            let git = Git {
+                path: new_path,
+                pending_branches: Some(Default::default()),
+                sha256: self.sha256,
+            };
+            assert!(git.is_work_repo());
+            work_repos.push(git);
+        }
+        // create the on-disk stuff
+        let merger = Merger(self, Vec::new());
+        let mut merger = work_repos.into_iter()
+            .try_fold(merger, |mut m, mut r| {
+                m.main().fork(&mut r)?;
+                m.push(r);
+                Ok(m)
+            })?;
+        let result = f(&mut *merger)?;
+        // merge the on-disk stuff
+        merger.merge().and(Ok(result))
+    }
+
+    /// Fetches branch `from_ref` from source `from` into branch `branch`.
+    ///
+    /// The fetch used is a force-fetch.
+    ///
+    /// # Panics
+    ///
+    /// Panics if called on a non-work repo, if `from` starts with `-`, if
+    /// `branch` isn't a cache branch, or if `from_ref` starts with `-`.
+    pub fn fetch_source(&mut self, from: &str, branch: &str, from_ref: &str)
+        -> Result<(), GitError>
+    {
+        assert!(self.is_work_repo());
+        assert!(!from.starts_with("-"));
+        assert!(!from_ref.starts_with("-"));
+        assert!(NamePurpose::WorkBranch.is_fit(branch));
+        let _output = self.cmd(|args| {
+            args.arg("fetch");
+            args.arg(from);
+            args.arg(format!("+{}:{}", from_ref, branch));
+        })?;
+        self.pending_branches.as_mut().unwrap().insert(branch.into());
+        Ok(())
+    }
+
+    /// Initializes this repo.
+    ///
+    /// # Panics
+    ///
+    /// Panics if called on a non-cache repo.
+    pub fn ensure_exists(&mut self) -> Result<(), GitError> {
+        assert!(self.is_cache_repo());
+        let _output = self.cmd_init(|_| {})?;
+        Ok(())
+    }
+
+    /// Checks if a given commit is present in the given branch's history.
+    ///
+    /// # Panics
+    ///
+    /// Panics if this isn't a cache branch on a cache repo or if commit isn't
+    /// a commit.
+    pub fn check_history(&self, branch: &str, commit: &str)
+        -> Result<(), GitError>
+    {
+        assert!(self.is_cache_repo());
+        assert!(NamePurpose::CacheBranch.is_fit(branch));
+        assert!(self.is_commit_hash(commit));
+        let _output = self.cmd(|args| {
+            args.arg("merge-base");
+            args.arg("--is-ancestor");
+            args.arg(commit);
+            args.arg(format!("refs/heads/{}", branch));
+        })?;
+        Ok(())
+    }
+
+    /// Checks if the given branch is a valid branch.
+    ///
+    /// Note: "HEAD" is **not** a branch.
+    ///
+    /// # Panics
+    ///
+    /// Panics if `branch` starts with `-`.
+    pub fn check_branch(&self, branch: &str) -> Result<(), GitError> {
+        assert!(!branch.starts_with("-"));
+        let mut output = self.cmd(|args| {
+            args.arg("check-ref-format");
+            args.arg("--branch");
+            args.arg(branch);
+        })?;
+        // perf: Vec::default doesn't allocate.
+        let stdout = std::mem::take(&mut output.stdout);
+        let stdout = String::from_utf8(stdout);
+        match stdout.as_ref().map(|x| x.strip_prefix(branch)) {
+            Ok(Some("")) | Ok(Some("\n")) | Ok(Some("\r\n")) => {
+                return Ok(())
+            },
+            _ => (),
+        }
+        output.stdout = match stdout {
+            Ok(e) => e.into_bytes(),
+            Err(e) => e.into_bytes(),
+        };
+        let v = vec![
+            OsString::from("git"),
+            "check-ref-format".into(),
+            "--branch".into(),
+            branch.into(),
+        ];
+        Err(GitError::new(output, v))
+    }
+
+    /// Returns the number of commits removed and the number of added between
+    /// from and to, respectively.
+    ///
+    /// # Panics
+    ///
+    /// Panics if called on a non-work repo.
+    pub fn get_counts(&self, from: &str, to: &str)
+        -> Result<(u64, u64), GitError>
+    {
+        // if called on a cache repo, `from` may no longer exist.
+        // this check makes sure `from` has not been garbage-collected.
+        assert!(self.is_work_repo());
+        assert!(self.is_commit_hash(from));
+        assert!(self.is_commit_hash(to));
+        let mut output = self.cmd(|args| {
+            args.arg("rev-list");
+            args.arg("--left-right");
+            args.arg("--count");
+            args.arg(format!("{}...{}", from, to));
+            args.arg("--");
+        })?;
+        // perf: Vec::default doesn't allocate.
+        let stdout = std::mem::take(&mut output.stdout);
+        let stdout = String::from_utf8(stdout);
+        match stdout.as_ref().ok().map(|x| x.trim()).filter(|x| {
+            x.trim_start_matches(|x| {
+                char::is_ascii_digit(&x)
+            }).trim_end_matches(|x| {
+                char::is_ascii_digit(&x)
+            }) == "\t"
+        }).and_then(|x| {
+            let (y, z) = x.split_once("\t")?;
+            Some((y.parse::<u64>().ok()?, z.parse::<u64>().ok()?))
+        }) {
+            Some(v) => return Ok(v),
+            None => (),
+        }
+        output.stdout = match stdout {
+            Ok(e) => e.into_bytes(),
+            Err(e) => e.into_bytes(),
+        };
+        let v = vec![
+            OsString::from("git"),
+            "rev-list".into(),
+            "--left-right".into(),
+            "--count".into(),
+            format!("{}...{}", from, to).into(),
+            "--".into(),
+        ];
+        Err(GitError::new(output, v))
+    }
+
+    /// Returns the commit hash at the given target.
+    ///
+    /// # Panics
+    ///
+    /// Panics if `target` starts with `-`.
+    pub fn get_hash(&self, target: &str)
+        -> Result<String, GitError>
+    {
+        assert!(!target.starts_with("-"));
+        let mut output = self.cmd(|args| {
+            args.arg("show");
+            args.arg(target);
+            args.arg("-s");
+            args.arg("--format=format:%H");
+            args.arg("--");
+        })?;
+        // perf: Vec::default doesn't allocate.
+        let stdout = std::mem::take(&mut output.stdout);
+        let stdout = String::from_utf8(stdout);
+        output.stdout = match stdout {
+            Ok(mut h) if self.is_commit_hash(h.trim()) => {
+                h.truncate(h.trim().len());
+                return Ok(h)
+            },
+            Ok(e) => e.into_bytes(),
+            Err(e) => e.into_bytes(),
+        };
+        let v = vec![
+            OsString::from("git"),
+            "show".into(),
+            target.into(),
+            "-s".into(),
+            "--format=format:%H".into(),
+            "--".into(),
+        ];
+        Err(GitError::new(output, v))
+    }
+
+    /// Returns the commit message for the given target.
+    ///
+    /// # Panics
+    ///
+    /// Panics if `target` starts with `-`.
+    pub fn get_message(&self, target: &str)
+        -> Result<String, GitError>
+    {
+        assert!(!target.starts_with("-"));
+        let mut output = self.cmd(|args| {
+            args.arg("show");
+            args.arg(target);
+            args.arg("-s");
+            args.arg("--format=format:%B");
+            args.arg("--");
+        })?;
+        // perf: Vec::default doesn't allocate.
+        let stdout = std::mem::take(&mut output.stdout);
+        let stdout = String::from_utf8(stdout);
+        output.stdout = match stdout {
+            Ok(e) => return Ok(e),
+            Err(e) => e.into_bytes(),
+        };
+        let v = vec![
+            OsString::from("git"),
+            "show".into(),
+            target.into(),
+            "-s".into(),
+            "--format=format:%B".into(),
+            "--".into(),
+        ];
+        Err(GitError::new(output, v))
+    }
+}
+
+/// Private operations on a git repo.
+impl Git {
+    /// Fetches branch `from_branch` from work repo `from` into branch `branch`.
+    ///
+    /// The fetch used is a force-fetch.
+    ///
+    /// # Panics
+    ///
+    /// Panics if this isn't a cache repo, if `from` isn't a work repo, if
+    /// `branch` isn't a fetch head or if `from_branch` isn't a cache branch.
+    fn fetch_work(&mut self, from: &Git, branch: &str, from_branch: &str)
+        -> Result<(), GitError>
+    {
+        assert_eq!(self.sha256, from.sha256);
+        assert!(self.is_cache_repo());
+        assert!(from.is_work_repo());
+        assert!(NamePurpose::CacheBranch.is_fit(from_branch));
+        assert!(NamePurpose::CacheFetchHead.is_fit(branch));
+        let _output = self.cmd(|args| {
+            args.arg("fetch");
+            args.arg(&from.path);
+            args.arg(format!("+{}:{}", from_branch, branch));
+        })?;
+        Ok(())
+    }
+
+    /// Replaces branch `new_name` with branch `old_name`.
+    ///
+    /// # Panics
+    ///
+    /// Panics if this isn't a cache repo, if `old_name` isn't a fetch head,
+    /// or if `new_name` isn't a cache branch.
+    fn replace(&mut self, old_name: &str, new_name: &str)
+        -> Result<(), GitError>
+    {
+        assert!(self.is_cache_repo());
+        assert!(NamePurpose::CacheBranch.is_fit(new_name));
+        assert!(NamePurpose::CacheFetchHead.is_fit(old_name));
+        let _output = self.cmd(|args| {
+            args.arg("branch");
+            args.arg("-M");
+            args.arg(old_name).arg(new_name);
+        })?;
+        Ok(())
+    }
+
+    /// Deletes work branch `branch`.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the branch isn't a work branch or if this isn't a work
+    /// repo.
+    fn rm_branch(&mut self, branch: &str) -> Result<(), GitError> {
+        assert!(self.is_work_repo());
+        assert!(NamePurpose::WorkBranch.is_fit(branch));
+        let _output = self.cmd(|args| {
+            args.arg("branch");
+            args.arg("-D").arg(branch);
+        })?;
+        Ok(())
+    }
+
+    /// Makes a shared clone of this lcoal repo into the given work repo.
+    ///
+    /// Equivalent to `git clone --bare --shared`, which is very dangerous!
+    ///
+    /// # Panics
+    ///
+    /// Panics if this repo isn't a cache repo, and/or if the given repo isn't
+    /// a work repo.
+    fn fork(&self, into: &mut Git) -> Result<(), GitError> {
+        // check that this is a cache repo
+        assert_eq!(self.sha256, into.sha256);
+        assert!(self.is_cache_repo());
+        assert!(into.is_work_repo());
+        let _output = into.cmd_clone_from(&self.path, |args| {
+            args.arg("--shared");
+        })?;
+        Ok(())
+    }
+
+    /// Deletes this repo.
+    ///
+    /// # Panics
+    ///
+    /// Panics if called on a non-work repo.
+    fn delete(self) -> Result<(), GitError> {
+        assert!(self.is_work_repo());
+        fs::remove_dir_all(&self.path).map_err(|e| {
+            let args = vec![
+                "(synthetic)".into(),
+                "rm".into(),
+                "-rf".into(),
+                OsString::from(&self.path)
+            ];
+            GitError::new(e, args)
+        })
+    }
+}
+
+/// Helpers.
+impl Git {
+    /// Returns true if this is a cache repo.
+    fn is_cache_repo(&self) -> bool {
+        let filename = self.path.file_name().unwrap().to_str();
+        if self.sha256 {
+            NamePurpose::CacheRepo64.is_fit(filename.unwrap())
+        } else {
+            NamePurpose::CacheRepo.is_fit(filename.unwrap())
+        }
+    }
+
+    /// Returns true if this is a work repo.
+    fn is_work_repo(&self) -> bool {
+        let filename = self.path.file_name().unwrap().to_str();
+        if self.sha256 {
+            NamePurpose::WorkRepo64.is_fit(filename.unwrap())
+        } else {
+            NamePurpose::WorkRepo.is_fit(filename.unwrap())
+        }
+    }
+
+    /// Returns true if the string is a commit hash.
+    ///
+    /// Does not check if the commit exists.
+    fn is_commit_hash(&self, commit: &str) -> bool {
+        if self.sha256 {
+            NamePurpose::Commit64.is_fit(commit)
+        } else {
+            NamePurpose::Commit.is_fit(commit)
+        }
+    }
+}
+
+/// Raw commands on a git repo.
+impl Git {
+    /// Runs a command for initializing this git repo.
+    ///
+    /// Always uses `--bare`.
+    fn cmd_init(&self, f: impl FnOnce(&mut Args)) -> Result<Output, GitError> {
+        self.cmd_common(|cmd| {
+            cmd.arg("init").arg("--bare");
+            f(&mut *cmd);
+            cmd.arg(&self.path);
+        })
+    }
+
+    /// Runs a command for cloning into this git repo.
+    ///
+    /// Always uses `--bare`.
+    fn cmd_clone_from(
+        &self,
+        from: impl AsRef<OsStr>,
+        f: impl FnOnce(&mut Args)
+    ) -> Result<Output, GitError> {
+        self.cmd_common(|cmd| {
+            cmd.arg("clone").arg("--bare");
+            f(&mut *cmd);
+            cmd.arg(from).arg(&self.path);
+        })
+    }
+
+    /// Runs a command for operating on this git repo.
+    ///
+    /// Note: Doesn't work for git init and git clone operations. Use
+    /// [`cmd_init`] and [`cmd_clone_from`] instead.
+    ///
+    /// Always uses `--bare`.
+    fn cmd(&self, f: impl FnOnce(&mut Args)) -> Result<Output, GitError> {
+        self.cmd_common(|cmd| {
+            cmd.arg("-C").arg(&self.path);
+            cmd.arg("--bare");
+            f(&mut *cmd);
+        })
+    }
+
+    /// Common handling of raw commands.
+    ///
+    /// `"git" + f()` and error handling.
+    fn cmd_common(
+        &self,
+        f: impl FnOnce(&mut Args)
+    ) -> Result<Output, GitError> {
+        let mut cmd = Args::new_cmd("git");
+        f(&mut cmd);
+        // run the command and make nicer Error
+        let Args { inner: mut cmd, args } = cmd;
+        let mut args = Some(args);
+        cmd.output().map_err(|e| {
+            GitError::new(e, args.take().unwrap())
+        }).and_then(|output| {
+            if output.status.success() {
+                Ok(output)
+            } else {
+                Err(GitError::new(output, args.take().unwrap()))
+            }
+        })
+    }
+
+}
diff --git a/src/git/tests.rs b/src/git/tests.rs
new file mode 100644
index 0000000..ef6c763
--- /dev/null
+++ b/src/git/tests.rs
@@ -0,0 +1,389 @@
+// This file is part of GAnarchy - decentralized development hub
+// Copyright (C) 2021  Soni L.
+// 
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+// 
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+// 
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+//! Unit tests for the git module.
+
+use super::*;
+
+use std::panic::AssertUnwindSafe;
+
+use assert_fs::assert::PathAssert;
+use assert_fs::fixture::{TempDir, PathChild};
+use predicates;
+use testserver::serve;
+
+const MAGIC_BRANCH: &'static str = "gan\
+          0000000000000000000000000000000000000000000000000000000000000000";
+
+const MAGIC_COMMIT: &'static str = "9d7224353c34ad675ee5e43fb3115aaaf98832e9";
+
+// Oh yes.
+static REPO: &'static str =
+r#####"!<arch>
+branches.a/     0           0     0     644     8         `
+!<arch>
+config.t/       0           0     0     644     66        `
+[core]
+	repositoryformatversion = 0
+	filemode = true
+	bare = true
+description.t/  0           0     0     644     73        `
+Unnamed repository; edit this file 'description' to name the repository.
+
+HEAD.t/         0           0     0     644     24        `
+ref: refs/heads/default
+hooks.a/        0           0     0     644     8         `
+!<arch>
+info.a/         0           0     0     644     428       `
+!<arch>
+exclude.t/      0           0     0     644     240       `
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
+refs.t/         0           0     0     644     60        `
+9d7224353c34ad675ee5e43fb3115aaaf98832e9	refs/heads/default
+objects.a/      0           0     0     644     916       `
+!<arch>
+4b.a/           0           0     0     644     192       `
+!<arch>
+//                                              42        `
+825dc642cb6eb9a060e54bf8d69288fbee4904.b/
+/0              0           0     0     644     21        `
+eAErKUpNVTBgAAAKLAIB
+
+9d.a/           0           0     0     644     398       `
+!<arch>
+//                                              42        `
+7224353c34ad675ee5e43fb3115aaaf98832e9.b/
+/0              0           0     0     644     227       `
+eAGdjUsKwjAURR1nFW8DlpikIQURHRSngs7EQT6vNtIkJUZod2/BrkC4o3M5HJtC8AUYp5uSEUEY
+xWpnpWDWSDSNppJiLUynnGyYUp1BFA0VRH9KnzJcU/TtxGCP0WEOKeIckjs+g/ZDZVM4wE4yXnMm
+dw1sKaeULHRJFvxLJvdLTi+05QHtpMM4IKyAkFvv37BMR8D1O5+izrafYfxZFfkCj/1MqQ==
+
+info.a/         0           0     0     644     70        `
+!<arch>
+packs.t/        0           0     0     644     1         `
+
+
+pack.a/         0           0     0     644     8         `
+!<arch>
+refs.a/         0           0     0     644     246       `
+!<arch>
+heads.a/        0           0     0     644     110       `
+!<arch>
+default.t/      0           0     0     644     41        `
+9d7224353c34ad675ee5e43fb3115aaaf98832e9
+
+tags.a/         0           0     0     644     8         `
+!<arch>
+"#####;
+
+/// Tests the low-level (raw) commands.
+mod low_level {
+    use super::*;
+
+    /// Tests that cmd_init works.
+    #[test]
+    fn test_cmd_git_init() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.cmd_init(|args| {
+            // templates actually override the -b flag, but at least
+            // this tests that passing args to git init actually works.
+            // also note that the -b flag requires a fairly recent version
+            // of git but ah well. .-.
+            args.arg("-b").arg("hello");
+        }).unwrap();
+        // git init *always* creates HEAD, even for bare repos!
+        // we don't actually care about what HEAD contains tho.
+        repo.child("HEAD").assert(predicates::path::exists());
+    }
+
+    /// Tests that cmd_clone works.
+    #[test]
+    fn test_cmd_git_clone() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.cmd_init(|_| {}).unwrap();
+        // check that a HEAD exists
+        repo.child("HEAD").assert(predicates::path::exists());
+        let clone = dir.child("ganarchy-fetch-0.git");
+        // have to create a Git manually for this one. ah well. :(
+        let git_clone = Git {
+            path: clone.path().into(),
+            pending_branches: None,
+            sha256: false,
+        };
+        git_clone.cmd_clone_from(repo.path(), |args| {
+            // also test if git clone args work.
+            args.arg("--shared");
+        }).unwrap();
+        // git clone should carry over the HEAD
+        let pred = predicates::path::eq_file(repo.child("HEAD").path());
+        clone.child("HEAD").assert(pred);
+    }
+
+    /// Tests that cmd works and that we can get output from it.
+    #[test]
+    fn test_cmd() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let git = Git::at_path(Initializer, repo.path()).unwrap();
+        // we do need to init this because git attempts to cd to it.
+        git.cmd_init(|_| {}).unwrap();
+        let output = git.cmd(|args| {
+            args.arg("check-ref-format");
+            args.arg("--normalize");
+            args.arg("//refs/heads/headache");
+        }).unwrap();
+        assert_eq!(output.stdout, b"refs/heads/headache\n");
+    }
+}
+
+/// Tests private operations.
+mod privates {
+    use super::*;
+
+    /// Tests that fork works.
+    #[test]
+    fn test_fork() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let clone = dir.child("ganarchy-fetch-0.git");
+        // have to create a Git manually for this one. ah well. :(
+        let mut git_clone = Git {
+            path: clone.path().into(),
+            pending_branches: Some(Default::default()),
+            sha256: false,
+        };
+        git.fork(&mut git_clone).unwrap();
+    }
+
+    /// Tests that delete works.
+    #[test]
+    fn test_delete() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let clone = dir.child("ganarchy-fetch-0.git");
+        // have to create a Git manually for this one. ah well. :(
+        let mut git_clone = Git {
+            path: clone.path().into(),
+            pending_branches: Some(Default::default()),
+            sha256: false,
+        };
+        git.fork(&mut git_clone).unwrap();
+        git_clone.delete().unwrap();
+    }
+
+    // TODO rm_branch, replace, fetch_work, etc!
+    // (or we can rely on with_work_repos as an integration test)
+}
+
+/// Tests public operations.
+mod publics {
+    use super::*;
+
+    /// Tests that ensure_exists works.
+    #[test]
+    fn test_ensure_exists() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+    }
+
+    /// Tests that with_work_repos works.
+    #[test]
+    fn test_with_work_repos() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")
+        }).unwrap();
+        git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            // This is basically check_history, but we skip cache repo check.
+            repo.cmd(|args| {
+                args.arg("merge-base");
+                args.arg("--is-ancestor");
+                args.arg(MAGIC_COMMIT);
+                args.arg(format!("refs/heads/{}", MAGIC_BRANCH));
+            })
+        }).unwrap();
+    }
+
+    /// Tests that fetch_source works.
+    #[test]
+    fn test_fetch_source() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")
+        }).unwrap();
+    }
+
+    /// Tests that with_work_repos works properly on failure.
+    #[test]
+    fn test_with_work_repos_failure() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        let addr2 = format!("{}{}", &addr, "nonexistent");
+        let res = git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD").unwrap();
+            repo.fetch_source(&addr2, MAGIC_BRANCH, "HEAD")
+        });
+        assert!(res.is_err());
+        // it should not merge the successful one.
+        assert!(git.check_history(MAGIC_BRANCH, MAGIC_COMMIT).is_err());
+    }
+
+    /// Tests that panicking through with_work_repos leaves the disk "dirty".
+    /// Also tests that a future call to with_work_repos fails gracefully.
+    #[test]
+    fn test_with_work_repos_panic() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        let res = std::panic::catch_unwind(AssertUnwindSafe(|| {
+            // we DO NOT want to call unwrap() on ANY of these.
+            git.with_work_repos::<_, ()>(1, |repos| {
+                let repo = &mut repos[0];
+                repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?;
+                panic!()
+            })
+        }));
+        // check that it panicked.
+        assert!(res.is_err());
+        // now check that future calls return an error, without calling the
+        // closure.
+        let res: Result<(), _> = git.with_work_repos(1, |_| panic!());
+        assert!(res.is_err());
+    }
+
+    /// Tests that check_branch is correct.
+    #[test]
+    fn test_check_branch() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        git.check_branch("default").unwrap();
+        assert!(git.check_branch("HEAD").is_err());
+    }
+
+    /// Tests that check_history is correct.
+    #[test]
+    fn test_check_history() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")
+        }).unwrap();
+        git.check_history(MAGIC_BRANCH, MAGIC_COMMIT).unwrap();
+    }
+
+    /// Tests that get_counts is correct. (non-exhaustive)
+    #[test]
+    fn test_get_counts() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        let counts = git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?;
+            repo.get_counts(MAGIC_COMMIT, MAGIC_COMMIT)
+        }).unwrap();
+        // Unfortunately we can only check MAGIC_COMMIT...MAGIC_COMMIT,
+        // so this is always gonna be (0, 0).
+        assert_eq!(counts, (0, 0));
+    }
+
+    /// Tests that get_hash is correct.
+    #[test]
+    fn test_get_hash() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        let hash = git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?;
+            repo.get_hash(MAGIC_BRANCH)
+        }).unwrap();
+        let hashtoo = git.get_hash(MAGIC_BRANCH).unwrap();
+        assert_eq!(hash, MAGIC_COMMIT);
+        assert_eq!(hash, hashtoo);
+    }
+
+    /// Tests that get_message is correct.
+    #[test]
+    fn test_get_message() {
+        let dir = TempDir::new().unwrap();
+        let repo = dir.child("ganarchy-cache.git");
+        let mut git = Git::at_path(Initializer, repo.path()).unwrap();
+        git.ensure_exists().unwrap();
+        let server = serve(REPO);
+        let addr = format!("http://{}:{}/", testserver::IP, server.port());
+        let msg = git.with_work_repos(1, |repos| {
+            let repo = &mut repos[0];
+            repo.fetch_source(&addr, MAGIC_BRANCH, "HEAD")?;
+            repo.get_message(MAGIC_COMMIT)
+        }).unwrap();
+        let msgtoo = git.get_message(MAGIC_COMMIT).unwrap();
+        let expect = "[Project] Example Project\n\
+                      \n\
+                      This is an example GAnarchy project.\n";
+        assert_eq!(msg, expect);
+        assert_eq!(msg, msgtoo);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..5291931
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,23 @@
+// GAnarchy - decentralized development hub
+// Copyright (C) 2021  Soni L.
+// 
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+// 
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+// 
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+mod git;
+mod util;
+//mod system;
+mod marker;
+
+#[cfg(test)]
+mod test_util;
diff --git a/src/marker.rs b/src/marker.rs
new file mode 100644
index 0000000..3537f59
--- /dev/null
+++ b/src/marker.rs
@@ -0,0 +1,23 @@
+// This file is part of GAnarchy - decentralized development hub
+// Copyright (C) 2021  Soni L.
+// 
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+// 
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+// 
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+/// A marker struct for initialization operations.
+///
+/// This is like a "custom unsafe". It's to mark things that shouldn't be used
+/// outside of initialization code. It serves as a lint to identify code that
+/// shouldn't be used inside application logic.
+#[derive(Copy, Clone)]
+pub struct Initializer;
diff --git a/src/test_util.rs b/src/test_util.rs
new file mode 100644
index 0000000..b2fa29f
--- /dev/null
+++ b/src/test_util.rs
@@ -0,0 +1,19 @@
+// This file is part of GAnarchy - decentralized development hub
+// Copyright (C) 2021  Soni L.
+// 
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+// 
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+// 
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+//! Collection of utilities that are useful for tests.
+
+// uh yeah there's nothing here currently.
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 0000000..0b96bde
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,108 @@
+// This file is part of GAnarchy - decentralized development hub
+// Copyright (C) 2021  Soni L.
+// 
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+// 
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+// 
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+//! Some utilities.
+
+/// A helper for checking if a name is fit for a certain purpose.
+pub enum NamePurpose {
+    /// A `CacheRepo` is the main SHA-1 git repo, used as a cache.
+    CacheRepo,
+    /// A `CacheRepo64` is the main SHA-2 git repo, used as a cache.
+    CacheRepo64,
+    /// A `WorkRepo` is a SHA-1 Git repo that isn't the main repo, used for
+    /// fetching from remote branches.
+    WorkRepo,
+    /// A `WorkRepo64` is a SHA-1 Git repo that isn't the main repo, used for
+    /// fetching from remote branches.
+    WorkRepo64,
+    /// A `CacheBranch` is a main branch in the main repo, used as a cache.
+    CacheBranch,
+    /// A `CacheFetchHead` is a work branch in the main repo, used for fetching
+    /// from a work repo.
+    CacheFetchHead,
+    /// A `WorkBranch` is a branch in a work repo, used for fetching from
+    /// remote branches.
+    WorkBranch,
+    /// A `Commit` is a 160-bit SHA-1 hash, 40 characters long.
+    Commit,
+    /// A `Commit64` is a 256-bit SHA-2 hash, 64 characters long.
+    Commit64,
+}
+
+impl NamePurpose {
+    /// Checks if the given name is fit for this purpose.
+    pub fn is_fit(&self, name: &str) -> bool {
+        use NamePurpose::*;
+        /// Checks if the value is within the range '0'..='9'.
+        fn is_digit(c: char) -> bool {
+            c.is_ascii_digit()
+        }
+        /// Checks if the value is within the ranges '0'..='9' or 'a'..='f'.
+        fn is_hex(c: char) -> bool {
+            // numbers aren't lowercase, unfortunately.
+            c.is_ascii_hexdigit() && !c.is_ascii_uppercase()
+        }
+        match self {
+            CacheRepo => name == "ganarchy-cache.git",
+            CacheRepo64 => name == "ganarchy-cache64.git",
+            WorkRepo => {
+                // "ganarchy-fetch-[0-9][0-9]*\.git"
+                Some(name)
+                    .and_then(|x| x.strip_prefix("ganarchy-fetch-"))
+                    .and_then(|x| x.strip_suffix(".git"))
+                    .and_then(|x| x.strip_prefix(is_digit))
+                    .map(|x| x.trim_matches(is_digit))
+                    == Some("")
+            },
+            WorkRepo64 => {
+                // "ganarchy-fetch-[0-9][0-9]*\.git"
+                Some(name)
+                    .and_then(|x| x.strip_prefix("ganarchy-fetch64-"))
+                    .and_then(|x| x.strip_suffix(".git"))
+                    .and_then(|x| x.strip_prefix(is_digit))
+                    .map(|x| x.trim_matches(is_digit))
+                    == Some("")
+            },
+            CacheBranch => {
+                // "gan[0-9a-f]{64}"
+                (0..64)
+                    .try_fold(name, |x, _| x.strip_suffix(is_hex))
+                    == Some("gan")
+            },
+            CacheFetchHead => {
+                // CacheBranch + "-[0-9]*[0-9]"
+                name.strip_suffix(is_digit)
+                    .map(|x| x.trim_end_matches(is_digit))
+                    .and_then(|x| x.strip_suffix("-"))
+                    .filter(|x| CacheBranch.is_fit(x))
+                    .is_some()
+            },
+            WorkBranch => CacheBranch.is_fit(name), // same as CacheBranch
+            Commit => {
+                // "[0-9a-f]{40}"
+                (0..40)
+                    .try_fold(name, |x, _| x.strip_suffix(is_hex))
+                    == Some("")
+            },
+            Commit64 => {
+                // "[0-9a-f]{64}"
+                (0..64)
+                    .try_fold(name, |x, _| x.strip_suffix(is_hex))
+                    == Some("")
+            },
+        }
+    }
+}