Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/git/repository/working_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -584,10 +584,16 @@ impl<'a> WorkingTree<'a> {
/// 3. Users who use skip-worktree are power users who understand the implications
/// 4. A warning wouldn't prevent data loss anyway — it's informational only
///
/// Untracked files are always included, regardless of the user's
/// `status.showUntrackedFiles` display preference.
/// Untracked files and dirty submodules are always included, regardless of
/// the user's `status.showUntrackedFiles` or `submodule.<name>.ignore`
/// display preferences.
pub fn is_dirty(&self) -> anyhow::Result<bool> {
let stdout = self.run_command(&["status", "--porcelain", "--untracked-files=normal"])?;
let stdout = self.run_command(&[
"status",
"--porcelain",
"--untracked-files=normal",
"--ignore-submodules=none",
])?;
Ok(!stdout.trim().is_empty())
}

Expand All @@ -598,7 +604,12 @@ impl<'a> WorkingTree<'a> {
/// [`GitError::UncommittedChanges`] in [`Self::ensure_clean`]. The same
/// caveats as [`Self::is_dirty`] apply (skip-worktree files are invisible).
pub fn dirty_files(&self) -> anyhow::Result<Vec<String>> {
let stdout = self.run_command(&["status", "--porcelain", "--untracked-files=normal"])?;
let stdout = self.run_command(&[
"status",
"--porcelain",
"--untracked-files=normal",
"--ignore-submodules=none",
])?;
Ok(stdout.lines().map(str::to_owned).collect())
}

Expand Down
101 changes: 101 additions & 0 deletions tests/integration_tests/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4259,6 +4259,107 @@ fn test_remove_worktree_submodule_dirty_fails_closed(mut repo: TestRepo) {
);
}

/// `submodule.<name>.ignore=all` is a display preference, not permission to
/// delete modifications inside an initialized submodule. This is especially
/// important because Worktrunk must internally force Git's worktree removal
/// when initialized submodules are present.
#[rstest]
fn test_remove_refuses_dirty_submodule_hidden_by_user_config(mut repo: TestRepo) {
let sub_source = repo.root_path().parent().unwrap().join("sub-source-hidden");
fs::create_dir_all(&sub_source).unwrap();
repo.run_git_in(&sub_source, &["init", "-b", "main"]);
fs::write(sub_source.join("sub.txt"), "submodule content").unwrap();
repo.run_git_in(&sub_source, &["add", "sub.txt"]);
repo.run_git_in(&sub_source, &["commit", "-m", "sub init"]);

let output = repo
.git_command()
.args([
"-c",
"protocol.file.allow=always",
"submodule",
"add",
sub_source.to_str().unwrap(),
"submod",
])
.run()
.unwrap();
assert!(
output.status.success(),
"Failed to add submodule: {}",
String::from_utf8_lossy(&output.stderr)
);
repo.run_git(&["commit", "-m", "add submodule"]);

let worktree_path = repo.add_worktree("feature-submod-hidden-dirty");
let output = repo
.git_command()
.current_dir(&worktree_path)
.args([
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
])
.run()
.unwrap();
assert!(
output.status.success(),
"Failed to init submodule: {}",
String::from_utf8_lossy(&output.stderr)
);

repo.run_git(&["config", "submodule.submod.ignore", "all"]);
let changed_file = worktree_path.join("submod/sub.txt");
fs::write(&changed_file, "DIRTIED\n").unwrap();

let hidden = repo
.git_command()
.current_dir(&worktree_path)
.args(["status", "--porcelain"])
.run()
.unwrap();
assert!(
hidden.stdout.is_empty(),
"the fixture must demonstrate that the user setting hides the dirty submodule"
);
let forced = repo
.git_command()
.current_dir(&worktree_path)
.args(["status", "--porcelain", "--ignore-submodules=none"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&forced.stdout).contains("submod"),
"the explicit safety query must reveal the dirty submodule"
);

let output = repo
.wt_command()
.args(["remove", "--foreground", "feature-submod-hidden-dirty"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"remove must refuse a dirty submodule hidden by config; stderr:\n{stderr}"
);
assert!(
stderr.contains("submod"),
"the refusal must name the dirty submodule; stderr:\n{stderr}"
);
assert!(
worktree_path.exists(),
"the hidden dirty submodule's worktree must be preserved"
);
assert_eq!(
fs::read_to_string(changed_file).unwrap(),
"DIRTIED\n",
"the hidden submodule change must remain recoverable"
);
}

/// Restore write permissions recursively so TempDir cleanup succeeds.
#[cfg(unix)]
fn restore_dir_permissions(dir: &std::path::Path) {
Expand Down
Loading