mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
1eded27943
This consolidates cross-cutting models that had accumulated parallel representations, while preserving the CLI and config interfaces. ## What changed - Forge identity now flows through one `ForgeKind`, with boundary-safe network-host classification shared by CI, remote references, and structured repository metadata. Branded SSH aliases such as `github-personal` remain outside provider dispatch and receive an actionable `forge.platform` diagnostic when forge data is requested. - Worktree removal now uses one owned target type and rechecks uncached worktree topology immediately before compare-and-swap branch deletion, retaining branches that gained a live or locked checkout. - Shell integration no longer implements the retired single-file directive writer. Stale wrappers receive repair guidance, execution fails closed, and child processes cannot inherit the retired sourceable-file capability. - Zsh and Git-version probes are shared, while dead mocks, redundant dependency edges, serializer detours, pass-through types, and duplicate tests are removed. The removal guard deliberately distinguishes live, stale-prunable, and locked registrations. The detached cleanup path performs the same record-aware check before deleting a branch ref. ## Testing `cargo run -- hook pre-merge --yes` passed after merging current `origin/main`: 4,489 tests, Clippy, formatting, lockfile checks, docs, doctests, and snapshot review. > _This was written by Claude Code on behalf of max_.
1253 lines
45 KiB
Rust
1253 lines
45 KiB
Rust
//! Tests for git repository methods to improve code coverage.
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use worktrunk::git::{InProgressOperation, RefType, Repository};
|
|
|
|
use crate::common::{BareRepoTest, TestRepo};
|
|
|
|
// =============================================================================
|
|
// is_bare() tests
|
|
// =============================================================================
|
|
|
|
/// When `core.bare` is unset (e.g., repos cloned by Eclipse/EGit), `is_bare()`
|
|
/// must return `false`. Before the fix for #1939, `git rev-parse
|
|
/// --is-bare-repository` was used, which infers `true` from inside `.git/`
|
|
/// when `core.bare` is absent.
|
|
#[test]
|
|
fn test_is_bare_returns_false_when_core_bare_unset() {
|
|
let repo = TestRepo::new();
|
|
|
|
// Simulate a repo where core.bare was never written (e.g., Eclipse/EGit)
|
|
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());
|
|
}
|
|
|
|
// =============================================================================
|
|
// operation_in_progress() tests - simulate various git operation states
|
|
// =============================================================================
|
|
|
|
/// Build a repo, let `setup` plant state files under its git dir, and report
|
|
/// what `operation_in_progress` makes of them.
|
|
fn operation_after(setup: impl FnOnce(&Path)) -> Option<InProgressOperation> {
|
|
let repo = TestRepo::new();
|
|
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
setup(&repo.root_path().join(".git"));
|
|
repository.operation_in_progress().unwrap()
|
|
}
|
|
|
|
/// Plant the todo list a multi-commit `git cherry-pick`/`git revert` leaves
|
|
/// behind while instructions are still queued.
|
|
fn write_sequencer_todo(git_dir: &Path, todo: &str) {
|
|
let sequencer = git_dir.join("sequencer");
|
|
fs::create_dir_all(&sequencer).unwrap();
|
|
fs::write(sequencer.join("todo"), todo).unwrap();
|
|
}
|
|
|
|
/// `operation_in_progress` reads each in-progress operation off the state files
|
|
/// git writes under the git dir. Both rebase backends report as a rebase:
|
|
/// `rebase-merge` (interactive/merge) and `rebase-apply` (am backend, shared
|
|
/// with `git am`).
|
|
#[test]
|
|
fn test_operation_in_progress_detects_each_operation() {
|
|
assert_eq!(operation_after(|_| {}), None, "clean worktree");
|
|
|
|
assert_eq!(
|
|
operation_after(|d| fs::write(d.join("MERGE_HEAD"), "abc123\n").unwrap()),
|
|
Some(InProgressOperation::Merge),
|
|
"merge",
|
|
);
|
|
assert_eq!(
|
|
operation_after(|d| fs::create_dir_all(d.join("rebase-merge")).unwrap()),
|
|
Some(InProgressOperation::Rebase),
|
|
"rebase, merge backend",
|
|
);
|
|
assert_eq!(
|
|
operation_after(|d| fs::create_dir_all(d.join("rebase-apply")).unwrap()),
|
|
Some(InProgressOperation::Rebase),
|
|
"rebase, am backend",
|
|
);
|
|
assert_eq!(
|
|
operation_after(|d| fs::write(d.join("CHERRY_PICK_HEAD"), "def456\n").unwrap()),
|
|
Some(InProgressOperation::CherryPick),
|
|
"cherry-pick",
|
|
);
|
|
assert_eq!(
|
|
operation_after(|d| fs::write(d.join("REVERT_HEAD"), "789abc\n").unwrap()),
|
|
Some(InProgressOperation::Revert),
|
|
"revert",
|
|
);
|
|
assert_eq!(
|
|
operation_after(|d| fs::write(d.join("BISECT_LOG"), "# bisect log\n").unwrap()),
|
|
Some(InProgressOperation::Bisect),
|
|
"bisect",
|
|
);
|
|
|
|
assert_eq!(
|
|
operation_after(|d| write_sequencer_todo(d, "pick abc123 first\npick def456 second\n")),
|
|
Some(InProgressOperation::CherryPick),
|
|
"queued cherry-pick",
|
|
);
|
|
assert_eq!(
|
|
operation_after(|d| write_sequencer_todo(d, "revert abc123 first\n")),
|
|
Some(InProgressOperation::Revert),
|
|
"queued revert",
|
|
);
|
|
assert_eq!(
|
|
operation_after(|d| write_sequencer_todo(d, "")),
|
|
None,
|
|
"drained sequencer",
|
|
);
|
|
// `label` belongs to a rebase todo, which lives under `rebase-merge` and is
|
|
// detected there; an unrecognized instruction is not a sequence to report.
|
|
assert_eq!(
|
|
operation_after(|d| write_sequencer_todo(d, "label onto\n")),
|
|
None,
|
|
"todo that isn't a pick or revert sequence",
|
|
);
|
|
}
|
|
|
|
/// Resolving a stopped cherry-pick with `git commit` instead of `git cherry-pick
|
|
/// --continue` clears `CHERRY_PICK_HEAD` while the rest of the sequence stays
|
|
/// queued. `git status` still calls that a cherry-pick in progress, and so must
|
|
/// this: the `_HEAD` files alone report an idle worktree with picks pending.
|
|
#[test]
|
|
fn test_operation_in_progress_reads_the_queued_sequencer() {
|
|
let repo = TestRepo::with_initial_commit();
|
|
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
let root = repo.root_path().to_path_buf();
|
|
|
|
repo.run_git(&["switch", "-c", "side"]);
|
|
repo.commit_in_worktree(&root, "conflict.txt", "side one\n", "Side edit");
|
|
repo.commit_in_worktree(&root, "conflict.txt", "side two\n", "Side edit again");
|
|
repo.run_git(&["switch", "main"]);
|
|
repo.commit_in_worktree(&root, "conflict.txt", "main\n", "Main edit");
|
|
|
|
// Two picks, so instructions remain queued once the first one stops.
|
|
let picked = repo
|
|
.git_command()
|
|
.args(["cherry-pick", "side~1", "side"])
|
|
.run()
|
|
.unwrap();
|
|
assert!(!picked.status.success(), "the first pick must conflict");
|
|
assert_eq!(
|
|
repository.operation_in_progress().unwrap(),
|
|
Some(InProgressOperation::CherryPick),
|
|
"stopped pick",
|
|
);
|
|
|
|
fs::write(root.join("conflict.txt"), "resolved\n").unwrap();
|
|
repo.run_git(&["add", "conflict.txt"]);
|
|
repo.run_git(&["commit", "--no-edit"]);
|
|
|
|
assert!(
|
|
!root.join(".git").join("CHERRY_PICK_HEAD").exists(),
|
|
"committing by hand is what clears the state file",
|
|
);
|
|
assert_eq!(
|
|
repository.operation_in_progress().unwrap(),
|
|
Some(InProgressOperation::CherryPick),
|
|
"the remaining picks are still queued",
|
|
);
|
|
|
|
// `git cherry-pick --quit` is how the queue is abandoned; state clears with it.
|
|
repo.run_git(&["cherry-pick", "--quit"]);
|
|
assert_eq!(repository.operation_in_progress().unwrap(), None);
|
|
}
|
|
|
|
// =============================================================================
|
|
// available_branches() tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_available_branches_all_have_worktrees() {
|
|
let mut repo = TestRepo::new();
|
|
// main branch already has a worktree (the main repo)
|
|
// Create feature branch with worktree
|
|
repo.add_worktree("feature");
|
|
|
|
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
let available = repository.available_branches().unwrap();
|
|
|
|
// Both main and feature have worktrees, so nothing should be available
|
|
assert!(available.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_available_branches_some_without_worktrees() {
|
|
let repo = TestRepo::with_initial_commit();
|
|
// Create a branch without a worktree
|
|
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();
|
|
|
|
// orphan-branch has no worktree, so it should be available
|
|
assert!(available.contains(&"orphan-branch".to_string()));
|
|
// main has a worktree, so it should not be available
|
|
assert!(!available.contains(&"main".to_string()));
|
|
}
|
|
|
|
// =============================================================================
|
|
// all_branches() tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_all_branches() {
|
|
let repo = TestRepo::with_initial_commit();
|
|
// Create some branches
|
|
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()));
|
|
}
|
|
|
|
// =============================================================================
|
|
// project_identifier() URL parsing tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_project_identifier_https() {
|
|
let mut repo = TestRepo::with_initial_commit();
|
|
repo.setup_remote("main");
|
|
// Override the remote URL to https format
|
|
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");
|
|
// Override the remote URL to http format (no SSL)
|
|
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");
|
|
// Override the remote URL to SSH format with colon
|
|
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_scp_custom_user() {
|
|
let mut repo = TestRepo::with_initial_commit();
|
|
repo.setup_remote("main");
|
|
repo.git_command()
|
|
.args([
|
|
"remote",
|
|
"set-url",
|
|
"origin",
|
|
"org-14957082@github.com:openai/codex.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/openai/codex");
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_identifier_ssh_protocol() {
|
|
let mut repo = TestRepo::with_initial_commit();
|
|
repo.setup_remote("main");
|
|
// Override the remote URL to ssh:// format
|
|
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();
|
|
// ssh://git@github.com/user/repo.git -> github.com/user/repo
|
|
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");
|
|
// Override the remote URL to ssh:// format with port
|
|
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();
|
|
// Port is stripped — irrelevant to project identity
|
|
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();
|
|
// Should be the full canonical path (security: avoids collisions across unrelated repos)
|
|
let expected = dunce::canonicalize(repo.root_path()).unwrap();
|
|
assert_eq!(id, expected.to_str().unwrap());
|
|
}
|
|
|
|
// =============================================================================
|
|
// config_value/set_config tests
|
|
// =============================================================================
|
|
|
|
#[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();
|
|
|
|
// Verify it was set
|
|
let value = repository.config_value("test.setting").unwrap();
|
|
assert_eq!(value, Some("new-value".to_string()));
|
|
}
|
|
|
|
// =============================================================================
|
|
// config_value() error handling: corrupt config propagation
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_config_value_propagates_error_on_corrupt_config() {
|
|
let repo = TestRepo::new();
|
|
let root = repo.root_path().to_path_buf();
|
|
|
|
// Create repository before corrupting config
|
|
let repository = Repository::at(root.clone()).unwrap();
|
|
|
|
// Corrupt the git config file after repository creation
|
|
let config_path = root.join(".git/config");
|
|
fs::write(&config_path, "[invalid section\n").unwrap();
|
|
|
|
let result = repository.config_value("test.key");
|
|
|
|
// Should propagate the error, not silently return None
|
|
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();
|
|
|
|
// Create repository and set a hint before corrupting config
|
|
let repository = Repository::at(root.clone()).unwrap();
|
|
repository.mark_hint_shown("test-hint").unwrap();
|
|
|
|
// Corrupt the git config file
|
|
let config_path = root.join(".git/config");
|
|
fs::write(&config_path, "[invalid section\n").unwrap();
|
|
|
|
let result = repository.clear_hint("test-hint");
|
|
|
|
// Should propagate the error, not silently return Ok(false)
|
|
assert!(
|
|
result.is_err(),
|
|
"clear_hint() should propagate errors from corrupt config, not return Ok(false)"
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Bulk config cache coverage
|
|
// =============================================================================
|
|
|
|
/// `mark_hint_shown` → `has_shown_hint` → `list_shown_hints` → `clear_hint`
|
|
/// exercises the full write-then-read round trip through the bulk config
|
|
/// cache, including coherent in-memory updates.
|
|
#[test]
|
|
fn test_hint_roundtrip_through_bulk_cache() {
|
|
let repo = TestRepo::new();
|
|
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
|
|
// Populate bulk cache before the write so set/unset hit the in-memory
|
|
// update paths.
|
|
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"));
|
|
|
|
// Deterministic alphabetical ordering (bulk cache is a HashMap — order
|
|
// must be explicitly sorted for display).
|
|
let hints = r.list_shown_hints();
|
|
assert_eq!(hints, vec!["alpha".to_string(), "zebra".to_string()]);
|
|
|
|
// Clear one → coherent in-memory removal.
|
|
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()]);
|
|
|
|
// Clear missing → Ok(false).
|
|
assert!(!r.clear_hint("never-set").unwrap());
|
|
}
|
|
|
|
/// `primary_remote()` honours `checkout.defaultRemote` when it points at
|
|
/// a configured remote — covers the early-return branch in the new bulk
|
|
/// lookup.
|
|
#[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");
|
|
// With no `checkout.defaultRemote`, falls back to the first remote
|
|
// with a URL (the filter-out-phantom-entries path).
|
|
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");
|
|
}
|
|
|
|
/// `all_remote_urls()` enumerates every configured remote via the bulk
|
|
/// map, filtering out phantom entries (keys with `remote.X.*` that have
|
|
/// no `.url`).
|
|
#[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",
|
|
]);
|
|
// A phantom entry: remote.X.prunetags set but no URL → should not appear.
|
|
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");
|
|
}
|
|
|
|
/// `unset_config_value` cleanly removes in-memory state after the bulk
|
|
/// cache is populated. Guards against a regression where the in-memory
|
|
/// remove used the literal key instead of the canonical form.
|
|
#[test]
|
|
fn test_unset_config_removes_from_bulk_cache() {
|
|
let repo = TestRepo::new();
|
|
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
|
|
// Populate cache, then write a mixed-case variable key (canonical
|
|
// variable name is lowercased by git).
|
|
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())
|
|
);
|
|
|
|
// Unset removes it — subsequent reads return None.
|
|
assert!(r.unset_config("branch.main.pushRemote").unwrap());
|
|
assert_eq!(r.config_value("branch.main.pushRemote").unwrap(), None);
|
|
|
|
// Unsetting again → Ok(false).
|
|
assert!(!r.unset_config("branch.main.pushRemote").unwrap());
|
|
}
|
|
|
|
/// `set_default_branch` → `clear_default_branch_cache` round trip,
|
|
/// covering the specialized default-branch writers that route through
|
|
/// `set_config_value` / `unset_config_value`.
|
|
#[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()));
|
|
|
|
// Clearing an existing cache returns true; a second clear returns false.
|
|
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());
|
|
}
|
|
|
|
/// `switch_previous` / `set_switch_previous` round trip. Exercises
|
|
/// `worktrunk.history` read + write through the bulk-config helpers.
|
|
#[test]
|
|
fn test_switch_previous_roundtrip() {
|
|
let repo = TestRepo::new();
|
|
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
// Populate the cache first to hit the in-memory update branch.
|
|
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()));
|
|
// `None` is a no-op — doesn't clear.
|
|
r.set_switch_previous(None).unwrap();
|
|
assert_eq!(r.switch_previous(), Some("feature-a".to_string()));
|
|
}
|
|
|
|
/// `primary_remote_url` composes `primary_remote` + `remote_url`,
|
|
/// returning the raw URL for the primary remote. `primary_remote_parsed_url`
|
|
/// threads that through `GitRemoteUrl::parse`.
|
|
#[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");
|
|
|
|
// Without a remote, both return None.
|
|
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());
|
|
}
|
|
|
|
/// `detect_ref_type` resolves the platform's PR/MR reference type from the
|
|
/// primary remote URL. Drives the numeric branch hint in `BranchNotFound`
|
|
/// ("To switch to PR #N, run wt switch pr:N"). The `forge.platform` config
|
|
/// branch is exercised end-to-end via the `Repository::ci_platform` paths.
|
|
#[test]
|
|
fn test_detect_ref_type() {
|
|
// GitHub remote → PR
|
|
let github = TestRepo::new();
|
|
github.run_git(&[
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://github.com/max-sixty/worktrunk.git",
|
|
]);
|
|
let r = Repository::at(github.root_path().to_path_buf()).unwrap();
|
|
assert_eq!(r.detect_ref_type(), Some(RefType::Pr));
|
|
|
|
// GitLab remote → MR
|
|
let gitlab = TestRepo::new();
|
|
gitlab.run_git(&[
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://gitlab.com/owner/repo.git",
|
|
]);
|
|
let r = Repository::at(gitlab.root_path().to_path_buf()).unwrap();
|
|
assert_eq!(r.detect_ref_type(), Some(RefType::Mr));
|
|
|
|
// Other known PR forges use the same canonical mapping.
|
|
for url in [
|
|
"https://gitea.example.com/owner/repo.git",
|
|
"https://dev.azure.com/org/project/_git/repo",
|
|
] {
|
|
let repo = TestRepo::new();
|
|
repo.run_git(&["remote", "add", "origin", url]);
|
|
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
assert_eq!(r.detect_ref_type(), Some(RefType::Pr), "{url}");
|
|
}
|
|
|
|
// Unknown host → None
|
|
let other = TestRepo::new();
|
|
other.run_git(&[
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://example.com/owner/repo.git",
|
|
]);
|
|
let r = Repository::at(other.root_path().to_path_buf()).unwrap();
|
|
assert_eq!(r.detect_ref_type(), None);
|
|
|
|
// No remote → None
|
|
let empty = TestRepo::new();
|
|
let r = Repository::at(empty.root_path().to_path_buf()).unwrap();
|
|
assert_eq!(r.detect_ref_type(), None);
|
|
}
|
|
|
|
/// `remote_url` for a configured remote round-trips; unknown remotes
|
|
/// return `None`. Covers the `.filter(|url| !url.is_empty())` branch
|
|
/// via the happy-path URL read.
|
|
#[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);
|
|
}
|
|
|
|
/// `primary_remote()` errors when no remotes are configured — covers
|
|
/// the `ok_or_else(|| anyhow!("No remotes configured"))` final arm.
|
|
#[test]
|
|
fn test_primary_remote_errors_with_no_remotes() {
|
|
let repo = TestRepo::new(); // TestRepo::new() ships without a remote.
|
|
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}"
|
|
);
|
|
}
|
|
|
|
/// `require_target_ref(None)` surfaces `StaleDefaultBranch` when the
|
|
/// persisted default branch no longer resolves locally. Covers the
|
|
/// `target.is_none()` arm added alongside `require_target_branch` for
|
|
/// commands like `wt step commit` that accept any commit-ish target.
|
|
#[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();
|
|
|
|
// Fresh Repository so the OnceCell re-reads the stale value.
|
|
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:?}"
|
|
);
|
|
}
|
|
|
|
/// A freshly `git init`'d repo whose default branch is unborn (no commits
|
|
/// yet) surfaces `UnbornDefaultBranch`, not `StaleDefaultBranch`. The cache
|
|
/// is correct — `main` simply has nothing to diff against — so the
|
|
/// cache-reset hint of `StaleDefaultBranch` would mislead. Reproduces #2989.
|
|
///
|
|
/// Both validators are checked: `require_target_ref` (rebase/squash/diff) and
|
|
/// `require_target_branch` (merge/push). The rendered message must read "has
|
|
/// no commits yet" and omit the cache-reset hint.
|
|
#[test]
|
|
fn test_require_target_surfaces_unborn_default_branch() {
|
|
use worktrunk::git::{Diagnostic, GitError};
|
|
// TestRepo::new() runs `git init -b main` with no commits, so HEAD is unborn.
|
|
let repo = TestRepo::new();
|
|
let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
r.set_config("worktrunk.default-branch", "main").unwrap();
|
|
|
|
// Fresh Repository so the OnceCell re-reads the persisted value.
|
|
let r2 = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
for err in [
|
|
r2.require_target_ref(None).unwrap_err(),
|
|
r2.require_target_branch(None).unwrap_err(),
|
|
] {
|
|
let gerr = err.downcast_ref::<GitError>().expect("GitError");
|
|
assert!(
|
|
matches!(gerr, GitError::UnbornDefaultBranch { branch } if branch == "main"),
|
|
"expected UnbornDefaultBranch, got {gerr:?}"
|
|
);
|
|
let rendered = gerr.render();
|
|
assert!(
|
|
rendered.contains("has no commits yet")
|
|
&& rendered.contains("Make an initial commit on"),
|
|
"unexpected render: {rendered}"
|
|
);
|
|
assert!(
|
|
!rendered.contains("Reset the cached value"),
|
|
"render should omit the stale-cache hint: {rendered}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `unset_config_value` propagates errors from corrupt git config
|
|
/// rather than returning `Ok(false)` (the exit-code-5 "key absent" case).
|
|
#[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();
|
|
|
|
// Corrupt the git config so subsequent writes fail with a real error
|
|
// (not the benign exit-code-5 that maps to Ok(false)).
|
|
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:?}"
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// merge-tree exit code handling tests
|
|
// =============================================================================
|
|
|
|
/// has_merge_conflicts_by_sha returns false for clean merges (exit 0)
|
|
/// and true for conflicts (exit 1).
|
|
#[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"]);
|
|
|
|
// Clean merge: feature adds a new file (no overlap with main)
|
|
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"]);
|
|
|
|
// Conflicting merge: feature edits the same file differently
|
|
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 main_sha = repo.git_output(&["rev-parse", "main"]);
|
|
let clean_sha = repo.git_output(&["rev-parse", "clean-feature"]);
|
|
let conflict_sha = repo.git_output(&["rev-parse", "conflict-feature"]);
|
|
|
|
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
assert!(
|
|
!repository
|
|
.has_merge_conflicts_by_sha(&main_sha, &clean_sha)
|
|
.unwrap()
|
|
);
|
|
assert!(
|
|
repository
|
|
.has_merge_conflicts_by_sha(&main_sha, &conflict_sha)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
/// has_merge_conflicts_by_sha returns true (not Err) for orphan branches,
|
|
/// since unrelated histories can't be cleanly merged.
|
|
#[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 main_sha = repo.git_output(&["rev-parse", "main"]);
|
|
let orphan_sha = repo.git_output(&["rev-parse", "orphan"]);
|
|
|
|
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
|
|
// Orphan branches have no merge base — treated as conflicting, not as an error
|
|
assert!(
|
|
repository
|
|
.has_merge_conflicts_by_sha(&main_sha, &orphan_sha)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
/// merge_integration_probe_by_sha short-circuits for orphan branches:
|
|
/// would_merge_add=true, is_patch_id_match=false.
|
|
#[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 orphan_sha = repo.git_output(&["rev-parse", "orphan"]);
|
|
let main_sha = repo.git_output(&["rev-parse", "main"]);
|
|
|
|
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
let probe = repository
|
|
.merge_integration_probe_by_sha(&orphan_sha, &main_sha)
|
|
.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"
|
|
);
|
|
}
|
|
|
|
/// merge_integration_probe_by_sha correctly detects already-integrated branches
|
|
/// (clean merge that doesn't change target tree).
|
|
#[test]
|
|
fn test_merge_integration_probe_already_integrated() {
|
|
let repo = TestRepo::with_initial_commit();
|
|
|
|
// Create feature, then merge it into main via fast-forward
|
|
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 feature_sha = repo.git_output(&["rev-parse", "feature"]);
|
|
let main_sha = repo.git_output(&["rev-parse", "main"]);
|
|
|
|
let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
|
|
let probe = repository
|
|
.merge_integration_probe_by_sha(&feature_sha, &main_sha)
|
|
.unwrap();
|
|
|
|
assert!(!probe.would_merge_add, "already-merged branch adds nothing");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Bug: repo_path() inside git submodules
|
|
// =============================================================================
|
|
|
|
/// Test that `repo_path()` returns the correct working directory when run inside
|
|
/// a git submodule.
|
|
///
|
|
/// Previously, `repo_path()` derived the path from `git_common_dir.parent()`, which
|
|
/// fails for submodules where git data is stored in `parent/.git/modules/sub`.
|
|
/// The fix tries `git rev-parse --show-toplevel` first (works for submodules),
|
|
/// falling back to parent of git_common_dir for normal repos.
|
|
#[test]
|
|
fn test_repo_path_in_submodule() {
|
|
// Create parent and submodule-origin repos
|
|
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"]);
|
|
|
|
// Add submodule to parent (using local path directly, with file transport allowed)
|
|
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"]);
|
|
|
|
// Now test: create Repository from inside the 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();
|
|
|
|
// The key assertion: repo_path() should return the submodule's working directory,
|
|
// NOT something like parent/.git/modules/sub
|
|
let repo_path = repository.repo_path().unwrap();
|
|
|
|
// Canonicalize both paths for comparison (handles symlinks like /var -> /private/var on macOS)
|
|
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
|
|
);
|
|
|
|
// Also verify that git_common_dir is in the parent's .git/modules/ (confirming this is a real submodule)
|
|
let git_common_dir = repository.git_common_dir();
|
|
// Use components() to check path structure (works on both Unix and Windows)
|
|
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
|
|
);
|
|
|
|
// Verify list_worktrees() returns corrected paths for submodule main worktree.
|
|
// Git's `worktree list` reports the main worktree as .git/modules/sub for submodules,
|
|
// which is wrong — it should be the actual working directory.
|
|
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"
|
|
);
|
|
|
|
// Verify worktree_for_branch() returns the corrected path (this is what `wt switch` uses)
|
|
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"
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// branch() error propagation tests (Bug fix: branch() swallows errors)
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_branch_returns_none_for_detached_head() {
|
|
let repo = TestRepo::with_initial_commit();
|
|
let root = repo.root_path().to_path_buf();
|
|
|
|
// Detach HEAD by checking out a specific commit
|
|
let sha = repo.git_output(&["rev-parse", "HEAD"]);
|
|
|
|
repo.run_git(&["checkout", "--detach", &sha]);
|
|
|
|
// Create a fresh repository instance to avoid cached result
|
|
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);
|
|
|
|
// First call
|
|
let result1 = wt.branch().unwrap();
|
|
// Second call should return cached result
|
|
let result2 = wt.branch().unwrap();
|
|
|
|
assert_eq!(result1, result2);
|
|
assert_eq!(result1, Some("main".to_string()));
|
|
}
|
|
|
|
// =============================================================================
|
|
// is_dirty() behavior tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_is_dirty_does_not_detect_skip_worktree_changes() {
|
|
// This test documents a known limitation: is_dirty() uses `git status --porcelain`
|
|
// which doesn't show files hidden via --skip-worktree or --assume-unchanged.
|
|
//
|
|
// We intentionally don't check for these because:
|
|
// 1. Detecting them requires `git ls-files -v` which lists ALL tracked files
|
|
// 2. On large repos (70k+ files), this adds noticeable latency to every clean check
|
|
// 3. Users who use skip-worktree are power users who understand the implications
|
|
let repo = TestRepo::new();
|
|
let root = repo.root_path().to_path_buf();
|
|
|
|
// Create and commit a file
|
|
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"]);
|
|
|
|
// Mark with skip-worktree and modify
|
|
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);
|
|
|
|
// is_dirty() returns false — this is documented behavior, not a bug
|
|
assert!(
|
|
!wt.is_dirty().unwrap(),
|
|
"is_dirty() does not detect skip-worktree changes by design"
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// sparse_checkout_paths() tests
|
|
// =============================================================================
|
|
|
|
#[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();
|
|
|
|
// Create directories with files and commit them
|
|
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"]);
|
|
|
|
// Set up sparse checkout in cone mode
|
|
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();
|
|
|
|
// Create two directories with files on main
|
|
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"]);
|
|
|
|
// Create feature branch and modify files in both 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"]);
|
|
|
|
// Go back to main and set up sparse checkout
|
|
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();
|
|
|
|
// Only changes in inside/ should be counted
|
|
// inside/file.txt: "base content\n" → "modified inside\nadded line\n" = 2 added, 1 deleted
|
|
assert_eq!(stats.added, 2, "sparse: only inside/ additions");
|
|
assert_eq!(stats.deleted, 1, "sparse: only inside/ deletions");
|
|
|
|
// Disable sparse checkout — full stats include both inside/ and outside/
|
|
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();
|
|
|
|
// Both files have identical diffs, so full = 2x sparse
|
|
assert_eq!(full_stats.added, 4, "full: inside/ + outside/ additions");
|
|
assert_eq!(full_stats.deleted, 2, "full: inside/ + outside/ deletions");
|
|
}
|