use assert_cmd::Command; use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; /// A ginit run isolated from the machine it happens on: `init.defaultBranch`, /// `user.name`/`user.email` and QuiCommit's own config all come from throwaway /// locations, so expectations hold both on a developer box and in CI. struct Scenario { work: TempDir, home: TempDir, config: PathBuf, } impl Scenario { fn new() -> Scenario { let work = TempDir::new().unwrap(); let home = TempDir::new().unwrap(); let config = home.path().join("config.toml"); Scenario { work, home, config } } fn dir(&self) -> &Path { self.work.path() } fn command_in(&self, dir: &Path, args: &[&str]) -> Command { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.current_dir(dir) .env("HOME", self.home.path()) .env("USERPROFILE", self.home.path()) .env("XDG_CONFIG_HOME", self.home.path()) .arg("--config") .arg(&self.config) .args(args); cmd } fn command(&self, args: &[&str]) -> Command { self.command_in(self.dir(), args) } /// `--yes` is the non-interactive side of ADR-0004; every case uses it /// because dialoguer refuses to answer without a terminal. fn ginit(&self, args: &[&str]) -> Command { let mut args: Vec<&str> = args.to_vec(); args.push("--yes"); self.command(&args) } fn git(&self, args: &[&str]) -> String { self.git_in(self.dir(), args) } fn git_in(&self, dir: &Path, args: &[&str]) -> String { let output = std::process::Command::new("git") .args(args) .current_dir(dir) .env("HOME", self.home.path()) .env("USERPROFILE", self.home.path()) .env("XDG_CONFIG_HOME", self.home.path()) .output() .expect("git should be available"); assert!( output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8_lossy(&output.stdout).trim().to_string() } fn read(&self, name: &str) -> String { fs::read_to_string(self.dir().join(name)).unwrap_or_else(|e| panic!("{name}: {e}")) } /// QuiCommit's config is written by hand rather than produced by /// `init --yes`, because on Windows libgit2 resolves the global git /// config from the user profile folder and ignores `HOME`, so a profile /// created by `init` would inherit this machine's real identity. fn write_config(&self, contents: &str) { fs::write(&self.config, contents).unwrap(); } } #[test] fn test_ginit_creates_repository_readme_and_gitignore() { let scenario = Scenario::new(); scenario .ginit(&["ginit"]) .assert() .success() .stdout(predicate::str::contains("Repository initialized")); assert!(scenario.dir().join(".git").exists()); assert_eq!( scenario.git(&["symbolic-ref", "--short", "HEAD"]), "main", "with no init.defaultBranch configured, ginit starts on main" ); let workdir = scenario.dir(); let name = workdir.file_name().unwrap().to_string_lossy().to_string(); assert_eq!(scenario.read("README.md"), format!("# {name}\n")); let gitignore = scenario.read(".gitignore"); assert!( gitignore.contains(".env\n"), "the baseline must ignore .env, got: {gitignore}" ); assert!(gitignore.contains("# Build output")); } #[test] fn test_ginit_on_an_existing_repository_skips_creation_and_fills_gaps() { let scenario = Scenario::new(); scenario.git(&["init"]); fs::write(scenario.dir().join("README.md"), "# mine\n").unwrap(); // A repository whose HEAD has no commit yet still has a branch, and that // branch is what ginit must report as untouched. scenario.git(&["symbolic-ref", "HEAD", "refs/heads/trunk"]); scenario .ginit(&["ginit"]) .assert() .success() // The branch line must name the branch that exists, not the default it // would have used for a fresh repository. .stdout(predicate::str::contains( "trunk (existing repository, branch untouched)", )); assert_eq!(scenario.read("README.md"), "# mine\n"); assert!( scenario.dir().join(".gitignore").exists(), "the missing .gitignore should have been added" ); } #[test] fn test_ginit_never_touches_existing_start_files() { let scenario = Scenario::new(); fs::write(scenario.dir().join("README.md"), "# mine\n").unwrap(); fs::write(scenario.dir().join(".gitignore"), "keep-me/\n").unwrap(); scenario .ginit(&["ginit"]) .assert() .success() .stdout(predicate::str::contains(".gitignore sections").not()); assert_eq!(scenario.read("README.md"), "# mine\n"); assert_eq!( scenario.read(".gitignore"), "keep-me/\n", "ginit must not append to a maintained .gitignore" ); } #[test] fn test_ginit_branch_flag_sets_the_initial_branch() { let scenario = Scenario::new(); scenario.ginit(&["ginit", "-b", "dev"]).assert().success(); assert_eq!(scenario.git(&["symbolic-ref", "--short", "HEAD"]), "dev"); } #[test] fn test_ginit_refuses_to_rename_a_branch_that_has_commits() { let scenario = Scenario::new(); scenario.git(&["init"]); scenario.git(&["config", "user.name", "Test User"]); scenario.git(&["config", "user.email", "test@example.com"]); scenario.git(&["config", "commit.gpgsign", "false"]); scenario.git(&["commit", "--allow-empty", "-m", "first"]); let branch = scenario.git(&["symbolic-ref", "--short", "HEAD"]); scenario .ginit(&["ginit", "-b", "experiment"]) .assert() .success() .stdout(predicate::str::contains( "already has commits, branch unchanged", )); assert_eq!(scenario.git(&["symbolic-ref", "--short", "HEAD"]), branch); } #[test] fn test_ginit_rejects_a_directory_inside_another_repository() { let scenario = Scenario::new(); scenario.git(&["init"]); let nested = scenario.dir().join("inner"); fs::create_dir(&nested).unwrap(); scenario .command_in(&nested, &["ginit", "--yes"]) .assert() .failure() .stderr(predicate::str::contains("inside the repository")); assert!( !nested.join(".git").exists(), "the nested directory must not become a repository" ); assert!( !nested.join("README.md").exists(), "a refused run must write nothing at all" ); } #[test] fn test_ginit_add_names_the_new_remote_origin() { let scenario = Scenario::new(); scenario .ginit(&["ginit", "--add", "https://example.com/origin.git"]) .assert() .success(); let remotes = scenario.git(&["remote", "-v"]); assert!( remotes.starts_with("origin"), "a bare URL should become origin, got: {remotes}" ); assert!(remotes.contains("https://example.com/origin.git")); } #[test] fn test_ginit_add_accepts_named_remotes_and_repeats() { let scenario = Scenario::new(); scenario .ginit(&[ "ginit", "--add", "upstream=https://example.com/upstream.git", "--add", "origin=https://example.com/origin.git", ]) .assert() .success(); let remotes = scenario.git(&["remote", "-v"]); assert!(remotes.contains("upstream"), "got: {remotes}"); assert!(remotes.contains("origin"), "got: {remotes}"); } #[test] fn test_ginit_refuses_to_rewrite_an_existing_remote_url() { let scenario = Scenario::new(); scenario .ginit(&["ginit", "--add", "https://example.com/first.git"]) .assert() .success(); scenario .ginit(&["ginit", "--add", "https://example.com/second.git"]) .assert() .failure() .stderr(predicate::str::contains("set-url")); let remotes = scenario.git(&["remote", "-v"]); assert!( remotes.contains("https://example.com/first.git"), "the existing URL must survive, got: {remotes}" ); assert!(!remotes.contains("second.git"), "got: {remotes}"); } #[test] fn test_ginit_rejects_an_add_value_without_a_url() { let scenario = Scenario::new(); scenario .ginit(&["ginit", "--add", "origin"]) .assert() .failure() .stderr(predicate::str::contains("--add 'origin' has no URL")); assert!( !scenario.dir().join(".git").exists(), "a rejected flag must stop the whole run" ); } #[test] fn test_ginit_gitignore_sections_follow_the_markers_in_the_directory() { struct Expected { markers: &'static [&'static str], present: &'static [&'static str], absent: &'static [&'static str], } let cases = [ Expected { markers: &[], present: &["# Build output", "tmp/", ".env"], absent: &["# Rust", "# Node"], }, Expected { markers: &["Cargo.toml"], present: &["# Rust", "target/"], absent: &["Cargo.lock", "# Node"], }, Expected { markers: &["Cargo.toml", "package.json"], present: &["# Rust", "# Node", "node_modules/"], absent: &[], }, Expected { markers: &["pyproject.toml"], present: &["# Python", "__pycache__/"], absent: &["# Ruby"], }, Expected { markers: &["go.mod", "composer.json"], present: &["# Go", "vendor/"], absent: &["# PHP"], }, Expected { markers: &["Cargo.toml", "pom.xml"], present: &["# Rust"], absent: &["# Maven"], }, ]; for case in cases { let scenario = Scenario::new(); for marker in case.markers { fs::write(scenario.dir().join(marker), "").unwrap(); } let assertion = scenario.ginit(&["ginit"]).assert().success(); let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string(); let gitignore = scenario.read(".gitignore"); for expected in case.present { assert!( gitignore.contains(expected), "{:?} should contain {expected}, got: {gitignore}", case.markers ); } for unexpected in case.absent { assert!( !gitignore.contains(unexpected), "{:?} should not contain {unexpected}, got: {gitignore}", case.markers ); } // The preview promises exactly what the file delivers: a section the // cross-section dedupe dropped (PHP behind Go, Maven behind Rust) may // not be listed as if it were going to be written. assert!( stdout.contains(".gitignore sections:"), "the preview should list the sections, got: {stdout}" ); for title in case.present.iter().filter(|line| line.starts_with('#')) { assert!( stdout.contains(title), "the preview should promise {title}, got: {stdout}" ); } for title in case.absent.iter().filter(|line| line.starts_with('#')) { assert!( !stdout.contains(title), "the preview must not promise the dropped {title}, got: {stdout}" ); } assert_eq!( gitignore.matches("dist/").count(), 1, "entries shared with the baseline appear once, got: {gitignore}" ); assert!( !gitignore.contains("\n\n\n"), "empty sections must be omitted, got: {gitignore}" ); } } #[test] fn test_ginit_dry_run_writes_nothing() { let scenario = Scenario::new(); scenario .ginit(&[ "ginit", "--dry-run", "-b", "dev", "--add", "https://example.com/origin.git", ]) .assert() .success() .stdout(predicate::str::contains("Dry run")) .stdout(predicate::str::contains("dev")); assert!( !scenario.dir().join(".git").exists(), "a dry run must not even create the repository" ); assert!(!scenario.dir().join("README.md").exists()); assert!(!scenario.dir().join(".gitignore").exists()); } #[test] fn test_ginit_yes_keeps_every_planned_line_of_the_output() { let scenario = Scenario::new(); fs::write(scenario.dir().join("Cargo.toml"), "").unwrap(); let output = scenario .ginit(&["ginit", "--add", "https://example.com/origin.git"]) .output() .unwrap(); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); // ADR-0004: --yes only removes the question, so the same artifacts the // preview promised are still reported and still on disk. for expected in ["README.md", ".gitignore", "origin", "Initial branch"] { assert!(stdout.contains(expected), "stdout was: {stdout}"); } assert!( !stdout.contains("Create these now?"), "--yes must not prompt, stdout was: {stdout}" ); assert!(scenario.read(".gitignore").contains("# Rust")); assert!(scenario.git(&["remote", "-v"]).contains("origin")); } #[test] fn test_ginit_readme_title_survives_spaces_and_unicode() { for name in ["my project", "中文 项目", "Ünïcödé-ß"] { let scenario = Scenario::new(); let titled = scenario.dir().join(name); fs::create_dir(&titled).unwrap(); scenario .command_in(&titled, &["ginit", "--yes"]) .assert() .success(); let readme = fs::read_to_string(titled.join("README.md")).unwrap(); assert_eq!(readme, format!("# {name}\n")); } } #[test] fn test_ginit_profile_applies_identity_and_records_the_mapping() { let scenario = Scenario::new(); scenario.write_config( r#"version = "1" [profiles.work] name = "work" user_name = "Work User" user_email = "work@example.com" "#, ); scenario .ginit(&["ginit", "--profile", "work"]) .assert() .success(); assert_eq!( scenario.git(&["config", "--local", "user.email"]), "work@example.com" ); let config = fs::read_to_string(&scenario.config).unwrap(); assert!( config.contains("[repo_profiles]"), "the directory should be mapped to the profile, got: {config}" ); } #[test] fn test_ginit_missing_profile_fails_without_writing_config() { let scenario = Scenario::new(); scenario.write_config( r#"version = "1" [profiles.work] name = "work" user_name = "Work User" user_email = "work@example.com" "#, ); let config_before = fs::read_to_string(&scenario.config).unwrap(); scenario .ginit(&["ginit", "--profile", "nope"]) .assert() .failure() .stderr(predicate::str::contains("profile add")); assert_eq!( fs::read_to_string(&scenario.config).unwrap(), config_before, "a rejected profile must leave the config untouched" ); } #[test] fn test_ginit_hints_at_pushing_after_the_first_commit() { let scenario = Scenario::new(); scenario .ginit(&["ginit", "--add", "https://example.com/origin.git"]) .assert() .success() // The label already ends in a colon; matching the newline guards the // doubled colon that `println!("{}:", …)` would produce. .stdout(predicate::str::contains("Next steps:\n")) .stdout(predicate::str::contains("quicommit commit --push")); } #[test] fn test_ginit_on_a_bare_repository_skips_files_and_still_adds_the_remote() { let scenario = Scenario::new(); scenario.git(&["init", "--bare"]); scenario .ginit(&["ginit", "--add", "https://example.com/mirror.git"]) .assert() .success(); assert!(!scenario.dir().join("README.md").exists()); assert!(!scenario.dir().join(".gitignore").exists()); assert!( scenario .git(&["config", "--local", "remote.origin.url"]) .contains("https://example.com/mirror.git") ); }