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);