use assert_cmd::prelude::*;
use prview::git::git_cmd;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn run_git(repo: &Path, args: &[&str]) {
let status = git_cmd()
.args(args)
.current_dir(repo)
.status()
.expect("failed to run git");
assert!(status.success(), "git {:?} failed", args);
}
fn write(repo: &Path, rel: &str, content: &str) {
let path = repo.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
fn init_repo() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path();
run_git(repo, &["init", "-b", "main"]);
run_git(repo, &["config", "user.email", "test@test.com"]);
run_git(repo, &["config", "user.name", "Test"]);
dir
}
fn commit_all(repo: &Path, msg: &str) {
run_git(repo, &["add", "-A"]);
run_git(repo, &["commit", "-m", msg]);
}
fn prview_scope(repo: &Path, args: &[&str]) -> assert_cmd::assert::Assert {
Command::new(assert_cmd::cargo::cargo_bin!("prview"))
.current_dir(repo)
.arg("scope")
.args(args)
.assert()
}
#[test]
fn include_only_keeps_matching_files() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "fn main() {}\n");
write(repo, "docs/readme.md", "# docs\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/app.rs", "fn main() { println!(\"hi\"); }\n");
write(repo, "docs/readme.md", "# docs\nmore\n");
commit_all(repo, "feature change");
let out = repo.join("pack");
prview_scope(
repo,
&[
"--include",
"src/**",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let full = std::fs::read_to_string(out.join("full.patch")).expect("full.patch");
assert!(full.contains("src/app.rs"), "src must be in pack:\n{full}");
assert!(
!full.contains("docs/readme.md"),
"docs must be excluded:\n{full}"
);
assert!(out.join("SCOPE.md").exists());
}
#[test]
fn exclude_only_drops_matching_files() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "a\n");
write(repo, "docs/readme.md", "d\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/app.rs", "a\nb\n");
write(repo, "docs/readme.md", "d\ne\n");
commit_all(repo, "feature change");
let out = repo.join("pack");
prview_scope(
repo,
&[
"--exclude",
"docs/**",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let full = std::fs::read_to_string(out.join("full.patch")).expect("full.patch");
assert!(full.contains("src/app.rs"));
assert!(!full.contains("docs/readme.md"), "docs excluded:\n{full}");
}
#[test]
fn wip_exclude_only_captures_wip_only_file() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/stable.rs", "stable\n");
write(repo, "docs/readme.md", "d\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "docs/readme.md", "d\nmore\n");
commit_all(repo, "docs only commit");
write(repo, "src/stable.rs", "stable\nWIP EDIT\n");
let out = repo.join("pack");
prview_scope(
repo,
&[
"--exclude",
"docs/**",
"--wip",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let wip = std::fs::read_to_string(out.join("wip.patch"))
.expect("wip.patch must exist for exclude-only WIP-only change");
assert!(
wip.contains("src/stable.rs"),
"WIP src change must be captured:\n{wip}"
);
assert!(wip.contains("WIP EDIT"));
assert!(
!wip.contains("docs/readme.md"),
"docs WIP must be excluded:\n{wip}"
);
}
#[test]
fn scope_runs_from_subdirectory() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "a\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/app.rs", "a\nb\n");
commit_all(repo, "change");
let subdir = repo.join("src");
let assert = Command::new(assert_cmd::cargo::cargo_bin!("prview"))
.current_dir(&subdir)
.arg("scope")
.args(["--include", "src/**", "--base", "main", "-o", "pack"])
.assert()
.success();
let _ = assert;
let full = std::fs::read_to_string(subdir.join("pack/full.patch")).expect("full.patch");
assert!(full.contains("src/app.rs"), "src change captured:\n{full}");
}
#[test]
fn scope_diffs_from_merge_base_not_base_tip() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "orig\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/app.rs", "orig\nfeature edit\n");
commit_all(repo, "feature change");
run_git(repo, &["checkout", "main"]);
write(
repo,
"src/base_only.rs",
"landed on base after divergence\n",
);
commit_all(repo, "base-only change");
run_git(repo, &["checkout", "feature"]);
let out = repo.join("pack");
prview_scope(
repo,
&[
"--include",
"src/**",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let full = std::fs::read_to_string(out.join("full.patch")).expect("full.patch");
assert!(
full.contains("src/app.rs"),
"feature's own change must be present:\n{full}"
);
assert!(
!full.contains("base_only.rs"),
"base-only change must NOT leak into the scope pack (merge-base diff):\n{full}"
);
}
#[test]
fn empty_committed_scope_with_wip_does_not_leak_all_commits() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "a\n");
write(repo, "docs/readme.md", "d\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "docs/readme.md", "d\nmore\n");
commit_all(repo, "docs only commit");
write(repo, "src/app.rs", "a\nWIP\n");
let out = repo.join("pack");
prview_scope(
repo,
&[
"--include",
"src/**",
"--wip",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let per_commit_count = std::fs::read_dir(out.join("per-commit"))
.map(|rd| rd.filter_map(|e| e.ok()).count())
.unwrap_or(0);
assert_eq!(
per_commit_count, 0,
"empty committed scope must yield zero per-commit patches"
);
let commits_log = std::fs::read_to_string(out.join("commits.log")).unwrap_or_default();
assert!(
!commits_log.contains("docs only commit"),
"unrelated commit must not leak into commits.log:\n{commits_log}"
);
let full = std::fs::read_to_string(out.join("full.patch")).unwrap_or_default();
assert!(
!full.contains("docs/readme.md"),
"docs must not leak into full.patch:\n{full}"
);
let wip = std::fs::read_to_string(out.join("wip.patch")).expect("wip.patch");
assert!(
wip.contains("src/app.rs"),
"WIP src change captured:\n{wip}"
);
}
#[test]
fn scoped_full_patch_preserves_rename_old_path() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/old_name.rs", "fn a() {}\nfn b() {}\nfn c() {}\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
run_git(repo, &["mv", "src/old_name.rs", "src/new_name.rs"]);
commit_all(repo, "rename module");
let out = repo.join("pack");
prview_scope(
repo,
&[
"--include",
"src/**",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let full = std::fs::read_to_string(out.join("full.patch")).expect("full.patch");
assert!(
full.contains("new_name.rs"),
"new path must be present:\n{full}"
);
assert!(
full.contains("old_name.rs"),
"old (pre-rename) path must remain visible in the scoped patch:\n{full}"
);
}
#[test]
fn scope_md_lists_wip_only_files() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/stable.rs", "stable\n");
write(repo, "docs/readme.md", "d\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "docs/readme.md", "d\nmore\n");
commit_all(repo, "docs only commit");
write(repo, "src/stable.rs", "stable\nWIP EDIT\n");
let out = repo.join("pack");
prview_scope(
repo,
&[
"--exclude",
"docs/**",
"--wip",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let scope_md = std::fs::read_to_string(out.join("SCOPE.md")).expect("SCOPE.md");
assert!(
scope_md.contains("src/stable.rs"),
"WIP-only file must be listed in SCOPE.md:\n{scope_md}"
);
assert!(
scope_md.contains("(WIP)"),
"WIP-only file must be marked as WIP:\n{scope_md}"
);
}
#[test]
fn wip_captures_untracked_file() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "a\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/brand_new.rs", "fn fresh() {}\n");
let out = repo.join("pack");
prview_scope(
repo,
&[
"--include",
"src/**",
"--wip",
"--base",
"main",
"-o",
out.to_str().unwrap(),
],
)
.success();
let wip = std::fs::read_to_string(out.join("wip.patch")).expect("wip.patch");
assert!(
wip.contains("src/brand_new.rs"),
"untracked WIP file must be captured:\n{wip}"
);
assert!(
wip.contains("fn fresh()"),
"content must be present:\n{wip}"
);
}
#[test]
fn merge_commits_are_skipped() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/base.rs", "base\n");
commit_all(repo, "init");
let base_sha = {
let o = git_cmd()
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
run_git(repo, &["checkout", "-b", "topic"]);
write(repo, "src/topic.rs", "topic\n");
commit_all(repo, "topic commit");
run_git(repo, &["checkout", "main"]);
write(repo, "src/main2.rs", "main2\n");
commit_all(repo, "main commit");
run_git(repo, &["merge", "--no-ff", "-m", "Merge topic", "topic"]);
let out = repo.join("pack");
prview_scope(
repo,
&[
"--include",
"src/**",
"--base",
&base_sha,
"-o",
out.to_str().unwrap(),
],
)
.success();
let per_commit = out.join("per-commit");
let count = std::fs::read_dir(&per_commit)
.map(|rd| rd.filter_map(|e| e.ok()).count())
.unwrap_or(0);
assert_eq!(
count, 2,
"merge commit must be skipped, expected 2 per-commit patches"
);
let commits_log = std::fs::read_to_string(out.join("commits.log")).unwrap_or_default();
assert!(
!commits_log.contains("Merge topic"),
"merge commit must not appear in commits.log:\n{commits_log}"
);
}
#[test]
fn output_dir_guard_refuses_non_pack() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "a\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/app.rs", "a\nb\n");
commit_all(repo, "change");
write(repo, "important/keep.txt", "do not delete me\n");
prview_scope(
repo,
&["--include", "src/**", "--base", "main", "-o", "important"],
)
.failure();
assert!(
repo.join("important/keep.txt").exists(),
"guard must not delete a non-pack directory"
);
}
#[test]
fn output_dir_guard_refuses_bare_scope_md() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "a\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/app.rs", "a\nb\n");
commit_all(repo, "change");
write(repo, "notes/SCOPE.md", "my project scope, do not delete\n");
write(repo, "notes/keep.txt", "precious\n");
prview_scope(
repo,
&["--include", "src/**", "--base", "main", "-o", "notes"],
)
.failure();
assert!(
repo.join("notes/keep.txt").exists(),
"guard must not delete a dir carrying only a bare SCOPE.md"
);
assert!(repo.join("notes/SCOPE.md").exists());
}
#[test]
fn output_dir_guard_allows_rerun_of_prior_pack() {
let dir = init_repo();
let repo = dir.path();
write(repo, "src/app.rs", "a\n");
commit_all(repo, "init");
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "src/app.rs", "a\nb\n");
commit_all(repo, "change");
let out = repo.join("pack");
let args = &[
"--include",
"src/**",
"--base",
"main",
"-o",
out.to_str().unwrap(),
];
prview_scope(repo, args).success();
assert!(
out.join(".prview-scope-pack.json").exists(),
"marker written"
);
prview_scope(repo, args).success();
assert!(out.join("full.patch").exists());
}