diff --git a/aria/contents/docs/libra/command/reset/index.mdx b/aria/contents/docs/libra/command/reset/index.mdx index a5da35d95..3c8de5042 100644 --- a/aria/contents/docs/libra/command/reset/index.mdx +++ b/aria/contents/docs/libra/command/reset/index.mdx @@ -1,4 +1,46 @@ --- title: The [reset] Command -description: +description: Reset current HEAD to the specified state --- + +### Usage + +libra reset [OPTIONS] [COMMIT] [--] [PATHSPEC]... + +### Arguments + +- [COMMIT] - The commit to reset to. Can be a commit hash, branch name, or a reference like HEAD. Defaults to HEAD if not specified. +- [PATHSPEC]... - One or more file paths to reset. When specified, the reset operation only affects the index for these files, not the HEAD. + +### Description + +This command has two primary forms. + +In the first form, it resets the current branch head (HEAD) to [COMMIT] and possibly updates the index (staging area) and the working tree, depending on the chosen mode. + +- --soft – Does not touch the index file or the working tree at all. It only resets the HEAD to point to the given commit. +- --mixed – Resets the index but not the working tree. This is the default mode. All changes are kept in the working directory but are unstaged. +- --hard – Resets the index and working tree. Any changes to tracked files in the working tree since the target [COMMIT] are discarded. + +In the second form, if one or more [PATHSPEC] arguments are given, the command does not move the HEAD pointer. Instead, it resets the index entries for the specified paths to their state at the given [COMMIT]. This is commonly used to unstage changes. + +### Options + +- --soft + Performs a soft reset. Only the HEAD pointer is moved. The index and working directory are not changed. +- --mixed + Performs a mixed reset. The HEAD pointer is moved and the index is reset to match. Changes in the working directory are kept but unstaged. This is the default mode. +- --hard + Performs a hard reset. The HEAD pointer, index, and working directory are all reset to match the specified commit. All local changes are lost. +- -h, --help + Print help +### Example +- libra reset --soft/mixed/hard commit +- libra reset HEAD -- 1.txt 2.txt + + + + All parameters should align with Git’s behavior as closely as possible, but + there may be some differences. Refs https://git-scm.com/docs/git-branch for + more information. + diff --git a/libra/src/cli.rs b/libra/src/cli.rs index 506134b62..3faa40906 100644 --- a/libra/src/cli.rs +++ b/libra/src/cli.rs @@ -53,6 +53,8 @@ enum Commands { Switch(command::switch::SwitchArgs), #[command(about = "Merge changes")] Merge(command::merge::MergeArgs), + #[command(about = "Reset current HEAD to specified state")] + Reset(command::reset::ResetArgs), #[command(about = "Update remote refs along with associated objects")] Push(command::push::PushArgs), #[command(about = "Download objects and refs from another repository")] @@ -118,6 +120,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> Result<(), GitError> { Commands::Commit(args) => command::commit::execute(args).await, Commands::Switch(args) => command::switch::execute(args).await, Commands::Merge(args) => command::merge::execute(args).await, + Commands::Reset(args) => command::reset::execute(args).await, Commands::Push(args) => command::push::execute(args).await, Commands::IndexPack(args) => command::index_pack::execute(args), Commands::Fetch(args) => command::fetch::execute(args).await, diff --git a/libra/src/command/mod.rs b/libra/src/command/mod.rs index c20d6ed81..9beb2d8a4 100644 --- a/libra/src/command/mod.rs +++ b/libra/src/command/mod.rs @@ -15,6 +15,7 @@ pub mod pull; pub mod push; pub mod remote; pub mod remove; +pub mod reset; pub mod restore; pub mod status; pub mod switch; diff --git a/libra/src/command/reset.rs b/libra/src/command/reset.rs new file mode 100644 index 000000000..9a4d18e34 --- /dev/null +++ b/libra/src/command/reset.rs @@ -0,0 +1,527 @@ +use crate::command::{get_target_commit, load_object}; +use crate::internal::branch::Branch; +use crate::internal::head::Head; +use crate::utils::object_ext::{BlobExt, TreeExt}; +use crate::utils::{path, util}; +use clap::Parser; +use mercury::hash::SHA1; +use mercury::internal::index::{Index, IndexEntry}; +use mercury::internal::object::commit::Commit; +use mercury::internal::object::tree::Tree; +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +pub struct ResetArgs { + /// The commit to reset to (default: HEAD) + #[clap(default_value = "HEAD")] + pub target: String, + + /// Soft reset: only move HEAD pointer + #[clap(long, group = "mode")] + pub soft: bool, + + /// Mixed reset: move HEAD and reset index (default) + #[clap(long, group = "mode")] + pub mixed: bool, + + /// Hard reset: move HEAD, reset index and working directory + #[clap(long, group = "mode")] + pub hard: bool, + + /// Pathspecs to reset specific files + #[clap(value_name = "PATH")] + pub pathspecs: Vec, +} + +#[derive(Debug)] +enum ResetMode { + Soft, + Mixed, + Hard, +} + +/// Execute the reset command with the given arguments. +/// Resets the current HEAD to the specified state, with different modes: +/// - Soft: Only moves HEAD pointer +/// - Mixed: Moves HEAD and resets index (default) +/// - Hard: Moves HEAD, resets index and working directory +pub async fn execute(args: ResetArgs) { + if !util::check_repo_exist() { + return; + } + + // Determine reset mode + let mode = if args.soft { + ResetMode::Soft + } else if args.hard { + ResetMode::Hard + } else { + ResetMode::Mixed // default + }; + + // Handle pathspec reset (only affects index) + if !args.pathspecs.is_empty() { + reset_pathspecs(&args.pathspecs, &args.target).await; + return; + } + + // Resolve target commit + let target_commit_id = match resolve_commit(&args.target).await { + Ok(id) => id, + Err(e) => { + eprintln!("fatal: {e}"); + return; + } + }; + + // Perform reset based on mode + match perform_reset(target_commit_id, mode).await { + Ok(_) => { + println!( + "HEAD is now at {} {}", + &target_commit_id.to_string()[..7], + get_commit_summary(&target_commit_id).unwrap_or_else(|_| "".to_string()) + ); + } + Err(e) => { + eprintln!("fatal: {e}"); + } + } +} + +/// Reset specific files in the index to their state in the target commit. +/// This function only affects the index, not the working directory. +async fn reset_pathspecs(pathspecs: &[String], target: &str) { + // Reset specific files in index to target commit + let target_commit_id = match resolve_commit(target).await { + Ok(id) => id, + Err(e) => { + eprintln!("fatal: {e}"); + return; + } + }; + + let commit: Commit = match load_object(&target_commit_id) { + Ok(c) => c, + Err(e) => { + eprintln!("fatal: failed to load commit: {e}"); + return; + } + }; + + let tree: Tree = match load_object(&commit.tree_id) { + Ok(t) => t, + Err(e) => { + eprintln!("fatal: failed to load tree: {e}"); + return; + } + }; + + let index_file = path::index(); + let mut index = match Index::load(&index_file) { + Ok(idx) => idx, + Err(e) => { + eprintln!("fatal: failed to load index: {e}"); + return; + } + }; + let mut changed = false; + + for pathspec in pathspecs { + let relative_path = util::workdir_to_current(PathBuf::from(pathspec)); + let path_str = relative_path.to_str().expect("Path contains invalid UTF-8"); + + match find_tree_item(&tree, path_str) { + Some(item) => { + let blob: mercury::internal::object::blob::Blob = load_object(&item.id).unwrap(); + let entry = IndexEntry::new_from_blob( + path_str.to_string(), + item.id, + blob.data.len() as u32, + ); + index.add(entry); + println!("Unstaged changes after reset of: {pathspec}"); + changed = true; + } + None => { + if index.get(path_str, 0).is_some() { + index.remove(path_str, 0); + println!("Removed from staging: {pathspec}"); + changed = true; + } else { + eprintln!( + "error: pathspec '{pathspec}' did not match any file(s) known to libra" + ); + } + } + } + } + + if changed { + if let Err(e) = index.save(&index_file) { + eprintln!("fatal: failed to save index: {e}"); + } + } +} + +/// Perform the actual reset operation based on the specified mode. +/// Updates HEAD pointer and optionally resets index and working directory. +async fn perform_reset(target_commit_id: SHA1, mode: ResetMode) -> Result<(), String> { + // First, get the current HEAD commit before updating it + let current_head_commit = Head::current_commit().await; + + // 1. Move HEAD pointer - preserve branch if we're on one + let current_head = Head::current().await; + match current_head { + Head::Branch(branch_name) => { + // Update the branch to point to target commit + Branch::update_branch(&branch_name, &target_commit_id.to_string(), None).await; + } + Head::Detached(_) => { + // Update detached HEAD + let head = Head::Detached(target_commit_id); + Head::update(head, None).await; + } + } + + match mode { + ResetMode::Soft => { + // Only move HEAD, nothing else to do + } + ResetMode::Mixed => { + // Reset index to target commit + reset_index_to_commit(&target_commit_id)?; + } + ResetMode::Hard => { + // Reset index and working directory + reset_index_to_commit(&target_commit_id)?; + reset_working_directory_to_commit(&target_commit_id, current_head_commit).await?; + } + } + + Ok(()) +} + +/// Reset the index to match the specified commit's tree. +/// Clears the current index and rebuilds it from the commit's tree structure. +fn reset_index_to_commit(commit_id: &SHA1) -> Result<(), String> { + let commit: Commit = + load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; + + let tree: Tree = + load_object(&commit.tree_id).map_err(|e| format!("failed to load tree: {e}"))?; + + let index_file = path::index(); + let mut index = Index::new(); + + // Rebuild index from tree + rebuild_index_from_tree(&tree, &mut index, "")?; + + index + .save(&index_file) + .map_err(|e| format!("failed to save index: {e}"))?; + + Ok(()) +} + +/// Reset the working directory to match the specified commit. +/// Removes files that exist in the original commit but not in the target commit, +/// and restores files from the target commit's tree. +async fn reset_working_directory_to_commit( + commit_id: &SHA1, + original_head_commit: Option, +) -> Result<(), String> { + let commit: Commit = + load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; + + let tree: Tree = + load_object(&commit.tree_id).map_err(|e| format!("failed to load tree: {e}"))?; + + let workdir = util::working_dir(); + + // Use the original HEAD commit to determine what files to clean up + if let Some(current_commit_id) = original_head_commit { + if current_commit_id != *commit_id { + // Remove files that exist in current commit but not in target commit + let current_commit: Commit = load_object(¤t_commit_id) + .map_err(|e| format!("failed to load current commit: {e}"))?; + let current_tree: Tree = load_object(¤t_commit.tree_id) + .map_err(|e| format!("failed to load current tree: {e}"))?; + + let current_files = current_tree.get_plain_items(); + let target_files: Vec<_> = tree.get_plain_items(); + let target_files_set: HashSet<_> = target_files.iter().map(|(path, _)| path).collect(); + + // Remove files that are in current commit but not in target commit + for (file_path, _) in current_files { + if !target_files_set.contains(&file_path) { + let full_path = workdir.join(&file_path); + if full_path.exists() { + if let Err(e) = fs::remove_file(&full_path) { + eprintln!("warning: failed to remove {}: {}", full_path.display(), e); + } + } + } + } + } + } else { + // No current HEAD, remove all tracked files from index + let index = Index::load(path::index()).unwrap_or_else(|_| Index::new()); + let tracked_files = index.tracked_files(); + + for file_path in tracked_files { + let full_path = workdir.join(&file_path); + if full_path.exists() { + if let Err(e) = fs::remove_file(&full_path) { + eprintln!("warning: failed to remove {}: {}", full_path.display(), e); + } + } + } + } + + // Remove empty directories + remove_empty_directories(&workdir)?; + + // Restore files from target tree + restore_working_directory_from_tree(&tree, &workdir, "")?; + + Ok(()) +} + +/// Recursively rebuild the index from a tree structure. +/// Traverses the tree and adds all files to the index with their blob hashes. +fn rebuild_index_from_tree(tree: &Tree, index: &mut Index, prefix: &str) -> Result<(), String> { + for item in &tree.tree_items { + let full_path = if prefix.is_empty() { + item.name.clone() + } else { + format!("{}/{}", prefix, item.name) + }; + + match item.mode { + mercury::internal::object::tree::TreeItemMode::Tree => { + let subtree: Tree = + load_object(&item.id).map_err(|e| format!("failed to load subtree: {e}"))?; + rebuild_index_from_tree(&subtree, index, &full_path)?; + } + _ => { + // Add file to index - but don't modify working directory files + // Use the blob hash from the tree, not from working directory + // Get blob size for IndexEntry + let blob = mercury::internal::object::blob::Blob::load(&item.id); + + // Create IndexEntry with the tree's blob hash + let entry = IndexEntry::new_from_blob(full_path, item.id, blob.data.len() as u32); + index.add(entry); + } + } + } + Ok(()) +} + +/// Restore the working directory from a tree structure. +/// Recursively creates directories and writes files from the tree's blob objects. +fn restore_working_directory_from_tree( + tree: &Tree, + workdir: &Path, + prefix: &str, +) -> Result<(), String> { + for item in &tree.tree_items { + let full_path = if prefix.is_empty() { + item.name.clone() + } else { + format!("{}/{}", prefix, item.name) + }; + + let file_path = workdir.join(&full_path); + + match item.mode { + mercury::internal::object::tree::TreeItemMode::Tree => { + // Create directory + fs::create_dir_all(&file_path).map_err(|e| { + format!("failed to create directory {}: {}", file_path.display(), e) + })?; + + let subtree: Tree = + load_object(&item.id).map_err(|e| format!("failed to load subtree: {e}"))?; + restore_working_directory_from_tree(&subtree, workdir, &full_path)?; + } + _ => { + // Restore file + let blob = load_object::(&item.id) + .map_err(|e| format!("failed to load blob: {e}"))?; + + // Create parent directory if needed + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + format!("failed to create directory {}: {}", parent.display(), e) + })?; + } + + fs::write(&file_path, blob.data) + .map_err(|e| format!("failed to write file {}: {}", file_path.display(), e))?; + } + } + } + Ok(()) +} + +/// Remove empty directories from the working directory. +/// Recursively traverses the directory tree and removes any empty directories, +/// except for the .libra directory and the working directory root. +fn remove_empty_directories(workdir: &Path) -> Result<(), String> { + fn remove_empty_dirs_recursive(dir: &Path, workdir: &Path) -> Result<(), String> { + if !dir.is_dir() || dir == workdir { + return Ok(()); + } + + let entries = fs::read_dir(dir) + .map_err(|e| format!("failed to read directory {}: {}", dir.display(), e))?; + + let mut has_files = false; + let mut subdirs = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|e| format!("failed to read directory entry: {e}"))?; + let path = entry.path(); + + if path.is_dir() { + // Don't remove .libra directory + if path.file_name().and_then(|n| n.to_str()) == Some(".libra") { + has_files = true; + } else { + subdirs.push(path); + } + } else { + has_files = true; + } + } + + // Recursively process subdirectories + for subdir in subdirs { + remove_empty_dirs_recursive(&subdir, workdir)?; + + // Check if subdir is now empty + if subdir + .read_dir() + .map(|mut d| d.next().is_none()) + .unwrap_or(false) + { + if let Err(e) = fs::remove_dir(&subdir) { + eprintln!( + "warning: failed to remove empty directory {}: {}", + subdir.display(), + e + ); + } + } else { + has_files = true; + } + } + + // Remove this directory if it's empty and not the working directory + if !has_files && dir != workdir { + if let Err(e) = fs::remove_dir(dir) { + eprintln!( + "warning: failed to remove empty directory {}: {}", + dir.display(), + e + ); + } + } + + Ok(()) + } + + // Start from working directory and process all subdirectories + let entries = + fs::read_dir(workdir).map_err(|e| format!("failed to read working directory: {e}"))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("failed to read directory entry: {e}"))?; + let path = entry.path(); + + if path.is_dir() && path.file_name().and_then(|n| n.to_str()) != Some(".libra") { + remove_empty_dirs_recursive(&path, workdir)?; + } + } + + Ok(()) +} + +/// Resolve a reference string to a commit SHA1. +/// Accepts commit hashes, branch names, or HEAD references. +async fn resolve_commit(reference: &str) -> Result { + get_target_commit(reference) + .await + .map_err(|e| e.to_string()) +} + +/// Get the first line of a commit's message for display purposes. +fn get_commit_summary(commit_id: &SHA1) -> Result { + let commit: Commit = + load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; + + let first_line = commit.message.lines().next().unwrap_or("").to_string(); + Ok(first_line) +} + +/// Find a specific file or directory in a tree by path. +/// Returns the tree item if found, None otherwise. +fn find_tree_item(tree: &Tree, path: &str) -> Option { + let parts: Vec<&str> = path.split('/').collect(); + find_tree_item_recursive(tree, &parts, 0) +} + +/// Recursively search for a tree item by path components. +/// Helper function for find_tree_item that handles nested directory structures. +fn find_tree_item_recursive( + tree: &Tree, + parts: &[&str], + index: usize, +) -> Option { + if index >= parts.len() { + return None; + } + + for item in &tree.tree_items { + if item.name == parts[index] { + if index == parts.len() - 1 { + // Found the target + return Some(item.clone()); + } else if item.mode == mercury::internal::object::tree::TreeItemMode::Tree { + // Continue searching in subtree + if let Ok(subtree) = load_object::(&item.id) { + if let Some(result) = find_tree_item_recursive(&subtree, parts, index + 1) { + return Some(result); + } + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reset_args_parse() { + let args = ResetArgs::try_parse_from(["reset", "--hard", "HEAD~1"]).unwrap(); + assert!(args.hard); + assert_eq!(args.target, "HEAD~1"); + } + + #[test] + fn test_reset_mode_detection() { + let args = ResetArgs::try_parse_from(["reset", "--soft"]).unwrap(); + assert!(args.soft); + + let args = ResetArgs::try_parse_from(["reset"]).unwrap(); + assert!(!args.soft && !args.hard); + } +} diff --git a/libra/tests/command/mod.rs b/libra/tests/command/mod.rs index 2a463cee9..a8ddf4a0a 100644 --- a/libra/tests/command/mod.rs +++ b/libra/tests/command/mod.rs @@ -41,6 +41,7 @@ mod pull_test; mod push_test; mod remote_test; mod remove_test; +mod reset_test; mod restore_test; mod status_test; mod switch_test; diff --git a/libra/tests/command/reset_test.rs b/libra/tests/command/reset_test.rs new file mode 100644 index 000000000..2f6038d92 --- /dev/null +++ b/libra/tests/command/reset_test.rs @@ -0,0 +1,390 @@ +use super::*; +use libra::command::branch::{self, BranchArgs}; +use libra::command::reset::{self, ResetArgs}; +use libra::command::status::changes_to_be_staged; +use std::fs; + +/// Setup a standard test repository with 4 commits and branches +async fn setup_standard_repo(temp_path: &std::path::Path) -> (SHA1, SHA1, SHA1, SHA1) { + test::setup_with_new_libra_in(temp_path).await; + + fs::write("1.txt", "content 1").unwrap(); + add::execute(AddArgs { + pathspec: vec!["1.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "commit 1: add 1.txt".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + }) + .await; + let commit1 = Head::current_commit().await.unwrap(); + branch::execute(BranchArgs { + new_branch: Some("1".to_string()), + commit_hash: None, + list: false, + delete: None, + set_upstream_to: None, + show_current: false, + remotes: false, + }) + .await; + + fs::write("2.txt", "content 2").unwrap(); + add::execute(AddArgs { + pathspec: vec!["2.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "commit 2: add 2.txt".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + }) + .await; + let commit2 = Head::current_commit().await.unwrap(); + branch::execute(BranchArgs { + new_branch: Some("2".to_string()), + commit_hash: None, + list: false, + delete: None, + set_upstream_to: None, + show_current: false, + remotes: false, + }) + .await; + + fs::write("3.txt", "content 3").unwrap(); + add::execute(AddArgs { + pathspec: vec!["3.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "commit 3: add 3.txt".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + }) + .await; + let commit3 = Head::current_commit().await.unwrap(); + branch::execute(BranchArgs { + new_branch: Some("3".to_string()), + commit_hash: None, + list: false, + delete: None, + set_upstream_to: None, + show_current: false, + remotes: false, + }) + .await; + + fs::write("4.txt", "content 4").unwrap(); + add::execute(AddArgs { + pathspec: vec!["4.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "commit 4: add 4.txt".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + }) + .await; + let commit4 = Head::current_commit().await.unwrap(); + branch::execute(BranchArgs { + new_branch: Some("4".to_string()), + commit_hash: None, + list: false, + delete: None, + set_upstream_to: None, + show_current: false, + remotes: false, + }) + .await; + + (commit1, commit2, commit3, commit4) +} + +/// Setup the standard test state: modify files and stage some changes +async fn setup_test_state() { + fs::write("3.txt", "content 3\nnew line").unwrap(); + fs::write("4.txt", "content 4\nnew line").unwrap(); + + fs::write("5.txt", "new line").unwrap(); + + add::execute(AddArgs { + pathspec: vec!["3.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; +} + +#[tokio::test] +#[serial] +/// Tests soft reset: only moves HEAD pointer, preserves index and working directory +async fn test_reset_soft() { + let temp_path = tempdir().unwrap(); + let _guard = ChangeDirGuard::new(temp_path.path()); + + let (commit1, _, _, _) = setup_standard_repo(temp_path.path()).await; + setup_test_state().await; + + // Perform soft reset to commit 1 + reset::execute(ResetArgs { + target: "1".to_string(), // Reset to branch 1 + soft: true, + mixed: false, + hard: false, + pathspecs: vec![], + }) + .await; + + // Verify HEAD moved to commit 1 + let current_commit = Head::current_commit().await.unwrap(); + assert_eq!(current_commit, commit1); + + // Verify all files still exist in working directory + assert!(fs::metadata("1.txt").is_ok()); + assert!(fs::metadata("2.txt").is_ok()); + assert!(fs::metadata("3.txt").is_ok()); + assert!(fs::metadata("4.txt").is_ok()); + assert!(fs::metadata("5.txt").is_ok()); + + // Verify file contents are preserved (including modifications) + assert_eq!(fs::read_to_string("3.txt").unwrap(), "content 3\nnew line"); + assert_eq!(fs::read_to_string("4.txt").unwrap(), "content 4\nnew line"); + assert_eq!(fs::read_to_string("5.txt").unwrap(), "new line"); + + // Verify index still has staged changes (3.txt should be staged) + let staged = libra::command::status::changes_to_be_committed().await; + assert!( + !staged.is_empty(), + "Staged changes should be preserved in soft reset" + ); +} + +#[tokio::test] +#[serial] +/// Tests mixed reset: moves HEAD and resets index, preserves working directory +async fn test_reset_mixed() { + let temp_path = tempdir().unwrap(); + let _guard = ChangeDirGuard::new(temp_path.path()); + + let (commit1, _, _, _) = setup_standard_repo(temp_path.path()).await; + setup_test_state().await; + + // Perform mixed reset (default) to commit 1 + reset::execute(ResetArgs { + target: "1".to_string(), // Reset to branch 1 + soft: false, + mixed: false, // false means default (mixed) + hard: false, + pathspecs: vec![], + }) + .await; + + // Verify HEAD moved to commit 1 + let current_commit = Head::current_commit().await.unwrap(); + assert_eq!(current_commit, commit1); + + // Verify all files still exist in working directory + assert!(fs::metadata("1.txt").is_ok()); + assert!(fs::metadata("2.txt").is_ok()); + assert!(fs::metadata("3.txt").is_ok()); + assert!(fs::metadata("4.txt").is_ok()); + assert!(fs::metadata("5.txt").is_ok()); + + // Verify file contents are preserved (including modifications) + assert_eq!(fs::read_to_string("3.txt").unwrap(), "content 3\nnew line"); + assert_eq!(fs::read_to_string("4.txt").unwrap(), "content 4\nnew line"); + assert_eq!(fs::read_to_string("5.txt").unwrap(), "new line"); + + // Verify index was reset (no staged changes) + let staged = libra::command::status::changes_to_be_committed().await; + assert!(staged.is_empty(), "Index should be reset in mixed reset"); + + // Verify unstaged changes exist (2.txt, 3.txt, 4.txt should be untracked/modified) + let unstaged = changes_to_be_staged(); + assert!( + !unstaged.new.is_empty() || !unstaged.modified.is_empty(), + "Should have unstaged changes after mixed reset" + ); +} + +#[tokio::test] +#[serial] +/// Tests hard reset: moves HEAD, resets index and working directory +async fn test_reset_hard() { + let temp_path = tempdir().unwrap(); + let _guard = ChangeDirGuard::new(temp_path.path()); + + let (commit1, _, _, _) = setup_standard_repo(temp_path.path()).await; + setup_test_state().await; + + // Perform hard reset to commit 1 + reset::execute(ResetArgs { + target: "1".to_string(), // Reset to branch 1 + soft: false, + mixed: false, + hard: true, + pathspecs: vec![], + }) + .await; + + // Verify HEAD moved to commit 1 + let current_commit = Head::current_commit().await.unwrap(); + assert_eq!(current_commit, commit1); + + // Verify working directory was reset - only 1.txt should exist from commit 1 + assert!(fs::metadata("1.txt").is_ok()); + assert!( + fs::metadata("2.txt").is_err(), + "2.txt should be removed by hard reset" + ); + assert!( + fs::metadata("3.txt").is_err(), + "3.txt should be removed by hard reset" + ); + assert!( + fs::metadata("4.txt").is_err(), + "4.txt should be removed by hard reset" + ); + + // Untracked files should remain + assert!( + fs::metadata("5.txt").is_ok(), + "Untracked files should remain after hard reset" + ); + + // Verify file content was restored to commit 1 state + assert_eq!(fs::read_to_string("1.txt").unwrap(), "content 1"); + assert_eq!(fs::read_to_string("5.txt").unwrap(), "new line"); + + // Verify index was reset + let staged = libra::command::status::changes_to_be_committed().await; + assert!(staged.is_empty(), "Index should be reset in hard reset"); + + // Verify only untracked files remain + let unstaged = changes_to_be_staged(); + assert!( + !unstaged.new.is_empty(), + "Should have untracked files (5.txt)" + ); + assert!( + unstaged.modified.is_empty(), + "Should have no modified files" + ); + assert!(unstaged.deleted.is_empty(), "Should have no deleted files"); +} + +#[tokio::test] +#[serial] +/// Tests reset with HEAD~ syntax +async fn test_reset_with_head_reference() { + let temp_path = tempdir().unwrap(); + let _guard = ChangeDirGuard::new(temp_path.path()); + + let (_, _, _, _) = setup_standard_repo(temp_path.path()).await; + let second_commit = Head::current_commit().await.unwrap(); + + // Reset using HEAD~ syntax + reset::execute(ResetArgs { + target: "HEAD~1".to_string(), + soft: false, + mixed: true, + hard: false, + pathspecs: vec![], + }) + .await; + + // Verify HEAD moved back one commit + let current_commit = Head::current_commit().await.unwrap(); + assert_ne!(current_commit, second_commit); + + // Verify working directory still has files + assert!(fs::metadata("1.txt").is_ok()); + assert!(fs::metadata("4.txt").is_ok()); + + // Verify index was reset (4.txt should be untracked) + let unstaged = changes_to_be_staged(); + assert!(unstaged + .new + .iter() + .any(|path| path.file_name().unwrap() == "4.txt")); +} + +#[tokio::test] +#[serial] +/// Tests reset on a branch (should move branch pointer, not create detached HEAD) +async fn test_reset_on_branch() { + let temp_path = tempdir().unwrap(); + let _guard = ChangeDirGuard::new(temp_path.path()); + + let (commit1, _, _, _) = setup_standard_repo(temp_path.path()).await; + + // Verify we're on a branch before reset + let head_before = Head::current().await; + match head_before { + Head::Branch(branch_name) => { + assert_eq!(branch_name, "master"); // Default branch name + + // Perform reset + reset::execute(ResetArgs { + target: commit1.to_string(), + soft: true, + mixed: false, + hard: false, + pathspecs: vec![], + }) + .await; + + // Verify we're still on the same branch after reset + let head_after = Head::current().await; + match head_after { + Head::Branch(branch_name_after) => { + assert_eq!(branch_name_after, branch_name); + } + Head::Detached(_) => { + panic!("Reset should not create detached HEAD when on a branch"); + } + } + + // Verify the branch pointer moved + let current_commit = Head::current_commit().await.unwrap(); + assert_eq!(current_commit, commit1); + } + Head::Detached(_) => { + panic!("Should be on a branch initially"); + } + } +} diff --git a/scorpio/src/daemon/mod.rs b/scorpio/src/daemon/mod.rs index 4652deb66..a62da7832 100644 --- a/scorpio/src/daemon/mod.rs +++ b/scorpio/src/daemon/mod.rs @@ -173,7 +173,6 @@ async fn mount_handler( }); } - // fetch the dictionary node info from mono. let work_dir = fetch(&mut ml, inode, mono_path).await.unwrap(); let store_path = PathBuf::from(store_path).join(&work_dir.hash);