use std::fs;
use std::ops::ControlFlow;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::error::Result;
use grit_lib::objects::odb::store::{MemoryStore, ObjectStore, WritableObjectStore};
use grit_lib::objects::odb::{Odb, OdbBuilder, WriteOptions};
use grit_lib::objects::{HashAlgo, Object, ObjectId, ObjectInfo, ObjectKind};
use grit_lib::porcelain::add::{stage, StageOptions};
use grit_lib::porcelain::commit::{commit_with_scope, CommitRequest, CommitScope};
use grit_lib::progress::NullProgress;
use grit_lib::repo::{InitOptions, Repository, RepositoryBuilder};
use grit_lib::stash::{StashMode, StashPushOptions};
const IDENT: &str = "A U Thor <author@example.com> 1700000000 +0000";
const FILES: usize = 1000;
#[derive(Debug)]
struct CountingStore {
inner: MemoryStore,
batched: bool,
contains: AtomicU64,
contains_many: AtomicU64,
writes: AtomicU64,
}
impl CountingStore {
fn new(batched: bool) -> Self {
Self {
inner: MemoryStore::new(HashAlgo::Sha1),
batched,
contains: AtomicU64::new(0),
contains_many: AtomicU64::new(0),
writes: AtomicU64::new(0),
}
}
fn snapshot(&self) -> Calls {
Calls {
contains: self.contains.load(Ordering::Relaxed),
contains_many: self.contains_many.load(Ordering::Relaxed),
writes: self.writes.load(Ordering::Relaxed),
}
}
}
impl ObjectStore for CountingStore {
fn hash_algo(&self) -> HashAlgo {
self.inner.hash_algo()
}
fn read(&self, oid: &ObjectId) -> Result<Option<Object>> {
self.inner.read(oid)
}
fn read_info(&self, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
self.inner.read_info(oid)
}
fn contains(&self, oid: &ObjectId) -> Result<bool> {
self.contains.fetch_add(1, Ordering::Relaxed);
self.inner.contains(oid)
}
fn contains_many(&self, oids: &[ObjectId]) -> Result<Vec<bool>> {
if !self.batched {
return oids.iter().map(|oid| self.contains(oid)).collect();
}
self.contains_many.fetch_add(1, Ordering::Relaxed);
self.inner.contains_many(oids)
}
fn for_each_object(&self, f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>) -> Result<()> {
self.inner.for_each_object(f)
}
}
impl WritableObjectStore for CountingStore {
fn write(&self, kind: ObjectKind, data: &[u8], options: WriteOptions) -> Result<ObjectId> {
self.writes.fetch_add(1, Ordering::Relaxed);
self.inner.write(kind, data, options)
}
fn freshen(&self, oid: &ObjectId) -> Result<bool> {
self.inner.freshen(oid)
}
}
#[derive(Debug, Clone, Copy)]
struct Calls {
contains: u64,
contains_many: u64,
writes: u64,
}
impl Calls {
fn since(self, before: Calls) -> Calls {
Calls {
contains: self.contains - before.contains,
contains_many: self.contains_many - before.contains_many,
writes: self.writes - before.writes,
}
}
fn round_trips(self) -> u64 {
self.contains + self.contains_many
}
}
fn commit_all(repo: &Repository, message: &str) {
stage(
repo,
&StageOptions::default(),
&mut NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("stage");
commit_with_scope(
repo,
&CommitRequest {
message: message.to_owned(),
author: Some(IDENT.parse().expect("signature")),
committer: Some(IDENT.parse().expect("signature")),
..Default::default()
},
CommitScope::Staged,
&mut NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("commit");
}
struct OneFileCommit {
dir: tempfile::TempDir,
store: Arc<CountingStore>,
calls: Calls,
}
fn one_file_commit(dirs: usize, batched: bool) -> OneFileCommit {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().canonicalize().expect("canonical root");
Repository::init(
&root,
&InitOptions::default().with_initial_branch("main".to_owned()),
)
.expect("init");
let git_dir = root.join(".git");
let store = Arc::new(CountingStore::new(batched));
let repo = RepositoryBuilder::new(RepositoryOptions::empty())
.odb_builder(
OdbBuilder::files(git_dir.join("objects"))
.primary(store.clone())
.alternates(false),
)
.open(&git_dir, Some(&root))
.expect("open");
for d in 0..dirs {
fs::create_dir_all(root.join(format!("d{d}"))).expect("mkdir");
}
for i in 0..FILES {
fs::write(root.join(format!("d{}/f{i}", i % dirs)), format!("{i}\n")).expect("write");
}
commit_all(&repo, "initial");
fs::write(root.join("d0/f0"), "changed\n").expect("modify");
let before = store.snapshot();
commit_all(&repo, "touch one file");
let calls = store.snapshot().since(before);
eprintln!("dirs={dirs} batched={batched}: {calls:?}");
OneFileCommit { dir, store, calls }
}
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(root)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?}: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn export_to_disk(root: &Path, store: &MemoryStore) {
let disk = Odb::new(&root.join(".git/objects"));
let mut oids = Vec::new();
store
.for_each_object(&mut |oid| {
oids.push(*oid);
ControlFlow::Continue(())
})
.expect("enumerate");
for oid in oids {
let obj = store.read(&oid).expect("read").expect("present");
assert_eq!(disk.write(obj.kind, &obj.data).expect("export"), oid);
}
}
fn assert_git_accepts(commit: &OneFileCommit) {
let root = commit.dir.path();
export_to_disk(root, &commit.store.inner);
git(root, &["fsck", "--full", "--strict"]);
assert_eq!(git(root, &["status", "--porcelain"]), "");
assert_eq!(git(root, &["rev-list", "--count", "HEAD"]).trim(), "2");
assert_eq!(git(root, &["show", "HEAD:d0/f0"]), "changed\n");
assert_eq!(
git(root, &["diff", "--name-only", "HEAD~1", "HEAD"]).trim(),
"d0/f0"
);
}
#[test]
fn one_file_commit_round_trips_do_not_grow_with_directory_count() {
for dirs in [10, 1000] {
let commit = one_file_commit(dirs, true);
assert_eq!(commit.calls.writes, 4, "dirs={dirs}: {:?}", commit.calls);
assert!(
commit.calls.round_trips() <= 16,
"dirs={dirs}: {:?}",
commit.calls
);
assert_git_accepts(&commit);
}
}
#[test]
fn one_file_commit_probes_each_unchanged_directory_once() {
let dirs = 1000;
let commit = one_file_commit(dirs, false);
assert_eq!(commit.calls.writes, 4, "{:?}", commit.calls);
assert_eq!(commit.calls.contains_many, 0);
assert!(
commit.calls.contains <= dirs as u64 + 16,
"{:?}",
commit.calls
);
assert_git_accepts(&commit);
}
struct Layered {
dir: tempfile::TempDir,
primary: Arc<MemoryStore>,
remote: Arc<CountingStore>,
repo: Repository,
}
fn layered_repo(dirs: usize) -> Layered {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().canonicalize().expect("canonical root");
Repository::init(
&root,
&InitOptions::default().with_initial_branch("main".to_owned()),
)
.expect("init");
let git_dir = root.join(".git");
let remote = Arc::new(CountingStore::new(true));
let open = |builder: OdbBuilder| {
RepositoryBuilder::new(RepositoryOptions::empty())
.odb_builder(builder.alternates(false))
.open(&git_dir, Some(&root))
.expect("open")
};
let history = open(OdbBuilder::files(git_dir.join("objects")).primary(remote.clone()));
for d in 0..dirs {
fs::create_dir_all(root.join(format!("d{d}"))).expect("mkdir");
}
for i in 0..FILES {
fs::write(root.join(format!("d{}/f{i}", i % dirs)), format!("{i}\n")).expect("write");
}
commit_all(&history, "initial");
drop(history);
let primary = Arc::new(MemoryStore::new(HashAlgo::Sha1));
let repo = open(
OdbBuilder::files(git_dir.join("objects"))
.primary(primary.clone())
.push_read_source(remote.clone()),
);
Layered {
dir,
primary,
remote,
repo,
}
}
impl Layered {
fn remote_calls(&self, op: impl FnOnce(&Repository)) -> Calls {
let before = self.remote.snapshot();
op(&self.repo);
self.remote.snapshot().since(before)
}
fn assert_git_accepts(&self, commits: usize) {
let root = self.dir.path();
export_to_disk(root, &self.remote.inner);
export_to_disk(root, &self.primary);
git(root, &["fsck", "--full", "--strict"]);
assert_eq!(
git(root, &["rev-list", "--count", "HEAD"]).trim(),
commits.to_string()
);
}
}
#[test]
fn layered_one_file_commits_round_trips_do_not_grow_with_directory_count() {
for dirs in [10, 1000] {
let layered = layered_repo(dirs);
let root = layered.dir.path().to_path_buf();
for (n, contents) in ["changed\n", "changed again\n"].into_iter().enumerate() {
fs::write(root.join("d0/f0"), contents).expect("modify");
let calls = layered.remote_calls(|repo| commit_all(repo, "touch one file"));
eprintln!("layered dirs={dirs} commit {n}: {calls:?}");
assert!(
calls.round_trips() <= 16,
"dirs={dirs} commit {n}: {calls:?}"
);
}
layered.assert_git_accepts(3);
assert_eq!(git(&root, &["status", "--porcelain"]), "");
assert_eq!(git(&root, &["show", "HEAD:d0/f0"]), "changed again\n");
}
}
#[test]
fn layered_stash_push_round_trips_do_not_grow_with_directory_count() {
let dirs = 1000;
let layered = layered_repo(dirs);
let root = layered.dir.path().to_path_buf();
for path in ["d0/f0", "d1/f1", "d2/f2"] {
fs::write(root.join(path), "edited\n").expect("modify");
}
let calls = layered.remote_calls(|repo| {
let options = StashPushOptions {
message: Some("wip".to_owned()),
include_untracked: false,
identity: IDENT.parse().expect("signature"),
selection: None,
pathspecs: Vec::new(),
dry_run: false,
mode: StashMode::default(),
};
repo.stash()
.push(
&options,
&mut NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("stash push")
.expect("changes to stash");
});
eprintln!("layered stash push dirs={dirs}: {calls:?}");
assert!(calls.round_trips() <= 16, "{calls:?}");
layered.assert_git_accepts(1);
assert_eq!(fs::read_to_string(root.join("d0/f0")).expect("read"), "0\n");
assert_eq!(git(&root, &["show", "stash@{0}:d1/f1"]), "edited\n");
}