Files
QuiCommit/tests/ginit_tests.rs
SidneyZhang c2f914307d feat(ginit): 新增一键初始化本地 Git 仓库命令
`quicommit ginit`(别名 `gi`)离线完成仓库起步:用 git2 建库并确立初始分支、
写最简 README.md、按根目录标志文件装配 .gitignore、按 --add 添加远程,
`--profile` 可选套用已有 Git 配置。在已有仓库上重复执行只补齐缺失项:绝不覆盖
既有的 README.md/.gitignore、绝不改动已有 remote 的地址、不提供 --force、
不创建首个提交。边界取舍见 ADR 0006,命令命名见 ADR 0005。

实现落在新的 src/git/init.rs(9 个 git 层函数)与 src/commands/ginit.rs,
命令层不直接引用 git2,GitRepo 公开接口未改动,其余命令行为不变。
测试:src/git/init.rs 11 条单元测试、tests/ginit_tests.rs 18 条集成测试;
新增 35 个 ginit_* 消息覆盖 7 种语言。

另将 CONTEXT.md 与 docs/adr/ 纳入版本跟踪(.gitignore 放开 adr/ 与
CONTEXT.md 两条忽略规则);RAODMAP.md 除 ginit 条目外还包含此前未提交的
规划条目更新。
2026-10-09 09:36:04 +08:00

561 lines
16 KiB
Rust

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")
);
}