use crate::core::graph::Theme;
use crate::core::hunk_select::HunkArgs;
use crate::core::test_helpers::TestRepo;
fn setup_with_woven_branch() -> TestRepo {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feature-a", &base_oid.to_string());
test_repo.switch_branch("feature-a");
test_repo.commit("Add feature file", "feature.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
test_repo
}
fn is_staged(porcelain: &str, filename: &str) -> bool {
porcelain.lines().any(|line| {
let x = line.chars().next().unwrap_or(' ');
(x == 'A' || x == 'M') && line.ends_with(filename)
})
}
fn run_add(files: Vec<String>) -> anyhow::Result<()> {
let theme = Theme::dark();
super::run(files, false, HunkArgs::default(), vec![], &theme)
}
#[test]
fn add_single_file_by_name() {
let test_repo = TestRepo::new();
test_repo.write_file("hello.txt", "hello world");
let result = test_repo.in_dir(|| run_add(vec!["hello.txt".to_string()]));
assert!(result.is_ok(), "add failed: {:?}", result);
let status = test_repo.status_porcelain();
assert!(
is_staged(&status, "hello.txt"),
"hello.txt should be staged; status: {}",
status
);
}
#[test]
fn add_single_file_by_shortid() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("feature.txt", "modified content");
let short_id = test_repo.in_dir(|| {
let info = crate::core::repo::gather_repo_info(&test_repo.repo, false, 1).unwrap();
let entities = info.collect_entities();
let allocator = crate::core::shortid::IdAllocator::new(entities);
allocator.get_file("feature.txt").to_string()
});
let result = test_repo.in_dir(|| run_add(vec![short_id.clone()]));
assert!(
result.is_ok(),
"add by short ID '{}' failed: {:?}",
short_id,
result
);
let status = test_repo.status_porcelain();
assert!(
is_staged(&status, "feature.txt"),
"feature.txt should be staged; status: {}",
status
);
}
#[test]
fn add_multiple_files() {
let test_repo = TestRepo::new();
test_repo.write_file("a.txt", "aaa");
test_repo.write_file("b.txt", "bbb");
let result = test_repo.in_dir(|| run_add(vec!["a.txt".to_string(), "b.txt".to_string()]));
assert!(result.is_ok(), "add multiple failed: {:?}", result);
let status = test_repo.status_porcelain();
assert!(
is_staged(&status, "a.txt"),
"a.txt should be staged; status: {}",
status
);
assert!(
is_staged(&status, "b.txt"),
"b.txt should be staged; status: {}",
status
);
}
#[test]
fn add_zz_stages_all() {
let test_repo = TestRepo::new();
test_repo.write_file("one.txt", "1");
test_repo.write_file("two.txt", "2");
test_repo.write_file("three.txt", "3");
let result = test_repo.in_dir(|| run_add(vec!["zz".to_string()]));
assert!(result.is_ok(), "add zz failed: {:?}", result);
let status = test_repo.status_porcelain();
assert!(
is_staged(&status, "one.txt"),
"one.txt should be staged; status: {}",
status
);
assert!(
is_staged(&status, "two.txt"),
"two.txt should be staged; status: {}",
status
);
assert!(
is_staged(&status, "three.txt"),
"three.txt should be staged; status: {}",
status
);
}
#[test]
fn add_nonexistent_file_errors() {
let test_repo = TestRepo::new();
let result = test_repo.in_dir(|| run_add(vec!["nope.txt".to_string()]));
assert!(result.is_err(), "expected error for nonexistent file");
}
#[test]
fn add_invalid_shortid_errors() {
let test_repo = TestRepo::new();
let result = test_repo.in_dir(|| run_add(vec!["zq".to_string()]));
assert!(result.is_err(), "expected error for invalid short ID");
}
#[test]
fn add_patch_flag_placeholder() {
let test_repo = TestRepo::new();
let theme = Theme::dark();
let result = test_repo.in_dir(|| super::run(vec![], true, HunkArgs::default(), vec![], &theme));
match &result {
Ok(()) => {}
Err(e) if e.to_string() == "Cancelled" => {}
Err(e) => panic!("add -p should not crash: {e:?}"),
}
}
#[test]
fn collect_entries_includes_untracked_subdirs() {
let test_repo = TestRepo::new();
let subdir = test_repo.workdir().join("subdir");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(subdir.join("a.txt"), "aaa\n").unwrap();
std::fs::write(subdir.join("b.txt"), "bbb\n").unwrap();
test_repo.write_file("root.txt", "root\n");
let workdir = test_repo.repo.workdir().expect("not bare").to_path_buf();
let entries = test_repo
.in_dir(|| crate::core::staging::collect_file_entries(&test_repo.repo, &workdir, None));
let entries = entries.unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert!(
paths.contains(&"root.txt"),
"expected root.txt, got: {:?}",
paths
);
assert!(
paths.contains(&"subdir/a.txt"),
"expected subdir/a.txt, got: {:?}",
paths
);
assert!(
paths.contains(&"subdir/b.txt"),
"expected subdir/b.txt, got: {:?}",
paths
);
for entry in &entries {
assert!(
!entry.hunks.is_empty(),
"file '{}' should have at least one hunk",
entry.path
);
}
}
#[test]
fn collect_entries_includes_empty_untracked_files() {
let test_repo = TestRepo::new();
test_repo.write_file("empty.txt", "");
let subdir = test_repo.workdir().join("newdir");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(subdir.join("also_empty.txt"), "").unwrap();
let workdir = test_repo.repo.workdir().expect("not bare").to_path_buf();
let entries = test_repo
.in_dir(|| crate::core::staging::collect_file_entries(&test_repo.repo, &workdir, None));
let entries = entries.unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert!(
paths.contains(&"empty.txt"),
"expected empty.txt, got: {:?}",
paths
);
assert!(
paths.contains(&"newdir/also_empty.txt"),
"expected newdir/also_empty.txt, got: {:?}",
paths
);
}
#[test]
fn add_file_whose_deletion_is_already_staged() {
let test_repo = TestRepo::new();
test_repo.commit("Add file", "gone.txt");
std::fs::remove_file(test_repo.workdir().join("gone.txt")).unwrap();
test_repo.stage_files(&["gone.txt"]);
let result = test_repo.in_dir(|| run_add(vec!["gone.txt".to_string()]));
assert!(result.is_ok(), "add failed: {:?}", result);
assert_eq!(test_repo.status_porcelain().trim(), "D gone.txt");
}
#[test]
fn add_forwards_an_option_after_the_separator() {
let test_repo = setup_with_woven_branch();
test_repo.write_file(
".gitignore",
"ignored.txt
",
);
test_repo.write_file("ignored.txt", "content");
let theme = Theme::dark();
let result = test_repo.in_dir(|| {
super::run(
vec!["ignored.txt".to_string()],
false,
HunkArgs::default(),
vec!["-f".to_string()],
&theme,
)
});
assert!(
result.is_ok(),
"add should forward -f to git add: {result:?}"
);
assert!(
is_staged(&test_repo.status_porcelain(), "ignored.txt"),
"-f must reach git add, which is the only way an ignored file stages"
);
}
#[test]
fn add_unknown_option_reaches_git() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("feature.txt", "changed");
let theme = Theme::dark();
let result = test_repo.in_dir(|| {
super::run(
vec!["feature.txt".to_string()],
false,
HunkArgs::default(),
vec!["--definitely-not-a-git-option".to_string()],
&theme,
)
});
assert!(
result.is_err(),
"git should reject an option loom passed through"
);
}
#[test]
fn add_patch_rejects_forwarded_arguments() {
let test_repo = setup_with_woven_branch();
let theme = Theme::dark();
let result = test_repo.in_dir(|| {
super::run(
vec![],
true,
HunkArgs::default(),
vec!["-f".to_string()],
&theme,
)
});
let err = result.unwrap_err().to_string();
assert!(
err.contains("takes no `git add` arguments"),
"unexpected error: {err}"
);
}
#[test]
fn add_does_not_claim_success_for_a_forwarded_dry_run() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("feature.txt", "changed");
let theme = Theme::dark();
let result = test_repo.in_dir(|| {
super::run(
vec!["feature.txt".to_string()],
false,
HunkArgs::default(),
vec!["--dry-run".to_string()],
&theme,
)
});
assert!(
result.is_ok(),
"--dry-run is a valid git add option: {result:?}"
);
assert!(
!is_staged(&test_repo.status_porcelain(), "feature.txt"),
"--dry-run stages nothing, so loom must not have staged it either"
);
}
#[test]
fn add_without_files_rejects_forwarded_arguments_without_blaming_patch() {
let test_repo = setup_with_woven_branch();
let theme = Theme::dark();
let result = test_repo.in_dir(|| {
super::run(
vec![],
false,
HunkArgs::default(),
vec!["-f".to_string()],
&theme,
)
});
let err = result.unwrap_err().to_string();
assert!(
err.contains("staging hunks interactively"),
"the message must not blame a `-p` the user never typed: {err}"
);
assert!(
err.contains("Files go before the separator"),
"`loom add -- file.txt` needs to be told where files go: {err}"
);
}