use crate::core::graph;
use crate::core::test_helpers::TestRepo;
fn run(branch: Option<String>, message: Option<String>, files: Vec<String>) -> anyhow::Result<()> {
super::run(branch, message, false, files, &graph::Theme::dark())
}
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
}
fn setup_with_two_branches() -> 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("A1", "a1.txt");
test_repo.switch_branch("integration");
test_repo.create_branch_at("feature-b", &base_oid.to_string());
test_repo.switch_branch("feature-b");
test_repo.commit("B1", "b1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
test_repo.merge_no_ff("feature-b");
test_repo
}
#[test]
fn commit_stages_specific_file() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("new.txt", "content");
test_repo.write_file("other.txt", "other");
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Add new file".to_string()),
vec!["new.txt".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
assert_eq!(test_repo.branch_commit_summary("feature-a"), "Add new file");
}
#[test]
fn commit_stages_zz_all_changes() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("file1.txt", "content1");
test_repo.write_file("file2.txt", "content2");
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Add files".to_string()),
vec!["zz".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
assert_eq!(test_repo.branch_commit_summary("feature-a"), "Add files");
}
#[test]
fn commit_uses_already_staged() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("staged.txt", "content");
test_repo.stage_files(&["staged.txt"]);
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Use staged".to_string()),
vec![], )
});
assert!(result.is_ok(), "commit failed: {:?}", result);
assert_eq!(test_repo.branch_commit_summary("feature-a"), "Use staged");
}
#[test]
fn commit_empty_index_fails() {
let test_repo = setup_with_woven_branch();
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Message".to_string()),
vec![],
)
});
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Nothing to commit")
);
}
#[test]
fn commit_to_non_woven_branch_fails() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit("A1", "a1.txt");
test_repo.create_branch_tracking("not-woven", "origin/main");
test_repo.write_file("file.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("not-woven".to_string()),
Some("Message".to_string()),
vec!["file.txt".to_string()],
)
});
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not woven"));
}
#[test]
fn commit_to_new_branch_creates_and_weaves() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("new.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("feature-new".to_string()),
Some("Add file".to_string()),
vec!["new.txt".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
assert!(test_repo.branch_exists("feature-new"));
assert_eq!(test_repo.branch_commit_summary("feature-new"), "Add file");
let head = test_repo.head_commit();
assert_eq!(
head.parent_count(),
2,
"HEAD should be a merge commit with 2 parents"
);
let second_parent = head.parent(1).unwrap();
assert_eq!(second_parent.summary().unwrap(), "Add file");
}
#[test]
fn commit_to_empty_branch_creates_merge_topology() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("new.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("New commit".to_string()),
vec!["new.txt".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
let branch_oid = test_repo.get_branch_target("feature-a");
let commit = test_repo.find_commit(branch_oid);
assert_eq!(commit.summary().unwrap(), "New commit");
let head = test_repo.head_commit();
assert_eq!(
head.parent_count(),
2,
"HEAD should be a merge commit (branch woven into integration)"
);
let second_parent = head.parent(1).unwrap();
assert_eq!(second_parent.id(), branch_oid);
}
fn setup_with_one_woven_one_empty() -> 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("A1", "a1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feature-a");
test_repo.create_branch_at("feature-b", &base_oid.to_string());
test_repo
}
#[test]
fn commit_to_second_empty_branch_creates_parallel_topology() {
let test_repo = setup_with_one_woven_one_empty();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.write_file("b1.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("feature-b".to_string()),
Some("B1".to_string()),
vec!["b1.txt".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
let branch_b_oid = test_repo.get_branch_target("feature-b");
let commit_b = test_repo.find_commit(branch_b_oid);
assert_eq!(commit_b.summary().unwrap(), "B1");
let parent = commit_b.parent(0).unwrap();
assert_eq!(
parent.id(),
base_oid,
"feature-b's commit should fork from merge-base, not from the merge commit"
);
let head = test_repo.head_commit();
assert_eq!(head.parent_count(), 2, "HEAD should be a merge commit");
let first_parent = head.parent(0).unwrap();
assert_eq!(
first_parent.parent_count(),
2,
"first parent should also be a merge commit (feature-a merge)"
);
let second_parent = head.parent(1).unwrap();
assert_eq!(second_parent.id(), branch_b_oid);
}
#[test]
fn commit_moves_to_correct_branch_in_topology() {
let test_repo = setup_with_two_branches();
test_repo.write_file("new.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("New on A".to_string()),
vec!["new.txt".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
let branch_oid = test_repo.get_branch_target("feature-a");
let commit = test_repo.find_commit(branch_oid);
assert_eq!(commit.summary().unwrap(), "New on A");
let parent = commit.parent(0).unwrap();
assert_eq!(parent.summary().unwrap(), "A1");
assert_eq!(test_repo.branch_commit_summary("feature-b"), "B1");
}
#[test]
fn commit_conflict_pauses_operation() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feat1", &base_oid.to_string());
test_repo.switch_branch("feat1");
test_repo.write_file("feature1", "feat 1 content");
test_repo.stage_files(&["feature1"]);
test_repo.commit_staged("Feature 1");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feat1");
test_repo.write_file("feature1", "conflicting content");
let result = test_repo.in_dir(|| {
run(
Some("new-line".to_string()),
Some("New line".to_string()),
vec!["zz".to_string()],
)
});
assert!(result.is_ok(), "commit should pause on conflict, not fail");
let state_path = test_repo.repo.path().join("loom").join("state.json");
assert!(
state_path.exists(),
"loom state file should exist when paused"
);
assert!(
test_repo.branch_exists("new-line"),
"new-line branch should be kept while operation is paused"
);
}
#[test]
fn commit_conflict_preserves_existing_empty_branch() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feat1", &base_oid.to_string());
test_repo.switch_branch("feat1");
test_repo.write_file("shared.txt", "feat1 content");
test_repo.stage_files(&["shared.txt"]);
test_repo.commit_staged("Feature 1");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("feat1");
test_repo.create_branch_at("empty-branch", &base_oid.to_string());
test_repo.write_file("shared.txt", "conflicting content");
let result = test_repo.in_dir(|| {
run(
Some("empty-branch".to_string()),
Some("Should conflict".to_string()),
vec!["zz".to_string()],
)
});
assert!(result.is_ok(), "commit should pause on conflict, not fail");
let state_path = test_repo.repo.path().join("loom").join("state.json");
assert!(
state_path.exists(),
"loom state file should exist when paused"
);
assert!(
test_repo.branch_exists("empty-branch"),
"Pre-existing empty branch should be preserved while operation is paused"
);
}
#[test]
fn commit_loose_when_no_branch_and_at_remote() {
let test_repo = TestRepo::new_on_main_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.write_file("loose.txt", "content");
let result = test_repo.in_dir(|| {
run(
None, Some("Loose commit".to_string()),
vec!["loose.txt".to_string()],
)
});
assert!(result.is_ok(), "loose commit failed: {:?}", result);
let head = test_repo.head_commit();
assert_eq!(head.parent_count(), 1, "Loose commit should have 1 parent");
assert_eq!(head.summary().unwrap(), "Loose commit");
let parent = head.parent(0).unwrap();
assert_eq!(parent.id(), base_oid);
}
#[test]
fn commit_loose_works_with_existing_local_commits() {
let test_repo = TestRepo::new_on_main_with_remote();
test_repo.write_file("first.txt", "first");
test_repo
.in_dir(|| {
run(
None,
Some("First loose commit".to_string()),
vec!["first.txt".to_string()],
)
})
.unwrap();
test_repo.write_file("second.txt", "second");
let result = test_repo.in_dir(|| {
run(
None,
Some("Second loose commit".to_string()),
vec!["second.txt".to_string()],
)
});
assert!(
result.is_ok(),
"loose commit with existing local commits failed: {:?}",
result
);
let head = test_repo.head_commit();
assert_eq!(head.summary().unwrap(), "Second loose commit");
assert_eq!(head.parent_count(), 1);
assert_eq!(
head.parent(0).unwrap().summary().unwrap(),
"First loose commit"
);
}
#[test]
fn commit_with_branch_flag_does_not_create_loose() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("file.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("feature-new".to_string()), Some("Branch commit".to_string()),
vec!["file.txt".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
assert!(test_repo.branch_exists("feature-new"));
let head = test_repo.head_commit();
assert_eq!(
head.parent_count(),
2,
"HEAD should be a merge commit (branch woven)"
);
}
#[test]
fn commit_not_on_integration_branch_fails() {
let test_repo = TestRepo::new();
test_repo.commit("A1", "a1.txt");
test_repo.write_file("new.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Message".to_string()),
vec!["new.txt".to_string()],
)
});
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("integration branch")
);
}
#[test]
fn commit_nonexistent_file_fails() {
let test_repo = setup_with_woven_branch();
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Message".to_string()),
vec!["nonexistent.txt".to_string()],
)
});
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("file"),
"Error should mention file"
);
}
#[test]
fn commit_accepts_staged_rename_only() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("original.txt", "content");
test_repo.stage_files(&["original.txt"]);
test_repo.commit_staged("Add original");
let workdir = test_repo.workdir();
std::process::Command::new("git")
.current_dir(&workdir)
.args(["mv", "original.txt", "renamed.txt"])
.output()
.unwrap();
let result = crate::core::repo::verify_has_staged_changes(&test_repo.repo);
assert!(
result.is_ok(),
"staged rename should be accepted, got: {:?}",
result.err()
);
}
#[test]
fn commit_abort_preserves_working_state() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("feat1", &base_oid.to_string());
test_repo.switch_branch("feat1");
test_repo.commit("feat1-content", "shared.txt");
test_repo.switch_branch("integration");
test_repo.commit("int", "int.txt"); test_repo.merge_no_ff("feat1");
test_repo.write_file("other-staged.txt", "staged-content");
test_repo.stage_files(&["other-staged.txt"]);
test_repo.write_file("other-unstaged.txt", "unstaged-content");
test_repo.write_file("new-file.txt", "new-content");
test_repo.write_file("shared.txt", "conflicting-write");
let result = test_repo.in_dir(|| {
run(
Some("new-line".to_string()),
Some("New line".to_string()),
vec!["shared.txt".to_string()],
)
});
assert!(
result.is_ok(),
"commit should pause on conflict: {:?}",
result
);
let state_path = test_repo.repo.path().join("loom").join("state.json");
assert!(
state_path.exists(),
"loom state must exist when commit is paused on conflict"
);
let workdir = test_repo.workdir();
let git_dir = test_repo.repo.path().to_path_buf();
crate::core::transaction::abort_cmd(&workdir, &git_dir).unwrap();
assert_eq!(test_repo.read_file("shared.txt"), "conflicting-write");
assert_eq!(test_repo.read_file("other-staged.txt"), "staged-content");
assert_eq!(
test_repo.read_file("other-unstaged.txt"),
"unstaged-content"
);
assert!(
workdir.join("new-file.txt").exists(),
"new untracked file must survive abort"
);
assert_eq!(test_repo.read_file("new-file.txt"), "new-content");
}