diff --git a/src/main.rs b/src/main.rs index 991dccf..d13a89d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1381,24 +1381,22 @@ impl GitChain { } fn dirty_working_directory(&self) -> Result { - // perform equivalent to git diff-index HEAD - let obj = self.repo.revparse_single("HEAD")?; - let tree = obj.peel(ObjectType::Tree)?; + use git2::StatusOptions; - // This is used for diff formatting for diff-index. But we're only interested in the diff stats. - // let mut opts = DiffOptions::new(); - // opts.id_abbrev(40); + // Configure status collection so we can detect *any* change + // in the working directory. This mimics `git status --porcelain` + // by including untracked files and directories. Ignored files + // and paths that haven't changed are skipped so the resulting + // status list only contains meaningful modifications. + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .include_ignored(false) + .include_unmodified(false); - let diff = self - .repo - .diff_tree_to_workdir_with_index(tree.as_tree(), None)?; - - let diff_stats = diff.stats()?; - let has_changes = diff_stats.files_changed() > 0 - || diff_stats.insertions() > 0 - || diff_stats.deletions() > 0; - - Ok(has_changes) + // If the repository reports no statuses, the working tree is clean. + let statuses = self.repo.statuses(Some(&mut opts))?; + Ok(!statuses.is_empty()) } fn backup(&self, chain_name: &str) -> Result<(), Error> { @@ -1420,11 +1418,15 @@ impl GitChain { } if self.dirty_working_directory()? { + let current_branch = self.get_current_branch_name()?; eprintln!( "🛑 Unable to back up branches for the chain: {}", chain.name.bold() ); - eprintln!("You have uncommitted changes in your working directory."); + eprintln!( + "You have uncommitted changes on branch {}.", + current_branch.bold() + ); eprintln!("Please commit or stash them."); process::exit(1); } @@ -1630,9 +1632,11 @@ impl GitChain { } if self.dirty_working_directory()? { - return Err(Error::from_str( - "You have uncommitted changes in your working directory.", - )); + let current_branch = self.get_current_branch_name()?; + return Err(Error::from_str(&format!( + "You have uncommitted changes on branch {}.", + current_branch.bold() + ))); } Ok(()) @@ -2008,9 +2012,11 @@ impl GitChain { // Check for uncommitted changes if self.dirty_working_directory()? { + let current_branch = self.get_current_branch_name()?; return Err(Error::from_str(&format!( - "🛑 Unable to merge branches for the chain: {}\nYou have uncommitted changes in your working directory.\nPlease commit or stash them.", - chain_name.bold() + "🛑 Unable to merge branches for the chain: {}\nYou have uncommitted changes on branch {}.\nPlease commit or stash them.", + chain_name.bold(), + current_branch.bold() ))); } diff --git a/tests/untracked_detection.rs b/tests/untracked_detection.rs new file mode 100644 index 0000000..a13c0e4 --- /dev/null +++ b/tests/untracked_detection.rs @@ -0,0 +1,107 @@ +#[path = "common/mod.rs"] +pub mod common; + +use common::{ + checkout_branch, commit_all, create_branch, create_new_file, first_commit_all, + generate_path_to_repo, get_current_branch_name, run_test_bin_expect_err, + run_test_bin_expect_ok, setup_git_repo, teardown_git_repo, +}; + +#[test] +fn backup_fails_with_untracked_files() { + let repo_name = "backup_fails_with_untracked"; + let repo = setup_git_repo(repo_name); + let path_to_repo = generate_path_to_repo(repo_name); + + // initial commit on master + create_new_file(&path_to_repo, "initial.txt", "initial"); + first_commit_all(&repo, "initial commit"); + + // create feature branch + create_branch(&repo, "feature"); + checkout_branch(&repo, "feature"); + create_new_file(&path_to_repo, "feature.txt", "feature"); + commit_all(&repo, "feature commit"); + + // initialize chain with root master + let args = vec!["init", "chain", "master"]; + run_test_bin_expect_ok(&path_to_repo, args); + + // add untracked file + create_new_file(&path_to_repo, "untracked.txt", "dirty"); + + // attempt backup and expect failure mentioning branch name + let args = vec!["backup"]; + let output = run_test_bin_expect_err(&path_to_repo, args); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("uncommitted")); + assert!(stderr.contains(&get_current_branch_name(&repo))); + + teardown_git_repo(repo_name); +} + +#[test] +fn merge_fails_with_untracked_files() { + let repo_name = "merge_fails_with_untracked"; + let repo = setup_git_repo(repo_name); + let path_to_repo = generate_path_to_repo(repo_name); + + // initial commit on master + create_new_file(&path_to_repo, "initial.txt", "initial"); + first_commit_all(&repo, "initial commit"); + + // create feature branch and commit + create_branch(&repo, "feature"); + checkout_branch(&repo, "feature"); + create_new_file(&path_to_repo, "feature.txt", "feature"); + commit_all(&repo, "feature commit"); + + // initialize chain with root master + let args = vec!["init", "chain", "master"]; + run_test_bin_expect_ok(&path_to_repo, args); + + // add untracked file + create_new_file(&path_to_repo, "untracked.txt", "dirty"); + + // attempt merge and expect failure mentioning branch name + let args = vec!["merge"]; + let output = run_test_bin_expect_err(&path_to_repo, args); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("uncommitted")); + assert!(stderr.contains(&get_current_branch_name(&repo))); + + teardown_git_repo(repo_name); +} + +#[test] +fn rebase_fails_with_untracked_files() { + let repo_name = "rebase_fails_with_untracked"; + let repo = setup_git_repo(repo_name); + let path_to_repo = generate_path_to_repo(repo_name); + + // initial commit on master + create_new_file(&path_to_repo, "initial.txt", "initial"); + first_commit_all(&repo, "initial commit"); + + // create feature branch and commit + create_branch(&repo, "feature"); + checkout_branch(&repo, "feature"); + create_new_file(&path_to_repo, "feature.txt", "feature"); + commit_all(&repo, "feature commit"); + + // initialize chain with root master + let args = vec!["init", "chain", "master"]; + run_test_bin_expect_ok(&path_to_repo, args); + + // add untracked file + create_new_file(&path_to_repo, "untracked.txt", "dirty"); + + // attempt rebase and expect failure mentioning branch name + let args = vec!["rebase"]; + let output = run_test_bin_expect_err(&path_to_repo, args); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("uncommitted")); + assert!(stderr.contains(&get_current_branch_name(&repo))); + + teardown_git_repo(repo_name); +}