use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use tempfile::TempDir;
pub trait Root {
fn root(&self) -> &Path;
}
impl Root for TempDir {
fn root(&self) -> &Path {
self.path()
}
}
struct Template {
dir: PathBuf,
built_at: String,
note: String,
}
type Registry = Mutex<HashMap<String, Arc<OnceLock<Template>>>>;
fn registry() -> &'static Registry {
static REGISTRY: OnceLock<Registry> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn cached<R: Root>(key: &str, build: impl FnOnce() -> (R, String)) -> (TempDir, String) {
let cell = registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(key.to_string())
.or_default()
.clone();
let template = cell.get_or_init(|| {
let (built, note) = build();
static COUNT: AtomicUsize = AtomicUsize::new(0);
let dir = template_store().join(COUNT.fetch_add(1, Ordering::Relaxed).to_string());
super::stop_servers(built.root(), true);
run_cp(&built.root().join("."), &dir);
Template {
dir,
built_at: built.root().to_string_lossy().into_owned(),
note,
}
});
let root = tempfile::tempdir().unwrap();
run_cp(&template.dir.join("."), root.path());
rewrite_paths(
root.path(),
&template.built_at,
&root.path().to_string_lossy(),
);
(root, template.note.clone())
}
fn template_store() -> PathBuf {
static STORE: OnceLock<PathBuf> = OnceLock::new();
STORE
.get_or_init(|| {
let parent = Path::new(env!("CARGO_TARGET_TMPDIR"));
let prefix = "templates-";
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(pid) = name.strip_prefix(prefix) {
if !Path::new("/proc").join(pid).exists() {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
}
let dir = parent.join(format!("{}{}", prefix, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
})
.clone()
}
pub fn run_cp(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
let output = Command::new("cp")
.arg("-a")
.arg(from)
.arg(to)
.output()
.expect("failed to run cp");
assert!(
output.status.success(),
"cp failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
pub fn rewrite_paths(dir: &Path, old: &str, new: &str) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
let kind = std::fs::symlink_metadata(&path).unwrap().file_type();
if kind.is_dir() {
rewrite_paths(&path, old, new);
} else if kind.is_file() {
let bytes = std::fs::read(&path).unwrap();
if !bytes.windows(old.len()).any(|w| w == old.as_bytes()) {
continue;
}
let text = String::from_utf8(bytes)
.unwrap_or_else(|_| panic!("{} holds a path and is not text", path.display()));
std::fs::write(&path, text.replace(old, new)).unwrap();
}
}
}
#[derive(Debug)]
pub enum Step {
Tsk(&'static str, &'static [&'static str]),
Stdin(&'static str, &'static [&'static str], &'static str),
}
#[derive(Debug)]
pub struct Seed {
pub branch: &'static str,
pub clones: &'static [&'static str],
pub refs: Option<&'static str>,
pub steps: &'static [Step],
}
pub const FETCHED: Seed = Seed {
branch: "main",
clones: &["work", "work2"],
refs: Some("branches"),
steps: &[Step::Tsk("work", &["ledger", "fetch"])],
};
pub fn instance(seed: &Seed) -> TempDir {
cached(&format!("{:?}", seed), || (build(seed), String::new())).0
}
fn build(seed: &Seed) -> TempDir {
let root = tempfile::tempdir().unwrap();
let path = root.path();
std::fs::create_dir_all(path.join("home")).unwrap();
std::fs::create_dir_all(path.join("state")).unwrap();
super::seed_dolt_home(&path.join("home"));
git(
path,
&["init", "--quiet", "--bare", "-b", seed.branch, "origin.git"],
);
git(path, &["init", "--quiet", "-b", seed.branch, "seed"]);
let origin = path.join("origin.git");
let work = path.join("seed");
git(
&work,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
std::fs::write(work.join("README.md"), "main\n").unwrap();
git(&work, &["add", "-A"]);
git(&work, &["commit", "--quiet", "-m", "Main commit"]);
git(
&work,
&[
"push",
"--quiet",
"origin",
&format!("HEAD:refs/heads/{}", seed.branch),
],
);
for name in seed.clones {
git(path, &["clone", "--quiet", origin.to_str().unwrap(), name]);
}
for step in seed.steps {
let (clone, args, input) = match step {
Step::Tsk(clone, args) => (clone, *args, ""),
Step::Stdin(clone, args, input) => (clone, *args, *input),
};
let output = tsk(path, seed, &path.join(clone), args, input);
assert!(
output.status.success(),
"tsk {:?} failed in {}: {}",
args,
clone,
String::from_utf8_lossy(&output.stderr)
);
}
root
}
fn tsk(root: &Path, seed: &Seed, dir: &Path, args: &[&str], input: &str) -> Output {
let home = root.join("home");
let mut command = Command::new(env!("CARGO_BIN_EXE_tsk"));
command
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("HOME", &home)
.env("XDG_STATE_HOME", root.join("state"))
.env("XDG_CONFIG_HOME", home.join("config"))
.env("TSK_HOME", home.join(".tsk"))
.env("GIT_CEILING_DIRECTORIES", root)
.env("TSK_DOLT_SERVER", "off")
.env("TSK_NO_UPDATE_CHECK", "1")
.env("TSK_AUTO_UPDATE", "never")
.env("TSK_BACKGROUND_PUBLISH", "off")
.env_remove("TSK_LEDGER_TRANSPORT")
.env_remove("CLAUDE_CODE_REMOTE")
.env_remove("CLAUDE_CODE_REMOTE_SESSION_ID")
.env_remove("CLAUDE_ENV_FILE");
match seed.refs {
Some(refs) => command.env("TSK_LEDGER_REFS", refs),
None => command.env_remove("TSK_LEDGER_REFS"),
};
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to run tsk");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
fn git(dir: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.output()
.expect("failed to run git");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}