Skip to content
46 changes: 46 additions & 0 deletions aria/contents/docs/libra/command/rm/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,54 @@ that.)
- `--cached`<br/>
Use this option to unstage and remove paths only from the index. Working tree files, whether modified or not, will be left alone.

- `--dry-run`<br/>
Don't actually remove any files; instead just show if they exist in the index and would otherwise be removed by the command. This option is useful for previewing what files would be removed before actually executing the removal. It works with all other options (`--cached`, `--recursive`, `--force`) to show exactly what the actual command would do.

- `-r`, `--recursive`<br/>
Allow recursive removal when a leading directory name is given.

- `-f`, `--force`<br/>
Force removal even if the specified paths are not tracked. This skips validation checks and deletes files or directories regardless of their index status.

### Examples

#### Basic Usage
```bash
# Remove a file from both index and working tree
libra rm file.txt

# Remove multiple files
libra rm file1.txt file2.txt
```

#### Using --cached
```bash
# Remove from index only, keep in working tree
libra rm --cached file.txt
```

#### Using --dry-run
```bash
# Preview what would be removed
libra rm --dry-run file1.txt file2.txt

# Preview cached removal
libra rm --dry-run --cached *.txt

# Preview recursive directory removal
libra rm --dry-run -r old_directory/
```

The `--dry-run` option will output something like:
```
Would remove the following:
rm 'file1.txt'
rm 'file2.txt'
```

#### Recursive Removal
```bash
# Remove entire directory recursively (preview first!)
libra rm --dry-run -r directory/
libra rm -r directory/
```
55 changes: 55 additions & 0 deletions libra/examples/test_dry_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Simple test program to verify rm --dry-run functionality
use libra::command::remove::{execute, RemoveArgs};
use std::fs;
use std::io::Write;
use tempfile::TempDir;

fn main() {
println!("Testing libra rm --dry-run functionality...");

// Create a temporary directory for test files
let temp_dir = TempDir::new().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();

// Create test files in the temporary directory
let file1_path = temp_path.join("file1.txt");
let file2_path = temp_path.join("file2.txt");

let mut file1 = fs::File::create(&file1_path).unwrap();
file1.write_all(b"Test content 1").unwrap();

let mut file2 = fs::File::create(&file2_path).unwrap();
file2.write_all(b"Test content 2").unwrap();

println!("Created test files in temporary directory: {:?}", temp_path);

// Test dry-run functionality
let args = RemoveArgs {
pathspec: vec![file1_path.to_string_lossy().to_string(), file2_path.to_string_lossy().to_string()],
cached: false,
recursive: false,
force: true, // Use force mode to avoid requiring git repository

Copilot AI Sep 8, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example won't work correctly because the execute function checks for repository existence at the beginning and returns early if no repository is found, making the force flag ineffective for this purpose.

Copilot uses AI. Check for mistakes.
dry_run: true,
};

println!("\nExecuting: libra rm --dry-run --force on test files");

match execute(args) {
Ok(_) => {
println!("✓ dry-run executed successfully!");

// Verify files still exist
if file1_path.exists() && file2_path.exists() {
println!(" Files still exist after dry-run (correct behavior)");
} else {
println!(" Error: dry-run should not actually delete files");
}
}
Err(e) => {
println!("✗ dry-run execution failed: {:?}", e);
}
}

println!("\nTest completed, temporary directory will be automatically cleaned up");
// The temporary directory will be automatically cleaned up when temp_dir goes out of scope
}
50 changes: 50 additions & 0 deletions libra/src/command/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub struct RemoveArgs {
/// force removal, skip validation
#[clap(short, long)]
pub force: bool,
/// show what would be removed without actually removing
#[clap(long)]
pub dry_run: bool,
}

pub fn execute(args: RemoveArgs) -> Result<(), GitError> {
Expand All @@ -44,6 +47,53 @@ pub fn execute(args: RemoveArgs) -> Result<(), GitError> {
return Err(GitError::CustomError(error_msg));
}

// In dry-run mode, show what would be removed but don't actually remove anything
if args.dry_run {
println!("Would remove the following:");
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 - find all files in this directory that are tracked
let entries = index.tracked_entries(0);
// Create directory prefix with proper path separator for cross-platform compatibility
let dir_prefix = if path_wd.is_empty() {
String::new()
} else if path_wd.ends_with(std::path::MAIN_SEPARATOR) {
path_wd.clone()
} else {
format!("{}{}", path_wd, std::path::MAIN_SEPARATOR)
Comment thread
mengnankkkk marked this conversation as resolved.
};
for entry in entries.iter() {
if entry.name.starts_with(&dir_prefix) {
println!("rm '{}'", entry.name.bright_yellow());
}
}
if !args.cached {
println!("rm directory '{}'", path_str.bright_yellow());
}
} else {
// file
if args.force {
// In force mode: always try to remove (matches actual execution logic)
// - If tracked, would be removed from index
// - If not tracked, would still be processed (and filesystem file deleted if not cached)
if index.tracked(&path_wd, 0) {
println!("rm '{}'", path_wd.bright_yellow());
} else {
// Even untracked files are processed in force mode
println!("rm '{}'", path_wd.bright_yellow());
}
Comment on lines +82 to +87

Copilot AI Sep 8, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The force mode logic has duplicate println! statements that could be simplified. Consider consolidating the output since both tracked and untracked files produce the same message.

Suggested change
if index.tracked(&path_wd, 0) {
println!("rm '{}'", path_wd.bright_yellow());
} else {
// Even untracked files are processed in force mode
println!("rm '{}'", path_wd.bright_yellow());
}
// In force mode, print removal message regardless of tracked status
println!("rm '{}'", path_wd.bright_yellow());

Copilot uses AI. Check for mistakes.
} else if index.tracked(&path_wd, 0) {
// Normal mode - only show if tracked (matches actual execution logic)
println!("rm '{}'", path_wd.bright_yellow());
}
}
}
return Ok(());
}

for path_str in args.pathspec.iter() {
let path = PathBuf::from(path_str);
let path_wd = path.to_workdir().to_string_or_panic();
Expand Down
169 changes: 169 additions & 0 deletions libra/tests/command/remove_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async fn test_remove_single_file() {
cached: false,
recursive: false,
force: false,
dry_run: false,
};
remove::execute(args).unwrap();

Expand Down Expand Up @@ -110,6 +111,7 @@ async fn test_remove_cached() {
cached: true,
recursive: false,
force: false,
dry_run: false,
};
remove::execute(args).unwrap();

Expand Down Expand Up @@ -164,6 +166,7 @@ async fn test_remove_directory_recursive() {
cached: false,
recursive: true,
force: false,
dry_run: false,
};
remove::execute(args).unwrap();

Expand Down Expand Up @@ -236,6 +239,7 @@ async fn test_remove_directory_without_recursive() {
cached: false,
recursive: false,
force: false,
dry_run: false,
};
assert!(
remove::execute(args).is_err(),
Expand Down Expand Up @@ -271,6 +275,7 @@ async fn test_remove_untracked_file() {
cached: false,
recursive: false,
force: false,
dry_run: false,
};
let result = remove::execute(args);
assert!(
Expand Down Expand Up @@ -318,6 +323,7 @@ async fn test_remove_modified_file() {
cached: false,
recursive: false,
force: false,
dry_run: false,
};
remove::execute(args).unwrap();

Expand Down Expand Up @@ -385,10 +391,173 @@ async fn test_remove_multiple_files() {
cached: false,
recursive: false,
force: false,
dry_run: false,
};
remove::execute(args).unwrap();
// Verify the specified files were removed
assert!(!file1.exists(), "File 1 should be removed");
assert!(file2.exists(), "File 2 should still exist");
assert!(!file3.exists(), "File 3 should be removed");
}

#[tokio::test]
#[serial]
/// Tests the --dry-run flag which shows what would be removed without actually removing anything
async fn test_remove_dry_run() {
let test_dir = tempdir().unwrap();
test::setup_with_new_libra_in(test_dir.path()).await;
let _guard = test::ChangeDirGuard::new(test_dir.path());

// Create multiple files
let file1 = create_file("file1.txt", "File 1 content");
let file2 = create_file("file2.txt", "File 2 content");
let file3 = create_file("subdir/file3.txt", "File 3 content");

// Add all files to the index
add::execute(AddArgs {
pathspec: vec![String::from(".")],
all: false,
update: false,
refresh: false,
verbose: false,
dry_run: false,
ignore_errors: false,
})
.await;

// Make sure all files exist before dry-run
assert!(file1.exists(), "File 1 should exist before dry-run");
assert!(file2.exists(), "File 2 should exist before dry-run");
assert!(file3.exists(), "File 3 should exist before dry-run");

// Run rm with --dry-run flag
let args = RemoveArgs {
pathspec: vec![String::from("file1.txt"), String::from("file2.txt")],
cached: false,
recursive: false,
force: false,
dry_run: true,
};
remove::execute(args).unwrap();

// Verify that no files were actually removed
assert!(file1.exists(), "File 1 should still exist after dry-run");
assert!(file2.exists(), "File 2 should still exist after dry-run");
assert!(file3.exists(), "File 3 should still exist after dry-run");

// Verify files are still in the index by checking they don't appear as deleted
let changes = changes_to_be_staged();
assert!(
!changes
.deleted
.iter()
.any(|x| x.to_str().unwrap() == "file1.txt"),
"File 1 should not appear as deleted"
);
assert!(
!changes
.deleted
.iter()
.any(|x| x.to_str().unwrap() == "file2.txt"),
"File 2 should not appear as deleted"
);
}

#[tokio::test]
#[serial]
/// Tests --dry-run with --cached flag
async fn test_remove_dry_run_cached() {
let test_dir = tempdir().unwrap();
test::setup_with_new_libra_in(test_dir.path()).await;
let _guard = test::ChangeDirGuard::new(test_dir.path());

// Create a file and add it to index
let file_path = create_file("test_file.txt", "Test content");

add::execute(AddArgs {
pathspec: vec![String::from("test_file.txt")],
all: false,
update: false,
refresh: false,
verbose: false,
dry_run: false,
ignore_errors: false,
})
.await;

// Run rm with --dry-run and --cached flags
let args = RemoveArgs {
pathspec: vec![String::from("test_file.txt")],
cached: true,
recursive: false,
force: false,
dry_run: true,
};
remove::execute(args).unwrap();

// Verify the file still exists in both filesystem and index
assert!(file_path.exists(), "File should still exist in filesystem");

// Verify file doesn't appear as deleted in changes
let changes = changes_to_be_staged();
assert!(
!changes
.deleted
.iter()
.any(|x| x.to_str().unwrap() == "test_file.txt"),
"File should not appear as deleted"
);
}

#[tokio::test]
#[serial]
/// Tests --dry-run with recursive directory removal
async fn test_remove_dry_run_recursive() {
let test_dir = tempdir().unwrap();
test::setup_with_new_libra_in(test_dir.path()).await;
let _guard = test::ChangeDirGuard::new(test_dir.path());

// Create a directory with files
let file1 = create_file("test_dir/file1.txt", "File 1 content");
let file2 = create_file("test_dir/file2.txt", "File 2 content");
let file3 = create_file("test_dir/subdir/file3.txt", "File 3 content");

// Add all files to the index
add::execute(AddArgs {
pathspec: vec![String::from("test_dir")],
all: false,
update: false,
refresh: false,
verbose: false,
dry_run: false,
ignore_errors: false,
})
.await;

// Run rm with --dry-run and --recursive flags
let args = RemoveArgs {
pathspec: vec![String::from("test_dir")],
cached: false,
recursive: true,
force: false,
dry_run: true,
};
remove::execute(args).unwrap();

// Verify that no files or directories were actually removed
assert!(file1.exists(), "File 1 should still exist after dry-run");
assert!(file2.exists(), "File 2 should still exist after dry-run");
assert!(file3.exists(), "File 3 should still exist after dry-run");
assert!(PathBuf::from("test_dir").exists(), "Directory should still exist");
assert!(PathBuf::from("test_dir/subdir").exists(), "Subdirectory should still exist");

// Verify files are still tracked by checking they don't appear as deleted
let changes = changes_to_be_staged();
assert!(
!changes
.deleted
.iter()
.any(|x| x.to_str().unwrap().starts_with("test_dir/")),
"No files in test_dir should appear as deleted"
);
}
Loading