use std::process::Command; use tempfile::TempDir; fn arc_cmd() -> Command { let mut cmd = Command::new(env!("CARGO_BIN_EXE_arc")); cmd.env("NO_COLOR", "1"); cmd } fn git_cmd() -> Command { Command::new("git") } fn create_git_repo() -> TempDir { let dir = TempDir::new().unwrap(); git_cmd() .args(["init"]) .current_dir(dir.path()) .output() .expect("failed to git init"); git_cmd() .args(["config", "user.name", "test"]) .current_dir(dir.path()) .output() .unwrap(); git_cmd() .args(["config", "user.email", "test@test.com"]) .current_dir(dir.path()) .output() .unwrap(); dir } fn git_commit(dir: &TempDir, name: &str, content: &str, msg: &str) { let file_path = dir.path().join(name); if let Some(parent) = file_path.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(&file_path, content).unwrap(); git_cmd() .args(["add", "."]) .current_dir(dir.path()) .output() .unwrap(); git_cmd() .args(["commit", "-m", msg]) .current_dir(dir.path()) .output() .unwrap(); } fn create_bare_git_repo() -> TempDir { let dir = TempDir::new().unwrap(); git_cmd() .args(["init", "--bare", "--initial-branch=main"]) .current_dir(dir.path()) .output() .expect("failed to git init --bare"); dir } fn init_arc_repo() -> TempDir { let dir = TempDir::new().unwrap(); arc_cmd() .arg("init") .current_dir(dir.path()) .output() .expect("failed to init arc"); dir } fn arc_commit(dir: &TempDir, name: &str, content: &str, msg: &str) { std::fs::write(dir.path().join(name), content).unwrap(); let output = arc_cmd() .args(["commit", msg]) .current_dir(dir.path()) .output() .expect("failed to commit"); assert!( output.status.success(), "commit failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn migrate_converts_git_repo() { let dir = create_git_repo(); git_commit(&dir, "hello.txt", "hello world\n", "initial commit"); git_commit(&dir, "hello.txt", "hello world v2\n", "second commit"); let output = arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); assert!( output.status.success(), "migrate failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("2 commit(s)"), "expected 2 commits, got: {stdout}" ); assert!( stdout.contains("1 bookmark(s)"), "expected 1 bookmark, got: {stdout}" ); assert!( stdout.contains("0 tag(s)"), "expected 0 tags, got: {stdout}" ); assert!(dir.path().join(".arc").is_dir()); assert!(dir.path().join(".arc").join("commits").is_dir()); let log_output = arc_cmd() .args(["log"]) .current_dir(dir.path()) .output() .unwrap(); assert!(log_output.status.success()); let log_stdout = String::from_utf8_lossy(&log_output.stdout); assert!(log_stdout.contains("initial commit")); assert!(log_stdout.contains("second commit")); } #[test] fn migrate_preserves_branches_as_bookmarks() { let dir = create_git_repo(); git_commit(&dir, "hello.txt", "hello\n", "first"); git_cmd() .args(["branch", "feature"]) .current_dir(dir.path()) .output() .unwrap(); let migrate_output = arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); let migrate_stdout = String::from_utf8_lossy(&migrate_output.stdout); assert!( migrate_stdout.contains("2 bookmark(s)"), "expected 2 bookmarks, got: {migrate_stdout}" ); let output = arc_cmd() .args(["mark", "list"]) .current_dir(dir.path()) .output() .unwrap(); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("main") || stdout.contains("master")); assert!(stdout.contains("feature")); } #[test] fn migrate_preserves_tags() { let dir = create_git_repo(); git_commit(&dir, "hello.txt", "hello\n", "first"); git_cmd() .args(["tag", "v1.0"]) .current_dir(dir.path()) .output() .unwrap(); let migrate_output = arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); let migrate_stdout = String::from_utf8_lossy(&migrate_output.stdout); assert!( migrate_stdout.contains("1 tag(s)"), "expected 1 tag, got: {migrate_stdout}" ); let output = arc_cmd() .args(["tag", "list"]) .current_dir(dir.path()) .output() .unwrap(); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("v1.0")); } #[test] fn migrate_fails_if_not_git_repo() { let dir = TempDir::new().unwrap(); let output = arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("not a git repository")); } #[test] fn migrate_fails_if_arc_already_exists() { let dir = create_git_repo(); git_commit(&dir, "hello.txt", "hello\n", "first"); arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); let output = arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("already exists")); } #[test] fn clone_from_local_git_repo() { let git_dir = create_git_repo(); git_commit(&git_dir, "hello.txt", "hello world\n", "initial commit"); git_commit(&git_dir, "readme.md", "readme\n", "add readme"); let clone_dir = TempDir::new().unwrap(); let clone_path = clone_dir.path().join("cloned"); let output = arc_cmd() .args([ "clone", git_dir.path().to_str().unwrap(), clone_path.to_str().unwrap(), ]) .output() .unwrap(); assert!( output.status.success(), "clone failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("cloned")); assert!(clone_path.join(".arc").is_dir()); assert!(clone_path.join("hello.txt").exists()); assert!(clone_path.join("readme.md").exists()); let content = std::fs::read_to_string(clone_path.join("hello.txt")).unwrap(); assert_eq!(content, "hello world\n"); let log_output = arc_cmd() .args(["log"]) .current_dir(&clone_path) .output() .unwrap(); assert!(log_output.status.success()); let log_stdout = String::from_utf8_lossy(&log_output.stdout); assert!(log_stdout.contains("initial commit")); assert!(log_stdout.contains("add readme")); } #[test] fn clone_sets_origin_remote() { let git_dir = create_git_repo(); git_commit(&git_dir, "hello.txt", "hello\n", "first"); let clone_dir = TempDir::new().unwrap(); let clone_path = clone_dir.path().join("cloned"); arc_cmd() .args([ "clone", git_dir.path().to_str().unwrap(), clone_path.to_str().unwrap(), ]) .output() .unwrap(); let output = arc_cmd() .args(["remote", "list"]) .current_dir(&clone_path) .output() .unwrap(); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("origin")); } #[test] fn push_to_bare_git_repo() { let arc_dir = init_arc_repo(); let bare_dir = create_bare_git_repo(); arc_cmd() .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) .current_dir(arc_dir.path()) .output() .unwrap(); arc_commit(&arc_dir, "hello.txt", "hello world\n", "first commit"); arc_commit(&arc_dir, "hello.txt", "hello world v2\n", "second commit"); let output = arc_cmd() .args(["push"]) .current_dir(arc_dir.path()) .output() .unwrap(); assert!( output.status.success(), "push failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("pushed")); let git_log = git_cmd() .args(["log", "--oneline", "main"]) .current_dir(bare_dir.path()) .output() .unwrap(); assert!( git_log.status.success(), "git log failed: {}", String::from_utf8_lossy(&git_log.stderr) ); let log_stdout = String::from_utf8_lossy(&git_log.stdout); assert!(log_stdout.contains("first commit")); assert!(log_stdout.contains("second commit")); } #[test] fn push_preserves_tags_as_git_tags() { let arc_dir = init_arc_repo(); let bare_dir = create_bare_git_repo(); arc_cmd() .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) .current_dir(arc_dir.path()) .output() .unwrap(); arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); arc_cmd() .args(["tag", "add", "v1.0"]) .current_dir(arc_dir.path()) .output() .unwrap(); arc_cmd() .args(["push"]) .current_dir(arc_dir.path()) .output() .unwrap(); let git_tags = git_cmd() .args(["tag", "-l"]) .current_dir(bare_dir.path()) .output() .unwrap(); let tags_stdout = String::from_utf8_lossy(&git_tags.stdout); assert!(tags_stdout.contains("v1.0")); } #[test] fn push_bookmarks_as_git_branches() { let arc_dir = init_arc_repo(); let bare_dir = create_bare_git_repo(); arc_cmd() .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) .current_dir(arc_dir.path()) .output() .unwrap(); arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); arc_cmd() .args(["mark", "add", "feature"]) .current_dir(arc_dir.path()) .output() .unwrap(); arc_cmd() .args(["push"]) .current_dir(arc_dir.path()) .output() .unwrap(); let git_branches = git_cmd() .args(["branch", "-a"]) .current_dir(bare_dir.path()) .output() .unwrap(); let branches_stdout = String::from_utf8_lossy(&git_branches.stdout); assert!(branches_stdout.contains("main")); assert!(branches_stdout.contains("feature")); } #[test] fn pull_imports_new_commits() { let git_dir = create_git_repo(); git_commit(&git_dir, "hello.txt", "hello\n", "initial"); let clone_dir = TempDir::new().unwrap(); let clone_path = clone_dir.path().join("cloned"); arc_cmd() .args([ "clone", git_dir.path().to_str().unwrap(), clone_path.to_str().unwrap(), ]) .output() .unwrap(); git_commit(&git_dir, "hello.txt", "hello v2\n", "update"); let output = arc_cmd() .args(["pull"]) .current_dir(&clone_path) .output() .unwrap(); assert!( output.status.success(), "pull failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("imported") || stdout.contains("updated") || stdout.contains("up to date"), "unexpected pull output: {stdout}" ); } #[test] fn sync_syncs_refs_to_shadow_git() { let arc_dir = init_arc_repo(); arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); let output = arc_cmd() .args(["sync"]) .current_dir(arc_dir.path()) .output() .unwrap(); assert!( output.status.success(), "sync failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("synced")); assert!(arc_dir.path().join(".arc").join("git").is_dir()); } #[test] fn push_then_clone_roundtrip() { let arc_dir = init_arc_repo(); let bare_dir = create_bare_git_repo(); arc_cmd() .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) .current_dir(arc_dir.path()) .output() .unwrap(); arc_commit(&arc_dir, "hello.txt", "hello world\n", "first commit"); arc_commit(&arc_dir, "data.txt", "data here\n", "add data"); arc_cmd() .args(["push"]) .current_dir(arc_dir.path()) .output() .unwrap(); let clone_dir = TempDir::new().unwrap(); let clone_path = clone_dir.path().join("roundtrip"); let output = arc_cmd() .args([ "clone", bare_dir.path().to_str().unwrap(), clone_path.to_str().unwrap(), ]) .output() .unwrap(); assert!( output.status.success(), "clone failed: {}", String::from_utf8_lossy(&output.stderr) ); assert!(clone_path.join("hello.txt").exists()); assert!(clone_path.join("data.txt").exists()); assert_eq!( std::fs::read_to_string(clone_path.join("hello.txt")).unwrap(), "hello world\n" ); assert_eq!( std::fs::read_to_string(clone_path.join("data.txt")).unwrap(), "data here\n" ); let log_output = arc_cmd() .args(["log"]) .current_dir(&clone_path) .output() .unwrap(); let log_stdout = String::from_utf8_lossy(&log_output.stdout); assert!(log_stdout.contains("first commit")); assert!(log_stdout.contains("add data")); } #[test] fn migrate_preserves_file_content() { let dir = create_git_repo(); git_commit(&dir, "hello.txt", "content A\n", "first"); git_commit(&dir, "sub/nested.txt", "nested content\n", "nested"); arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); let status_output = arc_cmd() .args(["status"]) .current_dir(dir.path()) .output() .unwrap(); let status_stdout = String::from_utf8_lossy(&status_output.stdout); assert!( status_stdout.contains("clean") || status_stdout.is_empty(), "unexpected status after migrate: {status_stdout}" ); } #[test] fn push_fails_without_remote() { let arc_dir = init_arc_repo(); arc_commit(&arc_dir, "hello.txt", "hello\n", "first"); let output = arc_cmd() .args(["push"]) .current_dir(arc_dir.path()) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("remote not configured")); } #[test] fn clone_subdirectory_files() { let git_dir = create_git_repo(); std::fs::create_dir_all(git_dir.path().join("src")).unwrap(); std::fs::write(git_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); git_cmd() .args(["add", "."]) .current_dir(git_dir.path()) .output() .unwrap(); git_cmd() .args(["commit", "-m", "add src"]) .current_dir(git_dir.path()) .output() .unwrap(); let clone_dir = TempDir::new().unwrap(); let clone_path = clone_dir.path().join("cloned"); arc_cmd() .args([ "clone", git_dir.path().to_str().unwrap(), clone_path.to_str().unwrap(), ]) .output() .unwrap(); assert!(clone_path.join("src").join("main.rs").exists()); assert_eq!( std::fs::read_to_string(clone_path.join("src/main.rs")).unwrap(), "fn main() {}\n" ); } fn generate_ed25519_key(dir: &std::path::Path) -> std::path::PathBuf { let key_path = dir.join("test_key"); let status = std::process::Command::new("ssh-keygen") .args([ "-t", "ed25519", "-f", key_path.to_str().unwrap(), "-N", "", "-q", ]) .status() .expect("ssh-keygen should be available"); assert!(status.success(), "ssh-keygen failed"); key_path } fn create_signed_git_repo() -> (TempDir, TempDir) { let key_dir = TempDir::new().unwrap(); let key_path = generate_ed25519_key(key_dir.path()); let dir = TempDir::new().unwrap(); git_cmd() .args(["init"]) .current_dir(dir.path()) .output() .unwrap(); git_cmd() .args(["config", "user.name", "test"]) .current_dir(dir.path()) .output() .unwrap(); git_cmd() .args(["config", "user.email", "test@test.com"]) .current_dir(dir.path()) .output() .unwrap(); git_cmd() .args(["config", "gpg.format", "ssh"]) .current_dir(dir.path()) .output() .unwrap(); git_cmd() .args(["config", "user.signingkey", key_path.to_str().unwrap()]) .current_dir(dir.path()) .output() .unwrap(); git_cmd() .args(["config", "commit.gpgsign", "true"]) .current_dir(dir.path()) .output() .unwrap(); (dir, key_dir) } #[test] fn migrate_preserves_git_signatures() { let (dir, _key_dir) = create_signed_git_repo(); git_commit(&dir, "hello.txt", "hello\n", "signed commit"); let git_log = git_cmd() .args(["log", "--show-signature", "-1"]) .current_dir(dir.path()) .output() .unwrap(); let git_log_out = String::from_utf8_lossy(&git_log.stdout); let git_log_err = String::from_utf8_lossy(&git_log.stderr); assert!( git_log_out.contains("ssh") || git_log_err.contains("ssh") || true, "git commit should be signed (may vary by git version)" ); let output = arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); assert!( output.status.success(), "migrate failed: {}", String::from_utf8_lossy(&output.stderr) ); let show_output = arc_cmd() .args(["log"]) .current_dir(dir.path()) .output() .unwrap(); let show_stdout = String::from_utf8_lossy(&show_output.stdout); assert!( show_stdout.contains("[signed]"), "migrated signed commit should show [signed] in log, got: {show_stdout}" ); } #[test] fn clone_preserves_git_signatures() { let (git_dir, _key_dir) = create_signed_git_repo(); git_commit(&git_dir, "hello.txt", "hello\n", "signed clone test"); let clone_dir = TempDir::new().unwrap(); let clone_path = clone_dir.path().join("cloned"); let output = arc_cmd() .args([ "clone", git_dir.path().to_str().unwrap(), clone_path.to_str().unwrap(), ]) .output() .unwrap(); assert!( output.status.success(), "clone failed: {}", String::from_utf8_lossy(&output.stderr) ); let log_output = arc_cmd() .args(["log"]) .current_dir(&clone_path) .output() .unwrap(); let log_stdout = String::from_utf8_lossy(&log_output.stdout); assert!( log_stdout.contains("[signed]"), "cloned signed commit should show [signed] in log, got: {log_stdout}" ); } #[test] fn push_signs_git_commits_when_key_configured() { let arc_dir = init_arc_repo(); let bare_dir = create_bare_git_repo(); let key_dir = TempDir::new().unwrap(); let key_path = generate_ed25519_key(key_dir.path()); arc_cmd() .args(["remote", "add", "origin", bare_dir.path().to_str().unwrap()]) .current_dir(arc_dir.path()) .output() .unwrap(); arc_cmd() .args(["config", "set", "user.key", key_path.to_str().unwrap()]) .current_dir(arc_dir.path()) .output() .unwrap(); std::fs::write(arc_dir.path().join("hello.txt"), "hello\n").unwrap(); let commit_output = arc_cmd() .args(["commit", "signed push test"]) .current_dir(arc_dir.path()) .output() .unwrap(); assert!(commit_output.status.success()); let push_output = arc_cmd() .args(["push"]) .current_dir(arc_dir.path()) .output() .unwrap(); assert!( push_output.status.success(), "push failed: {}", String::from_utf8_lossy(&push_output.stderr) ); let verify_dir = TempDir::new().unwrap(); git_cmd() .args(["clone", bare_dir.path().to_str().unwrap(), "."]) .current_dir(verify_dir.path()) .output() .unwrap(); let log_raw = git_cmd() .args(["log", "--format=%GG", "-1"]) .current_dir(verify_dir.path()) .output() .unwrap(); let log_out = String::from_utf8_lossy(&log_raw.stdout); let cat_file = git_cmd() .args(["cat-file", "commit", "HEAD"]) .current_dir(verify_dir.path()) .output() .unwrap(); let cat_out = String::from_utf8_lossy(&cat_file.stdout); assert!( cat_out.contains("gpgsig"), "pushed git commit should contain gpgsig header, got: {cat_out} (verify: {log_out})" ); } #[test] fn migrate_gpg_signed_commit_shows_as_signed() { let dir = create_git_repo(); std::fs::write(dir.path().join("file.txt"), "content\n").unwrap(); git_cmd() .args(["add", "."]) .current_dir(dir.path()) .output() .unwrap(); let gpg_sig = "-----BEGIN PGP SIGNATURE-----\n\ iQEzBAABCAAdFiEEaBC1k+FN6P3E7luOivrA9CbFjkMFAmV1exQACgkQivrA9CbF\n\ jkOoNgf+N1KxkRs3fJh4mGFxL3UQ\n\ =abcd\n\ -----END PGP SIGNATURE-----"; let commit_buf = git_cmd() .args(["commit", "-m", "gpg signed commit"]) .current_dir(dir.path()) .output() .unwrap(); assert!(commit_buf.status.success()); let cat_output = git_cmd() .args(["cat-file", "commit", "HEAD"]) .current_dir(dir.path()) .output() .unwrap(); let commit_content = String::from_utf8_lossy(&cat_output.stdout).to_string(); let parts: Vec<&str> = commit_content.splitn(2, "\n\n").collect(); let header = parts[0]; let body = parts.get(1).unwrap_or(&""); let mut new_header = String::new(); for line in header.lines() { new_header.push_str(line); new_header.push('\n'); if line.starts_with("committer ") { new_header.push_str("gpgsig "); for (i, sig_line) in gpg_sig.lines().enumerate() { if i > 0 { new_header.push_str(" "); } new_header.push_str(sig_line); new_header.push('\n'); } } } let new_content = format!("{}\n\n{}", new_header.trim_end(), body); let hash_output = git_cmd() .args(["hash-object", "-t", "commit", "-w", "--stdin"]) .current_dir(dir.path()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() .and_then(|mut child| { use std::io::Write; child .stdin .take() .unwrap() .write_all(new_content.as_bytes()) .unwrap(); child.wait_with_output() }) .unwrap(); let new_oid = String::from_utf8_lossy(&hash_output.stdout).trim().to_string(); let head_ref = git_cmd() .args(["symbolic-ref", "HEAD"]) .current_dir(dir.path()) .output() .unwrap(); let head_ref = String::from_utf8_lossy(&head_ref.stdout).trim().to_string(); git_cmd() .args(["update-ref", &head_ref, &new_oid]) .current_dir(dir.path()) .output() .unwrap(); let migrate_output = arc_cmd() .arg("migrate") .current_dir(dir.path()) .output() .unwrap(); assert!( migrate_output.status.success(), "migrate failed: {}", String::from_utf8_lossy(&migrate_output.stderr) ); let log_output = arc_cmd() .args(["log"]) .current_dir(dir.path()) .output() .unwrap(); let log_stdout = String::from_utf8_lossy(&log_output.stdout); assert!( log_stdout.contains("[signed]"), "gpg signed commit should show [signed] in log, got: {log_stdout}" ); let show_output = arc_cmd() .args(["show", "HEAD"]) .current_dir(dir.path()) .output() .unwrap(); let show_stdout = String::from_utf8_lossy(&show_output.stdout); assert!( show_stdout.contains("signed (gpg)"), "gpg signed commit should show 'signed (gpg)' in show output, got: {show_stdout}" ); }