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,
false,
message,
None,
files,
vec![],
&graph::Theme::dark(),
)
}
fn run_integration(message: &str, files: Vec<String>) -> anyhow::Result<()> {
super::run(
None,
true,
Some(message.to_string()),
None,
files,
vec![],
&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 a_commit_failing_after_it_is_created_puts_back_the_staging() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("kept.txt", "staged before the commit");
test_repo.stage_files(&["kept.txt"]);
test_repo.write_file("file.txt", "content");
std::fs::write(test_repo.repo.path().join("loom"), "not a directory").unwrap();
let before = test_repo.head_oid();
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Message".to_string()),
vec!["file.txt".to_string()],
)
});
assert!(result.is_err(), "the state file cannot be written");
assert_ne!(
test_repo.head_oid(),
before,
"the commit must already exist"
);
let status = test_repo.status_porcelain();
assert!(status.contains("A kept.txt"), "{status}");
}
#[test]
fn a_failed_commit_puts_back_the_staging_it_set_aside() {
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("kept.txt", "staged before the commit");
test_repo.stage_files(&["kept.txt"]);
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());
let status = test_repo.status_porcelain();
assert!(status.contains("A kept.txt"), "{status}");
}
#[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().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().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().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().unwrap(), "New on A");
let parent = commit.parent(0).unwrap();
assert_eq!(parent.summary().unwrap().unwrap(), "A1");
assert_eq!(test_repo.branch_commit_summary("feature-b"), "B1");
}
#[test]
fn commit_to_inner_branch_lands_at_its_tip() {
let test_repo = TestRepo::new_with_remote();
let base_oid = test_repo.find_remote_branch_target("origin/main");
test_repo.create_branch_at("inner", &base_oid.to_string());
test_repo.switch_branch("inner");
let i1_oid = test_repo.commit("I1", "i1.txt");
test_repo.create_branch_at("outer", &i1_oid.to_string());
test_repo.switch_branch("outer");
test_repo.commit("O1", "o1.txt");
test_repo.switch_branch("integration");
test_repo.merge_no_ff("outer");
test_repo.write_file("new.txt", "content");
let result = test_repo.in_dir(|| {
run(
Some("inner".to_string()),
Some("New on inner".to_string()),
vec!["new.txt".to_string()],
)
});
assert!(result.is_ok(), "commit failed: {:?}", result);
let inner = test_repo.find_commit(test_repo.get_branch_target("inner"));
assert_eq!(inner.summary().unwrap().unwrap(), "New on inner");
assert_eq!(inner.parent_id(0).unwrap(), i1_oid);
let outer = test_repo.find_commit(test_repo.get_branch_target("outer"));
assert_eq!(outer.summary().unwrap().unwrap(), "O1");
assert_eq!(outer.parent_id(0).unwrap(), inner.id());
}
#[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().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().unwrap(), "Second loose commit");
assert_eq!(head.parent_count(), 1);
assert_eq!(
head.parent(0).unwrap().summary().unwrap().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_integration_flag_forces_loose_on_custom_named_branch() {
let test_repo = TestRepo::new_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_integration("Loose commit", 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().unwrap(), "Loose commit");
assert_eq!(head.parent(0).unwrap().id(), base_oid);
}
#[test]
fn commit_integration_flag_commits_on_top_of_merges() {
let test_repo = setup_with_two_branches();
let old_head = test_repo.head_commit().id();
test_repo.write_file("loose.txt", "content");
let result =
test_repo.in_dir(|| run_integration("Loose commit", vec!["loose.txt".to_string()]));
assert!(result.is_ok(), "loose commit failed: {:?}", result);
let head = test_repo.head_commit();
assert_eq!(head.summary().unwrap().unwrap(), "Loose commit");
assert_eq!(head.parent_count(), 1);
assert_eq!(head.parent(0).unwrap().id(), old_head);
assert_eq!(test_repo.branch_commit_summary("feature-a"), "A1");
assert_eq!(test_repo.branch_commit_summary("feature-b"), "B1");
}
#[test]
fn commit_integration_flag_keeps_other_staged_files_staged() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("other.txt", "other content");
test_repo.stage_files(&["other.txt"]);
test_repo.write_file("mine.txt", "mine content");
let result = test_repo.in_dir(|| run_integration("Only mine", vec!["mine.txt".to_string()]));
assert!(result.is_ok(), "loose commit failed: {:?}", result);
assert_eq!(
test_repo.commit_file_paths(test_repo.head_commit().id()),
vec!["mine.txt"]
);
let status = test_repo.status_porcelain();
assert!(
status.lines().any(|l| l == "A other.txt"),
"other.txt must still be staged, got: {status:?}"
);
assert_eq!(test_repo.read_file("other.txt"), "other content");
}
#[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");
}
#[test]
fn commit_forwards_an_option_after_the_separator() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("forwarded.txt", "content");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
Some("Forwarded commit".to_string()),
None,
vec!["forwarded.txt".to_string()],
vec!["--author=Someone Else <someone@example.com>".to_string()],
&graph::Theme::dark(),
)
});
assert!(result.is_ok(), "commit should forward --author: {result:?}");
let head = test_repo.repo.head().unwrap().peel_to_commit().unwrap();
assert_eq!(head.author().name().unwrap(), "Someone Else");
}
#[test]
fn commit_unknown_option_reaches_git() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("bogus.txt", "content");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
Some("Bogus commit".to_string()),
None,
vec!["bogus.txt".to_string()],
vec!["--definitely-not-a-git-option".to_string()],
&graph::Theme::dark(),
)
});
assert!(
result.is_err(),
"git should reject an option loom passed through"
);
}
#[test]
fn commit_refuses_when_the_new_commit_replays_empty() {
let test_repo = TestRepo::new_with_remote();
let base = test_repo
.find_remote_branch_target("origin/main")
.to_string();
test_repo.create_branch_at("upstream-work", &base);
test_repo.switch_branch("upstream-work");
test_repo.commit_multi(&[("f.txt", "final\n")], "upstream adds f");
test_repo.push_branch_to_remote_main("upstream-work");
test_repo.switch_branch("integration");
test_repo.reset_hard(test_repo.find_remote_branch_target("origin/main"));
test_repo.commit_multi(&[("f.txt", "old\n")], "revert f");
let head_before = test_repo.head_oid();
test_repo.write_file("f.txt", "final\n");
let err = test_repo
.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Restore f".to_string()),
vec!["f.txt".to_string()],
)
})
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(
!err.contains("loom drop"),
"the rollback takes the commit with it: {err}"
);
assert!(err.contains("rolled back"), "{err}");
assert_eq!(test_repo.head_oid(), head_before, "{err}");
assert!(!test_repo.branch_exists("feature-a"), "{err}");
assert_eq!(test_repo.read_file("f.txt"), "final\n", "{err}");
assert!(
!crate::git::rebase_is_in_progress(test_repo.repo.path()),
"{err}"
);
assert!(
!test_repo
.repo
.path()
.join("loom")
.join("state.json")
.exists(),
"{err}"
);
}
#[test]
fn commit_refuses_with_no_file_arguments_too() {
let test_repo = TestRepo::new_with_remote();
let base = test_repo
.find_remote_branch_target("origin/main")
.to_string();
test_repo.create_branch_at("upstream-work", &base);
test_repo.switch_branch("upstream-work");
test_repo.commit_multi(&[("f.txt", "final\n")], "upstream adds f");
test_repo.push_branch_to_remote_main("upstream-work");
test_repo.switch_branch("integration");
test_repo.reset_hard(test_repo.find_remote_branch_target("origin/main"));
test_repo.commit_multi(&[("f.txt", "old\n")], "revert f");
let head_before = test_repo.head_oid();
test_repo.write_file("f.txt", "final\n");
test_repo.stage_files(&["f.txt"]);
let staged_before = crate::git::diff_cached(&test_repo.workdir()).unwrap();
let err = test_repo
.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Restore f".to_string()),
vec![],
)
})
.unwrap_err()
.to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
assert_eq!(test_repo.head_oid(), head_before, "{err}");
assert_eq!(test_repo.read_file("f.txt"), "final\n", "{err}");
assert!(!test_repo.branch_exists("feature-a"), "{err}");
assert_eq!(
crate::git::diff_cached(&test_repo.workdir()).unwrap(),
staged_before,
"the index must come back staged, not just the content: {err}"
);
}
#[test]
fn after_continue_reads_a_state_file_without_the_saved_staged_field() {
let test_repo = TestRepo::new_with_remote();
test_repo.commit_multi(&[("kept.txt", "kept\n")], "base");
test_repo.write_file("kept.txt", "kept\nset aside\n");
test_repo.stage_files(&["kept.txt"]);
let aside = crate::git::diff_cached(&test_repo.workdir()).unwrap();
crate::git::run_git(&test_repo.workdir(), &["restore", "--staged", "."]).unwrap();
let rollback = crate::core::transaction::Rollback {
saved_staged_patch: aside.clone(),
..Default::default()
};
test_repo.create_branch("feature-a");
let context = serde_json::json!({ "branch_name": "feature-a" });
super::after_continue(&test_repo.workdir(), &rollback, &context).unwrap();
assert_eq!(
crate::git::diff_cached(&test_repo.workdir()).unwrap(),
aside
);
}
const CHANGE_ID: &str = "I0123456789abcdef0123456789abcdef01234567";
fn full_message(test_repo: &TestRepo, rev: &str) -> String {
test_repo
.repo
.revparse_single(rev)
.unwrap()
.peel_to_commit()
.unwrap()
.message()
.unwrap()
.to_string()
}
fn write_gerrit_like_hook(test_repo: &TestRepo) {
write_hook(
test_repo,
"commit-msg",
"grep -q '^Change-Id:' \"$1\" || printf '\\nChange-Id: I%s\\n' \
0000000000000000000000000000000000000000 >> \"$1\"",
);
}
fn write_hook(test_repo: &TestRepo, name: &str, body: &str) {
write_script(test_repo, &format!(".git/hooks/{name}"), body);
}
fn write_script(test_repo: &TestRepo, relative: &str, body: &str) -> String {
let script = test_repo.workdir().join(relative);
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, format!("#!/bin/sh\n{body}\nexit 0\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
script.display().to_string().replace('\\', "/")
}
fn write_counting_hook(test_repo: &TestRepo) {
let log = test_repo
.workdir()
.join("runs")
.display()
.to_string()
.replace('\\', "/");
write_hook(test_repo, "commit-msg", &format!("echo run >> \"{log}\""));
}
#[test]
fn commit_adds_a_change_id_trailer() {
let test_repo = setup_with_woven_branch();
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
Some("Add a".to_string()),
vec!["a.txt".to_string()],
)
});
assert!(result.is_ok(), "{result:?}");
let msg = full_message(&test_repo, "feature-a");
assert!(msg.starts_with("Add a\n\nChange-Id: I"), "{msg}");
assert!(crate::core::changeid::from_message(&msg).is_some());
}
#[test]
fn commit_respects_the_change_id_opt_out() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_config("loom.changeId", "false");
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| run_integration("Plain", vec!["a.txt".to_string()]));
assert!(result.is_ok(), "{result:?}");
assert_eq!(full_message(&test_repo, "HEAD"), "Plain\n");
}
#[test]
fn commit_keeps_a_change_id_already_in_the_message() {
let test_repo = TestRepo::new_with_remote();
test_repo.write_file("a.txt", "a");
let message = format!("Has one\n\nChange-Id: {CHANGE_ID}");
let result = test_repo.in_dir(|| run_integration(&message, vec!["a.txt".to_string()]));
assert!(result.is_ok(), "{result:?}");
let msg = full_message(&test_repo, "HEAD");
assert_eq!(msg.matches("Change-Id:").count(), 1, "{msg}");
assert!(msg.contains(CHANGE_ID));
}
#[test]
fn commit_editor_path_gets_a_change_id_after_editing() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_fake_editor("Reworded by editor");
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
None,
None,
vec!["a.txt".to_string()],
vec![],
&graph::Theme::dark(),
)
});
assert!(result.is_ok(), "{result:?}");
let msg = full_message(&test_repo, "HEAD");
assert!(
msg.starts_with("Reworded by editor\n\nChange-Id: I"),
"{msg}"
);
assert_eq!(
test_repo.commit_file_paths(test_repo.head_oid()),
vec!["a.txt"]
);
}
#[test]
fn commit_editor_path_runs_hooks_once() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_fake_editor("Reworded by editor");
write_counting_hook(&test_repo);
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
None,
None,
vec!["a.txt".to_string()],
vec![],
&graph::Theme::dark(),
)
});
assert!(result.is_ok(), "{result:?}");
let runs = std::fs::read_to_string(test_repo.workdir().join("runs")).unwrap();
assert_eq!(runs.lines().count(), 1, "{runs}");
assert!(full_message(&test_repo, "HEAD").contains("Change-Id: I"));
}
#[test]
fn commit_editor_path_survives_a_failing_trailer_amend() {
let test_repo = setup_with_woven_branch();
test_repo.set_fake_editor("Reworded by editor");
write_hook(
&test_repo,
"prepare-commit-msg",
"grep -q Change-Id \"$1\" && exit 1",
);
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
run(
Some("feature-a".to_string()),
None,
vec!["a.txt".to_string()],
)
});
assert!(result.is_ok(), "{result:?}");
assert_eq!(
test_repo.branch_commit_summary("feature-a"),
"Reworded by editor"
);
assert!(!full_message(&test_repo, "feature-a").contains("Change-Id"));
}
#[test]
fn commit_editor_path_keeps_a_configured_signature() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_fake_editor("Reworded by editor");
let gpg = write_script(
&test_repo,
".git/fake-gpg",
"printf '\\n[GNUPG:] SIG_CREATED D 1 8 00 1 X\\n' >&2\n\
printf -- '-----BEGIN PGP SIGNATURE-----\\n\\nfake\\n-----END PGP SIGNATURE-----\\n'",
);
test_repo.set_config("gpg.program", &gpg);
test_repo.set_config("user.signingkey", "fake");
test_repo.set_config("commit.gpgsign", "true");
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
None,
None,
vec!["a.txt".to_string()],
vec![],
&graph::Theme::dark(),
)
});
assert!(result.is_ok(), "{result:?}");
let head = test_repo.head_commit();
assert!(head.raw_header().unwrap().contains("gpgsig"));
assert!(full_message(&test_repo, "HEAD").contains("Change-Id: I"));
}
#[test]
fn commit_editor_path_amend_keeps_the_stored_message_verbatim() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_config("commit.cleanup", "strip");
test_repo.set_fake_editor("Reworded by editor\n\n# kept");
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
None,
None,
vec!["a.txt".to_string()],
vec!["--cleanup=verbatim".to_string()],
&graph::Theme::dark(),
)
});
assert!(result.is_ok(), "{result:?}");
let msg = full_message(&test_repo, "HEAD");
assert!(
msg.starts_with("Reworded by editor\n\n# kept\n\nChange-Id: I"),
"{msg}"
);
}
#[test]
fn commit_msg_hook_sees_looms_change_id() {
let test_repo = TestRepo::new_with_remote();
write_gerrit_like_hook(&test_repo);
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| run_integration("Hooked", vec!["a.txt".to_string()]));
assert!(result.is_ok(), "{result:?}");
let msg = full_message(&test_repo, "HEAD");
assert_eq!(msg.matches("Change-Id:").count(), 1, "{msg}");
assert!(
!msg.contains("I0000000000"),
"the hook id must not win: {msg}"
);
}
#[test]
fn commit_no_verify_still_adds_a_change_id() {
let test_repo = TestRepo::new_with_remote();
write_gerrit_like_hook(&test_repo);
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
Some("Unverified".to_string()),
None,
vec!["a.txt".to_string()],
vec!["--no-verify".to_string()],
&graph::Theme::dark(),
)
});
assert!(result.is_ok(), "{result:?}");
let msg = full_message(&test_repo, "HEAD");
assert!(msg.starts_with("Unverified\n\nChange-Id: I"), "{msg}");
}
#[test]
fn commit_restores_other_staged_files_when_the_change_id_cannot_be_generated() {
let test_repo = TestRepo::new_with_remote();
unsafe {
for var in ["GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"] {
std::env::remove_var(var);
}
}
test_repo.set_config("user.useConfigOnly", "true");
test_repo.set_config("user.name", "");
test_repo.set_config("user.email", "");
test_repo.write_file("other.txt", "other");
test_repo.stage_files(&["other.txt"]);
test_repo.write_file("mine.txt", "mine");
let result = test_repo.in_dir(|| run_integration("Nobody", vec!["mine.txt".to_string()]));
let err = result.unwrap_err().to_string();
assert!(err.contains("committer identity"), "{err}");
let status = test_repo.status_porcelain();
assert!(
status.lines().any(|l| l == "A other.txt"),
"other.txt must still be staged, got: {status:?}"
);
}
#[test]
fn commit_ignores_trailer_config() {
let test_repo = TestRepo::new_with_remote();
test_repo.set_config("trailer.Change-Id.key", "Nope");
test_repo.set_config("trailer.ifexists", "doNothing");
test_repo.set_config("trailer.where", "start");
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| run_integration("Configured", vec!["a.txt".to_string()]));
assert!(result.is_ok(), "{result:?}");
let msg = full_message(&test_repo, "HEAD");
assert!(msg.starts_with("Configured\n\nChange-Id: I"), "{msg}");
assert_eq!(msg.lines().count(), 3, "{msg}");
assert!(crate::core::changeid::from_message(&msg).is_some(), "{msg}");
}
#[test]
fn commit_dry_run_does_not_amend_head() {
let test_repo = TestRepo::new_with_remote();
let head = test_repo.head_oid();
test_repo.write_file("a.txt", "a");
let result = test_repo.in_dir(|| {
super::run(
None,
true,
None,
None,
vec!["a.txt".to_string()],
vec!["--dry-run".to_string()],
&graph::Theme::dark(),
)
});
assert!(result.is_ok(), "{result:?}");
assert_eq!(test_repo.head_oid(), head);
assert!(!full_message(&test_repo, "HEAD").contains("Change-Id"));
}
#[test]
fn commit_rolls_back_when_the_rebase_refuses_to_start() {
let test_repo = setup_with_two_branches();
let workdir = test_repo.workdir();
let wt = workdir.parent().unwrap().join("wt");
crate::git::run_git(
&workdir,
&["worktree", "add", wt.to_str().unwrap(), "feature-a"],
)
.unwrap();
let head_before = test_repo.head_oid();
test_repo.write_file(
"other.txt",
"staged by the user
",
);
test_repo.stage_files(&["other.txt"]);
let staged_before = crate::git::diff_cached(&workdir).unwrap();
test_repo.write_file(
"new.txt",
"for the commit
",
);
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_err(),
"the rebase cannot start, so the commit fails"
);
assert_eq!(
test_repo.head_oid(),
head_before,
"the commit must not be left on integration"
);
assert_eq!(
crate::git::diff_cached(&workdir).unwrap(),
staged_before,
"the user's own staging comes back exactly as it was"
);
assert_eq!(
test_repo.read_file("new.txt"),
"for the commit\n",
"the content loom committed comes back to the working tree"
);
assert!(
!test_repo.repo.path().join("loom/state.json").exists(),
"the state file goes with the rollback"
);
}