use std::fs;
use worktrunk::git::Repository;
use crate::common::{BareRepoTest, TestRepo};
#[test]
fn test_is_bare_returns_false_when_core_bare_unset() {
let repo = TestRepo::new();
repo.run_git(&["config", "--unset", "core.bare"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(
!repository.is_bare().unwrap(),
"repo with unset core.bare should not be detected as bare"
);
}
#[test]
fn test_is_bare_returns_false_for_normal_repo() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(!repository.is_bare().unwrap());
}
#[test]
fn test_is_bare_returns_true_for_bare_repo() {
let test = BareRepoTest::new();
let repository = Repository::at(test.bare_repo_path().to_path_buf()).unwrap();
assert!(repository.is_bare().unwrap());
}
#[test]
fn test_worktree_state_normal() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let state = repository.worktree_state().unwrap();
assert!(state.is_none());
}
#[test]
fn test_worktree_state_merging() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let git_dir = repo.root_path().join(".git");
fs::write(git_dir.join("MERGE_HEAD"), "abc123\n").unwrap();
let state = repository.worktree_state().unwrap();
assert_eq!(state, Some("MERGING".to_string()));
}
#[test]
fn test_worktree_state_rebasing_with_progress() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let git_dir = repo.root_path().join(".git");
let rebase_dir = git_dir.join("rebase-merge");
fs::create_dir_all(&rebase_dir).unwrap();
fs::write(rebase_dir.join("msgnum"), "2\n").unwrap();
fs::write(rebase_dir.join("end"), "5\n").unwrap();
let state = repository.worktree_state().unwrap();
assert_eq!(state, Some("REBASING 2/5".to_string()));
}
#[test]
fn test_worktree_state_rebasing_apply() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let git_dir = repo.root_path().join(".git");
let rebase_dir = git_dir.join("rebase-apply");
fs::create_dir_all(&rebase_dir).unwrap();
fs::write(rebase_dir.join("msgnum"), "3\n").unwrap();
fs::write(rebase_dir.join("end"), "7\n").unwrap();
let state = repository.worktree_state().unwrap();
assert_eq!(state, Some("REBASING 3/7".to_string()));
}
#[test]
fn test_worktree_state_rebasing_no_progress() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let git_dir = repo.root_path().join(".git");
let rebase_dir = git_dir.join("rebase-merge");
fs::create_dir_all(&rebase_dir).unwrap();
let state = repository.worktree_state().unwrap();
assert_eq!(state, Some("REBASING".to_string()));
}
#[test]
fn test_worktree_state_cherry_picking() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let git_dir = repo.root_path().join(".git");
fs::write(git_dir.join("CHERRY_PICK_HEAD"), "def456\n").unwrap();
let state = repository.worktree_state().unwrap();
assert_eq!(state, Some("CHERRY-PICKING".to_string()));
}
#[test]
fn test_worktree_state_reverting() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let git_dir = repo.root_path().join(".git");
fs::write(git_dir.join("REVERT_HEAD"), "789abc\n").unwrap();
let state = repository.worktree_state().unwrap();
assert_eq!(state, Some("REVERTING".to_string()));
}
#[test]
fn test_worktree_state_bisecting() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let git_dir = repo.root_path().join(".git");
fs::write(git_dir.join("BISECT_LOG"), "# bisect log\n").unwrap();
let state = repository.worktree_state().unwrap();
assert_eq!(state, Some("BISECTING".to_string()));
}
#[test]
fn test_available_branches_all_have_worktrees() {
let mut repo = TestRepo::new();
repo.add_worktree("feature");
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let available = repository.available_branches().unwrap();
assert!(available.is_empty());
}
#[test]
fn test_available_branches_some_without_worktrees() {
let repo = TestRepo::with_initial_commit();
repo.git_command()
.args(["branch", "orphan-branch"])
.run()
.unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let available = repository.available_branches().unwrap();
assert!(available.contains(&"orphan-branch".to_string()));
assert!(!available.contains(&"main".to_string()));
}
#[test]
fn test_all_branches() {
let repo = TestRepo::with_initial_commit();
repo.git_command().args(["branch", "alpha"]).run().unwrap();
repo.git_command().args(["branch", "beta"]).run().unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let branches = repository.all_branches().unwrap();
assert!(branches.contains(&"main".to_string()));
assert!(branches.contains(&"alpha".to_string()));
assert!(branches.contains(&"beta".to_string()));
}
#[test]
fn test_project_identifier_https() {
let mut repo = TestRepo::with_initial_commit();
repo.setup_remote("main");
repo.git_command()
.args([
"remote",
"set-url",
"origin",
"https://github.com/user/repo.git",
])
.run()
.unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let id = repository.project_identifier().unwrap();
assert_eq!(id, "github.com/user/repo");
}
#[test]
fn test_project_identifier_http() {
let mut repo = TestRepo::with_initial_commit();
repo.setup_remote("main");
repo.git_command()
.args([
"remote",
"set-url",
"origin",
"http://gitlab.example.com/team/project.git",
])
.run()
.unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let id = repository.project_identifier().unwrap();
assert_eq!(id, "gitlab.example.com/team/project");
}
#[test]
fn test_project_identifier_ssh_colon() {
let mut repo = TestRepo::with_initial_commit();
repo.setup_remote("main");
repo.git_command()
.args([
"remote",
"set-url",
"origin",
"git@github.com:user/repo.git",
])
.run()
.unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let id = repository.project_identifier().unwrap();
assert_eq!(id, "github.com/user/repo");
}
#[test]
fn test_project_identifier_ssh_protocol() {
let mut repo = TestRepo::with_initial_commit();
repo.setup_remote("main");
repo.git_command()
.args([
"remote",
"set-url",
"origin",
"ssh://git@github.com/user/repo.git",
])
.run()
.unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let id = repository.project_identifier().unwrap();
assert_eq!(id, "github.com/user/repo");
}
#[test]
fn test_project_identifier_ssh_protocol_with_port() {
let mut repo = TestRepo::with_initial_commit();
repo.setup_remote("main");
repo.git_command()
.args([
"remote",
"set-url",
"origin",
"ssh://git@gitlab.example.com:2222/team/project.git",
])
.run()
.unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let id = repository.project_identifier().unwrap();
assert_eq!(id, "gitlab.example.com/team/project");
}
#[test]
fn test_project_identifier_no_remote_fallback() {
let repo = TestRepo::with_initial_commit();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let id = repository.project_identifier().unwrap();
let expected = dunce::canonicalize(repo.root_path()).unwrap();
assert_eq!(id, expected.to_str().unwrap());
}
#[test]
fn test_get_config_exists() {
let repo = TestRepo::new();
repo.git_command()
.args(["config", "test.key", "test-value"])
.run()
.unwrap();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let value = repository.config_value("test.key").unwrap();
assert_eq!(value, Some("test-value".to_string()));
}
#[test]
fn test_get_config_not_exists() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let value = repository.config_value("nonexistent.key").unwrap();
assert!(value.is_none());
}
#[test]
fn test_set_config() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
repository.set_config("test.setting", "new-value").unwrap();
let value = repository.config_value("test.setting").unwrap();
assert_eq!(value, Some("new-value".to_string()));
}
#[test]
fn test_config_value_propagates_error_on_corrupt_config() {
let repo = TestRepo::new();
let root = repo.root_path().to_path_buf();
let repository = Repository::at(root.clone()).unwrap();
let config_path = root.join(".git/config");
fs::write(&config_path, "[invalid section\n").unwrap();
let result = repository.config_value("test.key");
assert!(
result.is_err(),
"config_value() should propagate errors from corrupt config, not return Ok(None)"
);
}
#[test]
fn test_clear_hint_propagates_error_on_corrupt_config() {
let repo = TestRepo::new();
let root = repo.root_path().to_path_buf();
let repository = Repository::at(root.clone()).unwrap();
repository.mark_hint_shown("test-hint").unwrap();
let config_path = root.join(".git/config");
fs::write(&config_path, "[invalid section\n").unwrap();
let result = repository.clear_hint("test-hint");
assert!(
result.is_err(),
"clear_hint() should propagate errors from corrupt config, not return Ok(false)"
);
}
#[test]
fn test_hint_roundtrip_through_bulk_cache() {
let repo = TestRepo::new();
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(!r.is_bare().unwrap());
r.mark_hint_shown("zebra").unwrap();
r.mark_hint_shown("alpha").unwrap();
assert!(r.has_shown_hint("zebra"));
assert!(r.has_shown_hint("alpha"));
assert!(!r.has_shown_hint("unknown"));
let hints = r.list_shown_hints();
assert_eq!(hints, vec!["alpha".to_string(), "zebra".to_string()]);
assert!(r.clear_hint("alpha").unwrap());
assert!(!r.has_shown_hint("alpha"));
assert!(r.has_shown_hint("zebra"));
assert_eq!(r.list_shown_hints(), vec!["zebra".to_string()]);
assert!(!r.clear_hint("never-set").unwrap());
}
#[test]
fn test_primary_remote_honours_checkout_default_remote() {
let repo = TestRepo::new();
repo.run_git(&[
"remote",
"add",
"origin",
"https://github.com/max-sixty/worktrunk.git",
]);
repo.run_git(&[
"remote",
"add",
"upstream",
"https://github.com/max-sixty/worktrunk.git",
]);
repo.run_git(&["config", "checkout.defaultRemote", "upstream"]);
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert_eq!(r.primary_remote().unwrap(), "upstream");
repo.run_git(&["config", "--unset", "checkout.defaultRemote"]);
let r2 = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert_eq!(r2.primary_remote().unwrap(), "origin");
}
#[test]
fn test_all_remote_urls_filters_phantom_remotes() {
let repo = TestRepo::new();
repo.run_git(&[
"remote",
"add",
"origin",
"https://github.com/max-sixty/worktrunk.git",
]);
repo.run_git(&["config", "remote.phantom.prunetags", "true"]);
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
let urls = r.all_remote_urls();
assert_eq!(urls.len(), 1, "expected only origin, got {urls:?}");
assert_eq!(urls[0].0, "origin");
}
#[test]
fn test_unset_config_removes_from_bulk_cache() {
let repo = TestRepo::new();
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
let _ = r.is_bare();
r.set_config("branch.main.pushRemote", "origin").unwrap();
assert_eq!(
r.config_value("branch.main.pushRemote").unwrap(),
Some("origin".to_string())
);
assert!(r.unset_config("branch.main.pushRemote").unwrap());
assert_eq!(r.config_value("branch.main.pushRemote").unwrap(), None);
assert!(!r.unset_config("branch.main.pushRemote").unwrap());
}
#[test]
fn test_set_and_clear_default_branch() {
let repo = TestRepo::new();
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
r.set_default_branch("main").unwrap();
assert_eq!(r.default_branch(), Some("main".to_string()));
let r2 = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(r2.clear_default_branch_cache().unwrap());
assert!(!r2.clear_default_branch_cache().unwrap());
}
#[test]
fn test_switch_previous_roundtrip() {
let repo = TestRepo::new();
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
let _ = r.is_bare();
assert_eq!(r.switch_previous(), None);
r.set_switch_previous(Some("feature-a")).unwrap();
assert_eq!(r.switch_previous(), Some("feature-a".to_string()));
r.set_switch_previous(None).unwrap();
assert_eq!(r.switch_previous(), Some("feature-a".to_string()));
}
#[test]
fn test_primary_remote_url_composition() {
let repo = TestRepo::new();
repo.run_git(&[
"remote",
"add",
"origin",
"https://github.com/max-sixty/worktrunk.git",
]);
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert_eq!(
r.primary_remote_url(),
Some("https://github.com/max-sixty/worktrunk.git".to_string())
);
let parsed = r.primary_remote_parsed_url().expect("parses");
assert_eq!(parsed.owner(), "max-sixty");
assert_eq!(parsed.repo(), "worktrunk");
let bare = TestRepo::new();
let r2 = Repository::at(bare.root_path().to_path_buf()).unwrap();
assert_eq!(r2.primary_remote_url(), None);
assert!(r2.primary_remote_parsed_url().is_none());
}
#[test]
fn test_remote_url_known_and_unknown() {
let repo = TestRepo::new();
repo.run_git(&[
"remote",
"add",
"origin",
"git@github.com:max-sixty/worktrunk.git",
]);
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert_eq!(
r.remote_url("origin"),
Some("git@github.com:max-sixty/worktrunk.git".to_string())
);
assert_eq!(r.remote_url("nonexistent"), None);
}
#[test]
fn test_primary_remote_errors_with_no_remotes() {
let repo = TestRepo::new(); let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
let err = r.primary_remote().unwrap_err();
assert!(
err.to_string().contains("No remotes configured"),
"unexpected error: {err}"
);
}
#[test]
fn test_require_target_ref_surfaces_stale_default_branch() {
use worktrunk::git::GitError;
let repo = TestRepo::new();
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
r.set_config("worktrunk.default-branch", "nonexistent-branch")
.unwrap();
let r2 = Repository::at(repo.root_path().to_path_buf()).unwrap();
let err = r2.require_target_ref(None).unwrap_err();
let gerr = err.downcast_ref::<GitError>().expect("GitError");
assert!(
matches!(gerr, GitError::StaleDefaultBranch { branch } if branch == "nonexistent-branch"),
"expected StaleDefaultBranch, got {gerr:?}"
);
}
#[test]
fn test_unset_config_propagates_error_on_corrupt_config() {
let repo = TestRepo::new();
let root = repo.root_path().to_path_buf();
let r = Repository::at(root.clone()).unwrap();
r.set_default_branch("main").unwrap();
fs::write(root.join(".git/config"), "[invalid section\n").unwrap();
let err = r.unset_config("worktrunk.default-branch");
assert!(
err.is_err(),
"unset_config should propagate corrupt-config errors: {err:?}"
);
}
#[test]
fn test_tag_branch_name_collision_is_ancestor() {
let repo = TestRepo::with_initial_commit();
let main_sha = repo.git_output(&["rev-parse", "HEAD"]);
repo.run_git(&["checkout", "-b", "feature"]);
fs::write(repo.root_path().join("feature.txt"), "feature content").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Feature commit"]);
repo.run_git(&["tag", "feature", &main_sha]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let result = repository.is_ancestor("feature", "main").unwrap();
assert!(
!result,
"is_ancestor should check the branch 'feature', not the tag 'feature'"
);
}
#[test]
fn test_tag_branch_name_collision_same_commit() {
let repo = TestRepo::with_initial_commit();
let main_sha = repo.git_output(&["rev-parse", "HEAD"]);
repo.run_git(&["checkout", "-b", "feature"]);
fs::write(repo.root_path().join("feature.txt"), "feature content").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Feature commit"]);
repo.run_git(&["tag", "feature", &main_sha]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let result = repository.same_commit("feature", "main").unwrap();
assert!(
!result,
"same_commit should check the branch 'feature', not the tag 'feature'"
);
}
#[test]
fn test_tag_branch_name_collision_trees_match() {
let repo = TestRepo::with_initial_commit();
let main_sha = repo.git_output(&["rev-parse", "HEAD"]);
repo.run_git(&["checkout", "-b", "feature"]);
fs::write(repo.root_path().join("feature.txt"), "feature content").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Feature commit"]);
repo.run_git(&["tag", "feature", &main_sha]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let result = repository.trees_match("feature", "main").unwrap();
assert!(
!result,
"trees_match should check the branch 'feature', not the tag 'feature'"
);
}
#[test]
fn test_integration_functions_handle_head() {
let repo = TestRepo::new();
fs::write(repo.root_path().join("file.txt"), "content").unwrap();
repo.run_git(&["add", "file.txt"]);
repo.run_git(&["commit", "-m", "Add file"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(repository.same_commit("HEAD", "main").unwrap());
assert!(repository.is_ancestor("main", "HEAD").unwrap());
assert!(repository.trees_match("HEAD", "main").unwrap());
}
#[test]
fn test_integration_functions_handle_shas() {
let repo = TestRepo::with_initial_commit();
let main_sha = repo.git_output(&["rev-parse", "HEAD"]);
repo.run_git(&["checkout", "-b", "feature"]);
fs::write(repo.root_path().join("feature.txt"), "content").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Feature"]);
let feature_sha = repo.git_output(&["rev-parse", "HEAD"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(repository.same_commit(&main_sha, "main").unwrap());
assert!(!repository.same_commit(&feature_sha, &main_sha).unwrap());
assert!(repository.is_ancestor(&main_sha, &feature_sha).unwrap());
}
#[test]
fn test_integration_functions_handle_remote_refs() {
let mut repo = TestRepo::with_initial_commit();
repo.setup_remote("main");
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(repository.same_commit("origin/main", "main").unwrap());
assert!(repository.is_ancestor("origin/main", "main").unwrap());
}
#[test]
fn test_has_merge_conflicts_clean_vs_conflicting() {
let repo = TestRepo::new();
fs::write(repo.root_path().join("base.txt"), "base\n").unwrap();
repo.run_git(&["add", "base.txt"]);
repo.run_git(&["commit", "-m", "Base"]);
repo.run_git(&["checkout", "-b", "clean-feature"]);
fs::write(repo.root_path().join("new.txt"), "new\n").unwrap();
repo.run_git(&["add", "new.txt"]);
repo.run_git(&["commit", "-m", "Add new file"]);
repo.run_git(&["checkout", "main"]);
repo.run_git(&["checkout", "-b", "conflict-feature"]);
fs::write(repo.root_path().join("base.txt"), "conflict\n").unwrap();
repo.run_git(&["add", "base.txt"]);
repo.run_git(&["commit", "-m", "Edit base"]);
repo.run_git(&["checkout", "main"]);
fs::write(repo.root_path().join("base.txt"), "main-edit\n").unwrap();
repo.run_git(&["add", "base.txt"]);
repo.run_git(&["commit", "-m", "Edit base on main"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(
!repository
.has_merge_conflicts("main", "clean-feature")
.unwrap()
);
assert!(
repository
.has_merge_conflicts("main", "conflict-feature")
.unwrap()
);
}
#[test]
fn test_has_merge_conflicts_orphan_branch() {
let repo = TestRepo::with_initial_commit();
repo.run_git(&["checkout", "--orphan", "orphan"]);
repo.run_git(&["rm", "-rf", "."]);
fs::write(repo.root_path().join("orphan.txt"), "orphan\n").unwrap();
repo.run_git(&["add", "orphan.txt"]);
repo.run_git(&["commit", "-m", "Orphan commit"]);
repo.run_git(&["checkout", "main"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
assert!(repository.has_merge_conflicts("main", "orphan").unwrap());
}
#[test]
fn test_merge_integration_probe_orphan_branch() {
let repo = TestRepo::with_initial_commit();
repo.run_git(&["checkout", "--orphan", "orphan"]);
repo.run_git(&["rm", "-rf", "."]);
fs::write(repo.root_path().join("orphan.txt"), "orphan\n").unwrap();
repo.run_git(&["add", "orphan.txt"]);
repo.run_git(&["commit", "-m", "Orphan commit"]);
repo.run_git(&["checkout", "main"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let probe = repository
.merge_integration_probe("orphan", "main")
.unwrap();
assert!(probe.would_merge_add, "orphan branch always has changes");
assert!(
!probe.is_patch_id_match,
"no patch-id match possible without merge base"
);
}
#[test]
fn test_merge_integration_probe_already_integrated() {
let repo = TestRepo::with_initial_commit();
repo.run_git(&["checkout", "-b", "feature"]);
fs::write(repo.root_path().join("feature.txt"), "content\n").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Feature"]);
repo.run_git(&["checkout", "main"]);
repo.run_git(&["merge", "feature"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let probe = repository
.merge_integration_probe("feature", "main")
.unwrap();
assert!(!probe.would_merge_add, "already-merged branch adds nothing");
}
#[test]
fn test_repo_path_in_submodule() {
let parent = TestRepo::new();
fs::write(parent.path().join("README.md"), "# Parent").unwrap();
parent.run_git(&["add", "."]);
parent.run_git(&["commit", "-m", "Initial commit"]);
let sub_origin = TestRepo::new();
fs::write(sub_origin.path().join("README.md"), "# Submodule").unwrap();
sub_origin.run_git(&["add", "."]);
sub_origin.run_git(&["commit", "-m", "Submodule initial commit"]);
parent
.repo
.run_command(&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
sub_origin.path().to_str().unwrap(),
"sub",
])
.unwrap();
parent.run_git(&["commit", "-m", "Add submodule"]);
let submodule_path = parent.path().join("sub");
assert!(
submodule_path.exists(),
"Submodule path should exist: {:?}",
submodule_path
);
let repository = Repository::at(submodule_path.clone()).unwrap();
let repo_path = repository.repo_path().unwrap();
let expected = dunce::canonicalize(&submodule_path).unwrap();
let actual = dunce::canonicalize(repo_path).unwrap();
assert_eq!(
actual, expected,
"repo_path() should return submodule's working directory ({:?}), not git modules path",
expected
);
let git_common_dir = repository.git_common_dir();
let components: Vec<_> = git_common_dir.components().collect();
let has_git_modules = components.windows(2).any(|pair| {
matches!(
(pair[0].as_os_str().to_str(), pair[1].as_os_str().to_str()),
(Some(".git"), Some("modules"))
)
});
assert!(
has_git_modules,
"git_common_dir should be in parent's .git/modules/ for a submodule, got: {:?}",
git_common_dir
);
let worktrees = repository.list_worktrees().unwrap();
assert!(
!worktrees.is_empty(),
"list_worktrees() should return at least the main worktree"
);
let main_wt_path = dunce::canonicalize(&worktrees[0].path).unwrap();
assert_eq!(
main_wt_path, expected,
"list_worktrees()[0].path should be the submodule working directory, not .git/modules/sub"
);
let main_branch = worktrees[0]
.branch
.as_deref()
.expect("submodule main worktree should have a branch");
let found_path = repository
.worktree_for_branch(main_branch)
.unwrap()
.unwrap();
let found_canonical = dunce::canonicalize(&found_path).unwrap();
assert_eq!(
found_canonical, expected,
"worktree_for_branch() should return submodule working directory for default branch"
);
}
#[test]
fn test_branch_returns_none_for_detached_head() {
let repo = TestRepo::with_initial_commit();
let root = repo.root_path().to_path_buf();
let sha = repo.git_output(&["rev-parse", "HEAD"]);
repo.run_git(&["checkout", "--detach", &sha]);
let repository = Repository::at(&root).unwrap();
let wt = repository.worktree_at(&root);
let result = wt.branch();
assert!(
result.is_ok(),
"branch() should succeed even for detached HEAD"
);
assert!(
result.unwrap().is_none(),
"branch() should return None for detached HEAD"
);
}
#[test]
fn test_branch_returns_branch_for_unborn_repo() {
let repo = TestRepo::empty();
let root = repo.root_path().to_path_buf();
let repository = Repository::at(&root).unwrap();
let wt = repository.worktree_at(&root);
let result = wt.branch();
assert!(
result.is_ok(),
"branch() should succeed for unborn repo (no commits)"
);
assert_eq!(
result.unwrap(),
Some("main".to_string()),
"branch() should return the default branch name even without commits"
);
}
#[test]
fn test_branch_returns_branch_name() {
let repo = TestRepo::new();
let root = repo.root_path().to_path_buf();
let repository = Repository::at(&root).unwrap();
let wt = repository.worktree_at(&root);
let result = wt.branch();
assert!(result.is_ok(), "branch() should succeed");
assert_eq!(
result.unwrap(),
Some("main".to_string()),
"branch() should return the current branch name"
);
}
#[test]
fn test_branch_caches_result() {
let repo = TestRepo::new();
let root = repo.root_path().to_path_buf();
let repository = Repository::at(&root).unwrap();
let wt = repository.worktree_at(&root);
let result1 = wt.branch().unwrap();
let result2 = wt.branch().unwrap();
assert_eq!(result1, result2);
assert_eq!(result1, Some("main".to_string()));
}
#[test]
fn test_is_dirty_does_not_detect_skip_worktree_changes() {
let repo = TestRepo::new();
let root = repo.root_path().to_path_buf();
let file_path = root.join("local.env");
fs::write(&file_path, "original").unwrap();
repo.run_git(&["add", "local.env"]);
repo.run_git(&["commit", "-m", "add local.env"]);
repo.run_git(&["update-index", "--skip-worktree", "local.env"]);
fs::write(&file_path, "modified but hidden").unwrap();
let repository = Repository::at(&root).unwrap();
let wt = repository.worktree_at(&root);
assert!(
!wt.is_dirty().unwrap(),
"is_dirty() does not detect skip-worktree changes by design"
);
}
#[test]
fn test_sparse_checkout_paths_empty_for_normal_repo() {
let repo = TestRepo::new();
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let paths = repository.sparse_checkout_paths();
assert!(
paths.is_empty(),
"normal repo should have no sparse checkout paths"
);
}
#[test]
fn test_sparse_checkout_paths_returns_cone_paths() {
let repo = TestRepo::new();
let dir1 = repo.root_path().join("dir1");
let dir2 = repo.root_path().join("dir2");
fs::create_dir_all(&dir1).unwrap();
fs::create_dir_all(&dir2).unwrap();
fs::write(dir1.join("file.txt"), "content1").unwrap();
fs::write(dir2.join("file.txt"), "content2").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "add directories"]);
repo.run_git(&["sparse-checkout", "init", "--cone"]);
repo.run_git(&["sparse-checkout", "set", "dir1", "dir2"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let paths = repository.sparse_checkout_paths();
assert_eq!(paths, &["dir1".to_string(), "dir2".to_string()]);
}
#[test]
fn test_sparse_checkout_paths_cached() {
let repo = TestRepo::new();
let dir1 = repo.root_path().join("dir1");
fs::create_dir_all(&dir1).unwrap();
fs::write(dir1.join("file.txt"), "content").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "add dir1"]);
repo.run_git(&["sparse-checkout", "init", "--cone"]);
repo.run_git(&["sparse-checkout", "set", "dir1"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let first = repository.sparse_checkout_paths();
let second = repository.sparse_checkout_paths();
assert_eq!(first, second);
assert_eq!(first, &["dir1".to_string()]);
}
#[test]
fn test_branch_diff_stats_scoped_to_sparse_checkout() {
let repo = TestRepo::new();
let inside = repo.root_path().join("inside");
let outside = repo.root_path().join("outside");
fs::create_dir_all(&inside).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(inside.join("file.txt"), "base content\n").unwrap();
fs::write(outside.join("file.txt"), "base content\n").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "add directories"]);
repo.run_git(&["checkout", "-b", "feature"]);
fs::write(inside.join("file.txt"), "modified inside\nadded line\n").unwrap();
fs::write(outside.join("file.txt"), "modified outside\nadded line\n").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "modify both dirs"]);
repo.run_git(&["checkout", "main"]);
repo.run_git(&["sparse-checkout", "init", "--cone"]);
repo.run_git(&["sparse-checkout", "set", "inside"]);
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let stats = repository.branch_diff_stats("main", "feature").unwrap();
assert_eq!(stats.added, 2, "sparse: only inside/ additions");
assert_eq!(stats.deleted, 1, "sparse: only inside/ deletions");
repo.run_git(&["sparse-checkout", "disable"]);
let full_repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
let full_stats = full_repository
.branch_diff_stats("main", "feature")
.unwrap();
assert_eq!(full_stats.added, 4, "full: inside/ + outside/ additions");
assert_eq!(full_stats.deleted, 2, "full: inside/ + outside/ deletions");
}