mod common;
use common::grex;
use grex_core::git::gix_backend::file_url_from_path;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use tempfile::TempDir;
fn init_git_identity() {
static ONCE: OnceLock<()> = OnceLock::new();
ONCE.get_or_init(|| {
std::env::set_var("GIT_AUTHOR_NAME", "grex-test");
std::env::set_var("GIT_AUTHOR_EMAIL", "test@grex.local");
std::env::set_var("GIT_COMMITTER_NAME", "grex-test");
std::env::set_var("GIT_COMMITTER_EMAIL", "test@grex.local");
let null_cfg = std::env::temp_dir().join("grex-test-empty-gitconfig");
let _ = std::fs::write(&null_cfg, b"");
std::env::set_var("GIT_CONFIG_GLOBAL", &null_cfg);
std::env::set_var("GIT_CONFIG_SYSTEM", &null_cfg);
std::env::set_var("GIT_CONFIG_NOSYSTEM", "1");
});
}
fn run_git(cwd: &Path, args: &[&str]) {
let out = Command::new("git").args(args).current_dir(cwd).output().expect("git on PATH");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn seed_bare(tmp: &Path, name: &str, sink: &Path) -> PathBuf {
init_git_identity();
let mkdir_path = sink.join(format!("made-{name}")).to_string_lossy().replace('\\', "/");
let pack_yaml = format!(
"schema_version: \"1\"\nname: {name}\ntype: declarative\nactions:\n - mkdir:\n path: {mkdir_path}\n",
);
let work = tmp.join(format!("seed-{name}-work"));
fs::create_dir_all(work.join(".grex")).unwrap();
fs::write(work.join(".grex/pack.yaml"), &pack_yaml).unwrap();
run_git(&work, &["init", "-q", "-b", "main"]);
run_git(&work, &["config", "user.email", "grex-test@example.com"]);
run_git(&work, &["config", "user.name", "grex-test"]);
run_git(&work, &["add", "-A"]);
run_git(&work, &["commit", "-q", "-m", "seed"]);
let bare = tmp.join(format!("{name}.git"));
run_git(tmp, &["clone", "-q", "--bare", work.to_str().unwrap(), bare.to_str().unwrap()]);
bare
}
struct LegacyLayout {
_tmp: TempDir,
root: PathBuf,
child_names: [&'static str; 3],
}
fn build_legacy_layout() -> LegacyLayout {
let tmp = TempDir::new().unwrap();
let tmp_path = tmp.path().to_path_buf();
let names: [&'static str; 3] = ["alpha", "beta", "gamma"];
let sink = tmp_path.join("sink");
fs::create_dir_all(&sink).unwrap();
let root = tmp_path.join("root");
fs::create_dir_all(&root).unwrap();
let legacy = root.join(".grex").join("workspace");
fs::create_dir_all(&legacy).unwrap();
let mut clone_urls: Vec<String> = Vec::with_capacity(names.len());
for name in names {
let bare = seed_bare(&tmp_path, name, &sink);
let url = file_url_from_path(&bare);
run_git(&legacy, &["clone", "-q", url.as_str(), legacy.join(name).to_str().unwrap()]);
clone_urls.push(url);
}
let mut parent_yaml =
String::from("schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n");
for (name, url) in names.iter().zip(clone_urls.iter()) {
parent_yaml.push_str(&format!(" - url: {url}\n path: {name}\n"));
}
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(root.join(".grex/pack.yaml"), parent_yaml).unwrap();
fs::write(legacy.join(".grex.sync.lock"), b"").unwrap();
LegacyLayout { _tmp: tmp, root, child_names: names }
}
fn assert_pre_sync_legacy_shape(layout: &LegacyLayout, legacy_root: &Path) {
for name in layout.child_names {
assert!(legacy_root.join(name).join(".git").is_dir(), "fixture must seed legacy `{name}`");
assert!(
!layout.root.join(name).exists(),
"fixture must NOT pre-create flat-sibling `{name}`",
);
}
assert!(legacy_root.join(".grex.sync.lock").is_file());
}
fn assert_post_migration_shape(layout: &LegacyLayout, legacy_root: &Path) {
for name in layout.child_names {
assert!(
layout.root.join(name).join(".git").is_dir(),
"child `{name}` must be at flat-sibling slot post-migration",
);
assert!(
!legacy_root.join(name).exists(),
"legacy slot `{name}` must be removed after rename",
);
}
assert!(
!legacy_root.join(".grex.sync.lock").exists(),
"orphan lock at legacy location must be removed by migration",
);
assert!(!legacy_root.exists(), "empty `.grex/workspace/` must be rmdir'd by migration cleanup");
}
#[test]
fn auto_migrates_legacy_workspace_layout_on_first_sync() {
let layout = build_legacy_layout();
let legacy_root = layout.root.join(".grex").join("workspace");
assert_pre_sync_legacy_shape(&layout, &legacy_root);
let assertion = grex().current_dir(&layout.root).args(["sync", "."]).assert().success();
let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
for name in layout.child_names {
assert!(
stderr.lines().any(|line| line.contains("[migrated]") && line.contains(name)),
"stderr must announce migration for `{name}` on a single line; got:\n{stderr}",
);
assert!(
stdout.contains(name),
"sync stdout must mention child `{name}`; got:\n{stdout}\n--- stderr ---\n{stderr}",
);
}
assert_post_migration_shape(&layout, &legacy_root);
let assertion2 = grex().current_dir(&layout.root).args(["sync", "."]).assert().success();
let stderr2 = String::from_utf8(assertion2.get_output().stderr.clone()).unwrap();
assert!(
!stderr2.contains("[migrated]"),
"second sync must observe no legacy layout to migrate; got:\n{stderr2}",
);
}
#[test]
fn migration_refuses_to_clobber_pre_existing_destination() {
let layout = build_legacy_layout();
let legacy_root = layout.root.join(".grex").join("workspace");
let alpha_dest = layout.root.join(layout.child_names[0]);
fs::write(&alpha_dest, b"user-data; do not clobber\n").unwrap();
let _ = grex().current_dir(&layout.root).args(["sync", "."]).assert();
let body = fs::read_to_string(&alpha_dest).unwrap();
assert_eq!(body, "user-data; do not clobber\n", "user data must NOT be clobbered");
assert!(
legacy_root.join(layout.child_names[0]).join(".git").is_dir(),
"legacy `alpha` must remain on disk when destination is occupied",
);
}