`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 条目外还包含此前未提交的 规划条目更新。
561 lines
16 KiB
Rust
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")
|
|
);
|
|
}
|