diff --git a/aria/contents/docs/libra/command/rm/index.mdx b/aria/contents/docs/libra/command/rm/index.mdx
index 32ec0fa10..69694fbe8 100644
--- a/aria/contents/docs/libra/command/rm/index.mdx
+++ b/aria/contents/docs/libra/command/rm/index.mdx
@@ -26,3 +26,6 @@ that.)
- `-r`, `--recursive`
Allow recursive removal when a leading directory name is given.
+
+- `-f`, `--force`
+ Force removal even if the specified paths are not tracked. This skips validation checks and deletes files or directories regardless of their index status.
diff --git a/libra/Cargo.toml b/libra/Cargo.toml
index 400e9a115..c373376d7 100644
--- a/libra/Cargo.toml
+++ b/libra/Cargo.toml
@@ -52,6 +52,8 @@ tracing-subscriber = { workspace = true }
wax = { workspace = true }
url = { workspace = true }
ignore = { workspace = true }
+tempfile = { workspace = true }
+serial_test = { workspace = true }
[target.'cfg(unix)'.dependencies] # only on Unix
pager = { workspace = true }
diff --git a/libra/src/command/remove.rs b/libra/src/command/remove.rs
index fea07ccd7..4d954090a 100644
--- a/libra/src/command/remove.rs
+++ b/libra/src/command/remove.rs
@@ -13,13 +13,16 @@ use mercury::internal::index::Index;
#[derive(Parser, Debug)]
pub struct RemoveArgs {
/// file or dir to remove
- pathspec: Vec,
+ pub pathspec: Vec,
/// whether to remove from index
#[clap(long)]
- cached: bool,
+ pub cached: bool,
/// indicate recursive remove dir
#[clap(short, long)]
- recursive: bool,
+ pub recursive: bool,
+ /// force removal, skip validation
+ #[clap(short, long)]
+ pub force: bool,
}
pub fn execute(args: RemoveArgs) -> Result<(), GitError> {
@@ -28,11 +31,13 @@ pub fn execute(args: RemoveArgs) -> Result<(), GitError> {
}
let idx_file = path::index();
let mut index = Index::load(&idx_file)?;
- // check if pathspec is all in index
- if !validate_pathspec(&args.pathspec, &index) {
+
+ // check if pathspec is all in index (skip if force is enabled)
+ if !args.force && !validate_pathspec(&args.pathspec, &index) {
return Ok(());
}
- let dirs = get_dirs(&args.pathspec, &index);
+
+ let dirs = get_dirs(&args.pathspec, &index, args.force);
if !dirs.is_empty() && !args.recursive {
println!(
"fatal: not removing '{}' recursively without -r",
@@ -44,6 +49,7 @@ pub fn execute(args: RemoveArgs) -> Result<(), GitError> {
for path_str in args.pathspec.iter() {
let path = PathBuf::from(path_str);
let path_wd = path.to_workdir().to_string_or_panic();
+
if dirs.contains(path_str) {
// dir
let removed = index.remove_dir_files(&path_wd);
@@ -56,8 +62,20 @@ pub fn execute(args: RemoveArgs) -> Result<(), GitError> {
}
} else {
// file
- index.remove(&path_wd, 0);
- println!("rm '{}'", path_wd.bright_green());
+ if args.force {
+ // In force mode, remove from index if tracked, otherwise just delete from filesystem
+ if index.tracked(&path_wd, 0) {
+ index.remove(&path_wd, 0);
+ println!("rm '{}'", path_wd.bright_green());
+ } else {
+ println!("rm '{}'", path_wd.bright_yellow());
+ }
+ } else {
+ // Normal mode - only remove if tracked
+ index.remove(&path_wd, 0);
+ println!("rm '{}'", path_wd.bright_green());
+ }
+
if !args.cached {
fs::remove_file(&path)?;
}
@@ -90,14 +108,22 @@ fn validate_pathspec(pathspec: &[String], index: &Index) -> bool {
}
/// run after `validate_pathspec`
-fn get_dirs(pathspec: &[String], index: &Index) -> Vec {
+fn get_dirs(pathspec: &[String], index: &Index, force: bool) -> Vec {
let mut dirs = Vec::new();
for path_str in pathspec.iter() {
let path = PathBuf::from(path_str);
let path_wd = path.to_workdir().to_string_or_panic();
- // valid but not tracked, means a dir
- if !index.tracked(&path_wd, 0) {
- dirs.push(path_str.clone());
+
+ if force {
+ // In force mode, check if the path exists and is a directory
+ if path.exists() && path.is_dir() {
+ dirs.push(path_str.clone());
+ }
+ } else {
+ // valid but not tracked, means a dir
+ if !index.tracked(&path_wd, 0) {
+ dirs.push(path_str.clone());
+ }
}
}
dirs
diff --git a/libra/tests/command/remove_test.rs b/libra/tests/command/remove_test.rs
index 8b1378917..c1649165e 100644
--- a/libra/tests/command/remove_test.rs
+++ b/libra/tests/command/remove_test.rs
@@ -1 +1,312 @@
+use libra::command::{add, commit, init, remove};
+use libra::utils::test;
+use serial_test::serial;
+use tempfile::tempdir;
+use std::path::Path;
+async fn test_remove_file() {
+ println!("\n\x1b[1mTest remove file functionality.\x1b[0m");
+
+ // Create a test file
+ test::ensure_file("test_file.txt", Some("test content"));
+
+ // Add file to index
+ let add_args = add::AddArgs {
+ all: false,
+ update: false,
+ verbose: false,
+ pathspec: vec!["test_file.txt".to_string()],
+ dry_run: false,
+ refresh: false,
+ ignore_errors: false,
+ };
+ add::execute(add_args).await;
+
+ // Remove file from index and filesystem
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["test_file.txt".to_string()],
+ cached: false,
+ recursive: false,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Verify file is removed from filesystem
+ assert!(!Path::new("test_file.txt").exists());
+}
+
+async fn test_remove_cached_file() {
+ println!("\n\x1b[1mTest remove cached file functionality.\x1b[0m");
+
+ // Create a test file
+ test::ensure_file("cached_file.txt", Some("cached content"));
+
+ // Add file to index
+ let add_args = add::AddArgs {
+ all: false,
+ update: false,
+ verbose: false,
+ pathspec: vec!["cached_file.txt".to_string()],
+ dry_run: false,
+ refresh: false,
+ ignore_errors: false,
+ };
+ add::execute(add_args).await;
+
+ // Remove file from index only (keep in filesystem)
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["cached_file.txt".to_string()],
+ cached: true,
+ recursive: false,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Verify file still exists in filesystem
+ assert!(Path::new("cached_file.txt").exists());
+}
+
+async fn test_remove_directory() {
+ println!("\n\x1b[1mTest remove directory functionality.\x1b[0m");
+
+ // Create directory structure
+ test::ensure_file("test_dir/file1.txt", Some("file1 content"));
+ test::ensure_file("test_dir/file2.txt", Some("file2 content"));
+ test::ensure_file("test_dir/subdir/file3.txt", Some("file3 content"));
+
+ // Add all files to index
+ let add_args = add::AddArgs {
+ all: true,
+ update: false,
+ verbose: false,
+ pathspec: vec![],
+ dry_run: false,
+ refresh: false,
+ ignore_errors: false,
+ };
+ add::execute(add_args).await;
+
+ // Remove directory recursively
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["test_dir".to_string()],
+ cached: false,
+ recursive: true,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Verify directory is removed
+ assert!(!Path::new("test_dir").exists());
+}
+
+async fn test_remove_directory_without_recursive() {
+ println!("\n\x1b[1mTest remove directory without recursive flag.\x1b[0m");
+
+ // Create directory structure
+ test::ensure_file("test_dir2/file1.txt", Some("file1 content"));
+ test::ensure_file("test_dir2/file2.txt", Some("file2 content"));
+
+ // Add all files to index
+ let add_args = add::AddArgs {
+ all: true,
+ update: false,
+ verbose: false,
+ pathspec: vec![],
+ dry_run: false,
+ refresh: false,
+ ignore_errors: false,
+ };
+ add::execute(add_args).await;
+
+ // Try to remove directory without recursive flag (should fail)
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["test_dir2".to_string()],
+ cached: false,
+ recursive: false,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Directory should still exist because recursive flag was not used
+ assert!(Path::new("test_dir2").exists());
+}
+
+async fn test_force_remove_untracked_file() {
+ println!("\n\x1b[1mTest force remove untracked file.\x1b[0m");
+
+ // Create an untracked file
+ test::ensure_file("untracked_file.txt", Some("untracked content"));
+
+ // Force remove untracked file
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["untracked_file.txt".to_string()],
+ cached: false,
+ recursive: false,
+ force: true,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Verify file is removed
+ assert!(!Path::new("untracked_file.txt").exists());
+}
+
+async fn test_no_force_remove_untracked_file() {
+ println!("\n\x1b[1mTest remove untracked file without force.\x1b[0m");
+
+ // Create an untracked file
+ test::ensure_file("untracked_file.txt", Some("untracked content"));
+
+ // remove untracked file without force
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["untracked_file.txt".to_string()],
+ cached: false,
+ recursive: false,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Verify file is removed
+ assert!(Path::new("untracked_file.txt").exists());
+}
+
+async fn test_force_remove_untracked_directory() {
+ println!("\n\x1b[1mTest force remove untracked directory.\x1b[0m");
+
+ // Create untracked directory structure
+ test::ensure_file("untracked_dir/file1.txt", Some("file1 content"));
+ test::ensure_file("untracked_dir/file2.txt", Some("file2 content"));
+ test::ensure_file("untracked_dir/subdir/file3.txt", Some("file3 content"));
+
+ // Force remove untracked directory
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["untracked_dir".to_string()],
+ cached: false,
+ recursive: true,
+ force: true,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Verify directory is removed
+ assert!(!Path::new("untracked_dir").exists());
+}
+
+async fn test_remove_multiple_files() {
+ println!("\n\x1b[1mTest remove multiple files.\x1b[0m");
+
+ // Create multiple files
+ test::ensure_file("file1.txt", Some("content1"));
+ test::ensure_file("file2.txt", Some("content2"));
+ test::ensure_file("file3.txt", Some("content3"));
+
+ // Add files to index
+ let add_args = add::AddArgs {
+ all: true,
+ update: false,
+ verbose: false,
+ pathspec: vec![],
+ dry_run: false,
+ refresh: false,
+ ignore_errors: false,
+ };
+ add::execute(add_args).await;
+
+ // Remove multiple files
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["file1.txt".to_string(), "file2.txt".to_string(), "file3.txt".to_string()],
+ cached: false,
+ recursive: false,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+
+ // Verify all files are removed
+ assert!(!Path::new("file1.txt").exists());
+ assert!(!Path::new("file2.txt").exists());
+ assert!(!Path::new("file3.txt").exists());
+}
+
+async fn test_remove_nonexistent_file() {
+ println!("\n\x1b[1mTest remove nonexistent file.\x1b[0m");
+
+ // Try to remove a file that doesn't exist (should fail without force)
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec!["nonexistent.txt".to_string()],
+ cached: false,
+ recursive: false,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+
+ // With force flag, should not fail
+ let remove_args_force = remove::RemoveArgs {
+ pathspec: vec!["nonexistent.txt".to_string()],
+ cached: false,
+ recursive: false,
+ force: true,
+ };
+ let _ = remove::execute(remove_args_force);
+}
+
+async fn test_remove_empty_pathspec() {
+ println!("\n\x1b[1mTest remove with empty pathspec.\x1b[0m");
+
+ // Try to remove with empty pathspec (should fail)
+ let remove_args = remove::RemoveArgs {
+ pathspec: vec![],
+ cached: false,
+ recursive: false,
+ force: false,
+ };
+ let _ = remove::execute(remove_args);
+}
+
+#[tokio::test]
+#[serial]
+/// Tests the remove command functionality including:
+/// - Basic file removal
+/// - Cached removal (keep in filesystem)
+/// - Directory removal with recursive flag
+/// - Force removal of untracked files
+/// - Multiple file removal
+/// - Error handling for invalid paths
+async fn test_remove_command() {
+ println!("\n\x1b[1mTest remove command functionality.\x1b[0m");
+
+ let temp_path = tempdir().unwrap();
+ test::setup_clean_testing_env_in(temp_path.path());
+ let _guard = test::ChangeDirGuard::new(temp_path.path());
+
+ // Initialize repository
+ let init_args = init::InitArgs {
+ bare: false,
+ initial_branch: Some("main".to_string()),
+ repo_directory: temp_path.path().to_str().unwrap().to_string(),
+ quiet: false,
+ };
+ init::init(init_args).await.expect("Error initializing repository");
+
+ // Create initial commit
+ let commit_args = commit::CommitArgs {
+ message: "Initial commit".to_string(),
+ allow_empty: true,
+ conventional: false,
+ amend: false,
+ signoff: false,
+ disable_pre: true,
+ };
+ commit::execute(commit_args).await;
+
+ // Run all tests
+ test_remove_file().await;
+ test_remove_cached_file().await;
+ test_remove_directory().await;
+ test_remove_directory_without_recursive().await;
+ test_force_remove_untracked_file().await;
+ test_force_remove_untracked_directory().await;
+ test_no_force_remove_untracked_file().await;
+ test_remove_multiple_files().await;
+ test_remove_nonexistent_file().await;
+ test_remove_empty_pathspec().await;
+
+ println!("\n\x1b[32m✓ All remove command tests passed!\x1b[0m");
+}