chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

328 lines · 11.5 KB Raw History
//! A mirror's objects read in process, for pages that must be quick:
//! starting a git process costs more than these reads take. Each mirror is
//! opened once; new packs a sync brings are found when first needed.

use anyhow::{Context, Result};
use gix::revision::walk::Sorting;
use gix::traverse::commit::simple::CommitTimeOrder;
use knotless::read::{Author, Commit, Entry};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};

static OPEN: LazyLock<Mutex<HashMap<PathBuf, gix::ThreadSafeRepository>>> =
    LazyLock::new(Mutex::default);

pub struct Objects(gix::Repository);

/// The repository at `dir`, a bare mirror.
pub fn open(dir: &Path) -> Result<Objects> {
    let mut open = OPEN.lock().unwrap();
    let safe = match open.get(dir) {
        Some(safe) => safe.clone(),
        None => {
            let safe = gix::ThreadSafeRepository::open(dir)?;
            open.insert(dir.to_path_buf(), safe.clone());
            safe
        }
    };
    let mut repo = safe.to_thread_local();
    repo.object_cache_size_if_unset(4 << 20);
    Ok(Objects(repo))
}

/// The id of whatever `path` names in `tree`, if anything.
fn id_at(tree: &gix::Tree<'_>, path: &str) -> Result<Option<gix::ObjectId>> {
    Ok(tree.lookup_entry_by_path(path)?.map(|e| e.oid().to_owned()))
}

impl Objects {
    /// The branch HEAD names, if it names one.
    pub fn head(&self) -> Option<String> {
        let name = self.0.head_name().ok()??;
        Some(name.shorten().to_string())
    }

    /// Every branch and tag, by its short name.
    pub fn names(&self) -> Result<Vec<String>> {
        let platform = self.0.references()?;
        let mut names = Vec::new();
        for r in platform.all()? {
            let r = r?;
            let full = r.name().as_bstr().to_string();
            if let Some(short) = full
                .strip_prefix("refs/heads/")
                .or(full.strip_prefix("refs/tags/"))
            {
                names.push(short.to_string());
            }
        }
        Ok(names)
    }

    fn commit(&self, rev: &str) -> Result<gix::Commit<'_>> {
        let id = self
            .0
            .rev_parse_single(rev)
            .with_context(|| format!("no revision {rev}"))?;
        Ok(id.object()?.peel_to_commit()?)
    }

    /// The commit `rev` names, by its full id.
    pub fn resolve(&self, rev: &str) -> Result<String> {
        Ok(self.commit(rev)?.id.to_string())
    }

    pub fn tree(&self, rev: &str, path: &str) -> Result<Vec<Entry>> {
        let mut tree = self.commit(rev)?.tree()?;
        if !path.is_empty() {
            let entry = tree
                .lookup_entry_by_path(path)?
                .with_context(|| format!("no {path} at {rev}"))?;
            tree = entry
                .object()?
                .try_into_tree()
                .with_context(|| format!("{path} is not a directory"))?;
        }
        let mut out = Vec::new();
        for e in tree.iter() {
            let e = e?;
            let mode = e.mode();
            let kind = if mode.is_tree() {
                "tree"
            } else if mode.is_commit() {
                "commit"
            } else {
                "blob"
            };
            let size = match kind {
                "blob" => Some(self.0.find_header(e.oid())?.size()),
                _ => None,
            };
            out.push(Entry {
                name: e.filename().to_string(),
                mode: mode.kind().as_octal_str().to_string(),
                kind: kind.into(),
                sha: e.oid().to_string(),
                size,
            });
        }
        Ok(out)
    }

    pub fn blob(&self, rev: &str, path: &str) -> Result<Vec<u8>> {
        let tree = self.commit(rev)?.tree()?;
        let entry = tree
            .lookup_entry_by_path(path)?
            .with_context(|| format!("no {path} at {rev}"))?;
        let object = entry.object()?;
        anyhow::ensure!(
            object.kind == gix::object::Kind::Blob,
            "{path} is not a file"
        );
        Ok(object.detach().data)
    }

    /// Whether `commit` changed `path` from each of its parents: a commit
    /// that left it as one parent had it is not part of its history.
    fn touches(&self, commit: &gix::Commit<'_>, path: &str) -> Result<bool> {
        let here = id_at(&commit.tree()?, path)?;
        let mut parents = commit.parent_ids().peekable();
        if parents.peek().is_none() {
            return Ok(here.is_some());
        }
        for parent in parents {
            if id_at(&parent.object()?.peel_to_commit()?.tree()?, path)? == here {
                return Ok(false);
            }
        }
        Ok(true)
    }

    /// History from `rev`, newest first, touching `path` if it is not
    /// empty, after its first `skip` commits.
    pub fn log(&self, rev: &str, path: &str, skip: usize, limit: usize) -> Result<Vec<Commit>> {
        let tip = self.commit(rev)?.id;
        let walk = self
            .0
            .rev_walk([tip])
            .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst))
            .all()?;
        let mut out = Vec::new();
        let mut skipped = 0;
        for info in walk {
            let commit = info?.object()?;
            if !path.is_empty() && !self.touches(&commit, path)? {
                continue;
            }
            if skipped < skip {
                skipped += 1;
                continue;
            }
            out.push(summary(&commit)?);
            if out.len() == limit {
                break;
            }
        }
        Ok(out)
    }

    /// What moving a ref to `to` brought: the commits it reaches that none
    /// of `known` does, newest first, at most `limit` of them. A revision
    /// in `known` that is not here is passed over.
    pub fn brought(&self, to: &str, known: &[&str], limit: usize) -> Result<Vec<Commit>> {
        let tip = self.commit(to)?.id;
        let hidden: Vec<gix::ObjectId> = known
            .iter()
            .filter_map(|k| self.commit(k).ok().map(|c| c.id))
            .collect();
        let walk = self
            .0
            .rev_walk([tip])
            .with_hidden(hidden)
            .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst))
            .all()?;
        let mut out = Vec::new();
        for info in walk.take(limit) {
            out.push(summary(&info?.object()?)?);
        }
        Ok(out)
    }
}

fn summary(commit: &gix::Commit<'_>) -> Result<Commit> {
    let author = commit.author()?;
    let date = author
        .time()?
        .format(gix::date::time::format::ISO8601_STRICT)?;
    Ok(Commit {
        sha: commit.id.to_string(),
        parents: commit.parent_ids().map(|p| p.to_string()).collect(),
        author: Author {
            name: author.name.to_string(),
            email: author.email.to_string(),
            date,
        },
        message: commit.message_raw()?.to_string().trim_end().to_string(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use knotless::git::Git;

    async fn git(dir: &Path, args: &[&str]) -> String {
        Git::new(dir).text(args, None).await.unwrap()
    }

    #[tokio::test]
    async fn reads_as_git_does() {
        let dir = tempfile::tempdir().unwrap();
        let d = dir.path();
        let run = |args: Vec<&str>| {
            let out = std::process::Command::new("git")
                .args(args)
                .current_dir(d)
                .env("GIT_CONFIG_GLOBAL", "/dev/null")
                .env("GIT_AUTHOR_NAME", "A")
                .env("GIT_AUTHOR_EMAIL", "a@x")
                .env("GIT_COMMITTER_NAME", "A")
                .env("GIT_COMMITTER_EMAIL", "a@x")
                .output()
                .unwrap();
            assert!(out.status.success(), "{out:?}");
        };
        run(vec!["init", "-q", "-b", "main"]);
        std::fs::create_dir(d.join("src")).unwrap();
        std::fs::write(d.join("src/a.rs"), "fn a() {}\n").unwrap();
        std::fs::write(d.join("b"), "b\n").unwrap();
        run(vec!["add", "-A"]);
        run(vec!["commit", "-q", "-m", "one\n\nwith a body"]);
        std::fs::write(d.join("b"), "bb\n").unwrap();
        run(vec!["commit", "-qam", "two"]);
        run(vec!["checkout", "-q", "-b", "side", "HEAD~1"]);
        std::fs::write(d.join("src/a.rs"), "fn a() { 1 }\n").unwrap();
        run(vec!["commit", "-qam", "three"]);
        run(vec!["checkout", "-q", "main"]);
        run(vec!["merge", "-q", "--no-edit", "side"]);
        let one = git(&d.join(".git"), &["rev-parse", "HEAD~1"]).await;
        run(vec![
            "update-index",
            "--add",
            "--cacheinfo",
            &format!("160000,{one},vendored"),
        ]);
        run(vec!["commit", "-qm", "vendor"]);
        run(vec!["tag", "v1"]);
        run(vec!["update-ref", "refs/notes/x", "HEAD"]);

        let o = open(&d.join(".git")).unwrap();
        let again = open(&d.join(".git")).unwrap();
        assert_eq!(o.head().as_deref(), Some("main"));
        assert_eq!(
            again.resolve("v1").unwrap(),
            git(&d.join(".git"), &["rev-parse", "HEAD"]).await
        );
        let mut names = o.names().unwrap();
        names.sort();
        assert_eq!(names, ["main", "side", "v1"]);

        let tree = o.tree("main", "").unwrap();
        let shown: Vec<(&str, &str, Option<u64>)> = tree
            .iter()
            .map(|e| (e.name.as_str(), e.kind.as_str(), e.size))
            .collect();
        assert_eq!(
            shown,
            [
                ("b", "blob", Some(3)),
                ("src", "tree", None),
                ("vendored", "commit", None)
            ]
        );
        assert_eq!(tree[1].mode, "40000");
        assert_eq!(o.tree("main", "src").unwrap()[0].name, "a.rs");
        assert!(o.tree("main", "b").is_err());
        assert!(o.tree("main", "nope").is_err());
        assert!(o.tree("nope", "").is_err());
        assert_eq!(o.blob("main", "src/a.rs").unwrap(), b"fn a() { 1 }\n");
        assert!(o.blob("main", "src").is_err());
        assert!(o.blob("main", "nope").is_err());

        let subjects = |log: Vec<Commit>| {
            log.iter()
                .map(|c| c.message.lines().next().unwrap().to_string())
                .collect::<Vec<_>>()
        };
        let all = o.log("main", "", 0, 10).unwrap();
        assert_eq!(all.len(), 5);
        assert_eq!(all[1].parents.len(), 2);
        assert_eq!(all[4].message, "one\n\nwith a body");
        assert_eq!(all[4].author.name, "A");
        assert!(all[4].author.date.contains('T'), "{}", all[4].author.date);
        assert_eq!(o.log("main", "", 1, 2).unwrap().len(), 2);
        assert_eq!(subjects(o.log("main", "b", 0, 10).unwrap()), ["two", "one"]);
        assert_eq!(
            subjects(o.log("main", "src", 0, 10).unwrap()),
            ["three", "one"]
        );
        assert_eq!(subjects(o.log("main", "src", 1, 10).unwrap()), ["one"]);
        assert!(o.log("main", "nowhere", 0, 10).unwrap().is_empty());
        assert!(o.log("nope", "", 0, 10).is_err());
        assert!(open(&d.join("missing")).is_err());

        // What a push of main brought, past side and a ref it never saw.
        assert_eq!(
            subjects(o.brought("main", &["side", "gone"], 10).unwrap()),
            ["vendor", "Merge branch 'side'", "two"]
        );
        assert_eq!(o.brought("main", &[], 10).unwrap().len(), 5);
        assert_eq!(o.brought("main", &[], 2).unwrap().len(), 2);
        assert!(o.brought("main", &["main"], 10).unwrap().is_empty());
        assert!(o.brought("nope", &[], 10).is_err());

        run(vec!["checkout", "-q", "--detach"]);
        assert_eq!(open(&d.join(".git")).unwrap().head(), None);
    }
}