diff --git a/aria/contents/docs/libra/command/cherry-pick/index.mdx b/aria/contents/docs/libra/command/cherry-pick/index.mdx new file mode 100644 index 000000000..04be1f4f6 --- /dev/null +++ b/aria/contents/docs/libra/command/cherry-pick/index.mdx @@ -0,0 +1,36 @@ +--- +title: The [cherry-pick] Command +description: Apply the changes introduced by some existing commits +--- + +### Usage + +libra cherry-pick [OPTIONS] ... + +### Arguments + +- ... - Commits to cherry-pick. + +### Description + +Given one or more existing commits, apply the change that each one introduces, recording a new commit for each. This is useful for selectively applying commits from one branch to another without merging the entire branch. + +For each commit specified, cherry-pick will calculate the difference between that commit and its parent, and then apply that patch onto the current branch's HEAD. A new commit is created with the same log message as the original commit, but with a new commit ID. + +If a conflict occurs, the operation will stop, and the user must resolve the conflicts before committing the changes manually. + +### Options + +- -n, --no-commit
+ Usually, the command automatically creates a sequence of commits. This flag applies the changes necessary to cherry-pick each named commit to the working tree and the index, but does not make the commit. This allows you to inspect and modify the result of the cherry-pick before committing. + +### Example + + \- libra cherry-pick commit1 -n + \- libra cherry-pick commit2 commit3 + + + 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 9c30a8779..a856895c3 100644 --- a/libra/src/cli.rs +++ b/libra/src/cli.rs @@ -55,6 +55,8 @@ enum Commands { Merge(command::merge::MergeArgs), #[command(about = "Reset current HEAD to specified state")] Reset(command::reset::ResetArgs), + #[command(about = "Apply the changes introduced by some existing commits")] + CherryPick(command::cherry_pick::CherryPickArgs), #[command(about = "Update remote refs along with associated objects")] Push(command::push::PushArgs), #[command(about = "Download objects and refs from another repository")] @@ -122,6 +124,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> Result<(), GitError> { 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::CherryPick(args) => command::cherry_pick::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/cherry_pick.rs b/libra/src/command/cherry_pick.rs new file mode 100644 index 000000000..33f46f1e0 --- /dev/null +++ b/libra/src/command/cherry_pick.rs @@ -0,0 +1,395 @@ +use crate::command::{load_object, save_object}; +use crate::internal::branch::Branch; +use crate::internal::head::Head; +use crate::utils::object_ext::BlobExt; +use crate::utils::object_ext::TreeExt; +use crate::utils::{path, util}; +use clap::Parser; +use common::utils::format_commit_msg; +use mercury::hash::SHA1; +use mercury::internal::index::{Index, IndexEntry}; +use mercury::internal::object::commit::Commit; +use mercury::internal::object::tree::{Tree, TreeItem, TreeItemMode}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Arguments for the cherry-pick command +#[derive(Parser, Debug)] +pub struct CherryPickArgs { + /// Commits to cherry-pick + #[clap(required = true)] + pub commits: Vec, + + /// Don't automatically commit the cherry-pick + #[clap(short = 'n', long)] + pub no_commit: bool, +} + +/// Execute the cherry-pick command +/// +/// Cherry-pick applies the changes introduced by some existing commits to the current branch. +/// This is useful for selectively applying commits from one branch to another without merging. +/// +/// The process involves: +/// 1. Resolving commit references to SHA1 hashes +/// 2. For each commit, performing a three-way merge to apply changes +/// 3. Creating new commits with the same changes and the current HEAD as the parent +/// TODO: Now, it will apply the picked commits exactly as they are, +/// without resolving any conflicts, and it will not take the state of the staging area into account. +pub async fn execute(args: CherryPickArgs) { + if !util::check_repo_exist() { + return; + } + + if let Head::Detached(_) = Head::current().await { + eprintln!("fatal: cannot cherry-pick on detached HEAD"); + return; + } + + // To simplify the implementation, we currently disallow cherry-picking multiple commits with --no-commit. + // Unlike Git, which stages changes from all selected commits, this tool restricts the operation to a single commit. + // This limitation may be lifted in the future if multi-commit support is implemented. + if args.no_commit && args.commits.len() > 1 { + eprintln!("fatal: cannot cherry-pick multiple commits with --no-commit"); + eprintln!("(use 'libra commit' to save the changes from the first cherry-pick)"); + return; + } + + let mut commit_ids = Vec::new(); + for commit_ref in &args.commits { + match resolve_commit(commit_ref).await { + Ok(id) => commit_ids.push(id), + Err(e) => { + eprintln!("fatal: {e}"); + return; + } + } + } + + for (i, commit_id) in commit_ids.iter().enumerate() { + println!("Cherry-picking commit {}...", &commit_id.to_string()[..7]); + match cherry_pick_single_commit(commit_id, &args).await { + Ok(new_commit_id) => { + if let Some(id) = new_commit_id { + println!( + "Finished cherry-pick for {} in new commit {}", + &commit_id.to_string()[..7], + &id.to_string()[..7] + ); + } else { + println!("Changes staged for cherry-pick. Use 'libra commit' to finalize."); + } + } + Err(e) => { + eprintln!("error: failed to cherry-pick {}: {}", &args.commits[i], e); + // This simplified implementation does not handle conflicts or offer options to abort or skip. + return; + } + } + } +} + +/// Cherry-pick a single commit onto the current branch +/// +/// This function performs the core cherry-pick logic: +/// 1. Loads the commit to be cherry-picked and its parent +/// 2. Performs a three-way merge between: +/// - Base: The parent of the commit being cherry-picked +/// - Theirs: The commit being cherry-picked +/// - Ours: The current HEAD state +/// 3. Applies the resulting changes to the index and working directory +/// 4. Optionally creates a new commit with the applied changes +/// +/// Returns the SHA1 of the new commit if created, or None if --no-commit was used +async fn cherry_pick_single_commit( + commit_id: &SHA1, + args: &CherryPickArgs, +) -> Result, String> { + let commit_to_pick: Commit = + load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; + + if commit_to_pick.parent_commit_ids.len() > 1 { + return Err("cherry-picking merge commits is not supported".to_string()); + } + + // Three-way merge key points: + // Base: Parent commit of the commit to cherry-pick + // Theirs: The commit to cherry-pick + // Ours: Current HEAD + let parent_tree = if commit_to_pick.parent_commit_ids.is_empty() { + Tree::from_tree_items(vec![]).unwrap() // Root commit, base is an empty tree + } else { + let parent_commit: Commit = load_object(&commit_to_pick.parent_commit_ids[0]) + .map_err(|e| format!("failed to load parent commit: {e}"))?; + load_object(&parent_commit.tree_id) + .map_err(|e| format!("failed to load parent tree: {e}"))? + }; + + let their_tree: Tree = load_object(&commit_to_pick.tree_id) + .map_err(|e| format!("failed to load commit tree: {e}"))?; + + let index_file = path::index(); + let mut index = + Index::load(&index_file).map_err(|e| format!("failed to load current index: {e}"))?; + + // Apply patch to current index + // 1. Get diff (theirs vs base) + let diff = diff_trees(&their_tree, &parent_tree); + + // 2. Apply diff to current index (simplified merge) + for (path, their_hash, base_hash) in diff { + match (their_hash, base_hash) { + (Some(th), Some(_bh)) => { + // Modified file + update_index_entry(&mut index, &path, th); + } + (Some(th), None) => { + // New file + update_index_entry(&mut index, &path, th); + } + (None, Some(_bh)) => { + // Deleted file + index.remove(path.to_str().unwrap(), 0); + } + (None, None) => unreachable!(), + } + } + + // 3. Save updated index and sync working directory + index + .save(&index_file) + .map_err(|e| format!("failed to save index: {e}"))?; + reset_workdir_to_index(&index)?; + + if args.no_commit { + Ok(None) + } else { + let current_head = Head::current_commit() + .await + .ok_or("failed to resolve current HEAD")?; + let cherry_pick_commit_id = + create_cherry_pick_commit(&commit_to_pick, ¤t_head).await?; + Ok(Some(cherry_pick_commit_id)) + } +} + +/// Create a new commit representing the cherry-picked changes +/// +/// This function: +/// 1. Creates a tree object from the current index state +/// 2. Generates a commit message indicating the original commit +/// 3. Creates a new commit object with the current HEAD as parent +/// 4. Updates the HEAD reference to point to the new commit +async fn create_cherry_pick_commit( + original_commit: &Commit, + parent_id: &SHA1, +) -> Result { + let index = Index::load(path::index()).map_err(|e| format!("failed to load index: {e}"))?; + + // Create tree from current index state + let tree_id = create_tree_from_index(&index)?; + + let cherry_pick_message = format!( + "{}\n\n(cherry picked from commit {})", + original_commit.message.trim(), + original_commit.id + ); + + let commit = Commit::from_tree_id( + tree_id, + vec![*parent_id], + &format_commit_msg(&cherry_pick_message, None), + ); + + save_object(&commit, &commit.id).map_err(|e| format!("failed to save commit: {e}"))?; + update_head(&commit.id.to_string()).await; + Ok(commit.id) +} + +/// Calculate differences between two trees to generate a patch +/// +/// This function compares two tree objects and returns a list of differences. +/// Each difference is represented as a tuple containing: +/// - PathBuf: The file path +/// - Option: The hash in the "theirs" tree (None if deleted) +/// - Option: The hash in the "base" tree (None if newly added) +/// +/// This is used to determine what changes need to be applied when cherry-picking. +fn diff_trees(theirs: &Tree, base: &Tree) -> Vec<(PathBuf, Option, Option)> { + let mut diffs = Vec::new(); + let their_items: HashMap<_, _> = theirs.get_plain_items().into_iter().collect(); + let base_items: HashMap<_, _> = base.get_plain_items().into_iter().collect(); + + let all_paths: HashSet<_> = their_items.keys().chain(base_items.keys()).collect(); + + for path in all_paths { + let their_hash = their_items.get(path).cloned(); + let base_hash = base_items.get(path).cloned(); + if their_hash != base_hash { + diffs.push((path.clone(), their_hash, base_hash)); + } + } + diffs +} + +/// Add or update a file entry in the index +/// +/// This function: +/// 1. Loads the blob object from the given hash +/// 2. Creates a new IndexEntry with the file path, hash, and size +/// 3. Adds the entry to the index (overwriting any existing entry with the same path) +/// +/// This is used when applying cherry-pick changes to update the index state. +fn update_index_entry(index: &mut Index, path: &Path, hash: SHA1) { + let blob = mercury::internal::object::blob::Blob::load(&hash); + let entry = IndexEntry::new_from_blob( + path.to_str().unwrap().to_string(), + hash, + blob.data.len() as u32, + ); + index.add(entry); // add() will overwrite entries with the same name +} + +/// Create a tree object from the current index state +/// +/// This function builds a complete tree structure representing the current index: +/// 1. Groups index entries by their parent directories +/// 2. Recursively builds tree objects starting from the root +/// 3. Each directory becomes a tree object containing its files and subdirectories +/// +/// This is a reusable utility function that can be used in commit.rs and revert.rs +/// as well, since creating trees from index state is a common operation. +/// +/// Returns the SHA1 hash of the root tree object. +fn create_tree_from_index(index: &Index) -> Result { + // Path -> TreeItem mapping + let mut entries_map: HashMap> = HashMap::new(); + for path_buf in index.tracked_files() { + let path_str = path_buf.to_str().unwrap(); + if let Some(entry) = index.get(path_str, 0) { + let item = TreeItem { + mode: match entry.mode { + 0o100644 => TreeItemMode::Blob, // Regular file + 0o100755 => TreeItemMode::BlobExecutable, // Executable file + 0o120000 => TreeItemMode::Link, // Symbolic link + 0o040000 => TreeItemMode::Tree, // Directory + _ => return Err(format!("Unsupported file mode: {:#o}", entry.mode)), + }, + name: path_buf.file_name().unwrap().to_str().unwrap().to_string(), + id: entry.hash, + }; + let parent_dir = path_buf + .parent() + .unwrap_or_else(|| Path::new("")) + .to_path_buf(); + entries_map.entry(parent_dir).or_default().push(item); + } + } + + // Build recursively + build_tree_recursively(Path::new(""), &mut entries_map) +} + +/// Recursively build tree objects from a directory structure map +/// +/// This helper function: +/// 1. Takes the current directory path and the remaining entries map +/// 2. Creates tree items for all files in the current directory +/// 3. Recursively processes subdirectories to create subtree objects +/// 4. Combines everything into a single tree object for the current directory +/// +/// The recursion builds the tree structure bottom-up, creating leaf trees first +/// and then combining them into parent trees. +fn build_tree_recursively( + current_path: &Path, + entries_map: &mut HashMap>, +) -> Result { + let mut current_items = entries_map.remove(current_path).unwrap_or_default(); + + // Find all subdirectories and build them recursively + let subdirs: Vec<_> = entries_map + .keys() + .filter(|p| p.parent() == Some(current_path)) + .cloned() + .collect(); + + for subdir_path in subdirs { + let subdir_name = subdir_path + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + let subtree_hash = build_tree_recursively(&subdir_path, entries_map)?; + current_items.push(TreeItem { + mode: TreeItemMode::Tree, + name: subdir_name, + id: subtree_hash, + }); + } + + let tree = + Tree::from_tree_items(current_items).map_err(|e| format!("failed to create tree: {e}"))?; + save_object(&tree, &tree.id).map_err(|e| e.to_string())?; + Ok(tree.id) +} + +/// Reset the working directory to match the current index state +/// +/// This function synchronizes the working directory with the index by: +/// 1. Removing any files that exist in the working directory but not in the index +/// 2. Writing out all files that are tracked in the index to the working directory +/// 3. Creating necessary parent directories as needed +/// +/// This ensures that after cherry-picking, the working directory reflects the +/// merged state that was applied to the index. +fn reset_workdir_to_index(index: &Index) -> Result<(), String> { + let workdir = util::working_dir(); + let tracked_paths = index.tracked_files(); + let index_files_set: HashSet<_> = tracked_paths.iter().collect(); + let all_files_in_workdir = util::list_workdir_files().unwrap_or_default(); + for path_from_root in all_files_in_workdir { + if !index_files_set.contains(&path_from_root) { + let full_path = workdir.join(path_from_root); + if full_path.exists() { + fs::remove_file(&full_path).map_err(|e| e.to_string())?; + } + } + } + for path_buf in &tracked_paths { + let path_str = path_buf.to_str().unwrap(); + if let Some(entry) = index.get(path_str, 0) { + let blob = mercury::internal::object::blob::Blob::load(&entry.hash); + let target_path = workdir.join(path_str); + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&target_path, &blob.data).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// Resolve a commit reference (like "HEAD", branch name, or SHA1) to a SHA1 hash +/// +/// This function uses the utility function to convert various commit references +/// into their corresponding SHA1 hashes. It supports: +/// - Full SHA1 hashes +/// - Abbreviated SHA1 hashes +/// - Branch names +/// - Special references like "HEAD" +async fn resolve_commit(reference: &str) -> Result { + util::get_commit_base(reference).await +} + +/// Update the HEAD reference to point to a new commit +/// +/// This function updates the current branch to point to the specified commit. +/// It only works when HEAD is pointing to a branch (not in detached HEAD state). +/// The branch reference is updated to the new commit ID. +async fn update_head(commit_id: &str) { + if let Head::Branch(name) = Head::current().await { + Branch::update_branch(&name, commit_id, None).await; + } +} diff --git a/libra/src/command/mod.rs b/libra/src/command/mod.rs index 8b4bb57f2..4fa69c2d6 100644 --- a/libra/src/command/mod.rs +++ b/libra/src/command/mod.rs @@ -1,6 +1,7 @@ pub mod add; pub mod branch; pub mod checkout; +pub mod cherry_pick; pub mod clone; pub mod commit; pub mod config; diff --git a/libra/tests/command/branch_test.rs b/libra/tests/command/branch_test.rs index d86e58902..9994e3139 100644 --- a/libra/tests/command/branch_test.rs +++ b/libra/tests/command/branch_test.rs @@ -17,7 +17,7 @@ async fn test_branch() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(commit_args).await; let first_commit_id = Branch::find_branch("master", None).await.unwrap().commit; @@ -28,7 +28,7 @@ async fn test_branch() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(commit_args).await; let second_commit_id = Branch::find_branch("master", None).await.unwrap().commit; @@ -114,7 +114,7 @@ async fn test_create_branch_from_remote() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; let hash = Head::current_commit().await.unwrap(); @@ -153,7 +153,7 @@ async fn test_invalid_branch_name() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; diff --git a/libra/tests/command/checkout_test.rs b/libra/tests/command/checkout_test.rs index b3d9be109..99227f851 100644 --- a/libra/tests/command/checkout_test.rs +++ b/libra/tests/command/checkout_test.rs @@ -77,7 +77,7 @@ async fn test_checkout_module_functions() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(commit_args).await; diff --git a/libra/tests/command/cherry_pick_test.rs b/libra/tests/command/cherry_pick_test.rs new file mode 100644 index 000000000..b9f0d8e78 --- /dev/null +++ b/libra/tests/command/cherry_pick_test.rs @@ -0,0 +1,442 @@ +use super::*; +use libra::command::cherry_pick; +use serial_test::serial; +use std::fs; +use std::path::PathBuf; +use tempfile::tempdir; + +/// Test basic cherry-pick functionality +/// This test follows the workflow: +/// 1. Create a common ancestor commit (C1) +/// 2. Create a feature branch and add commits (C2, C3) +/// 3. Switch back to master branch +/// 4. Cherry-pick feature commits to master +#[tokio::test] +#[serial] +async fn test_basic_cherry_pick() { + let temp_path = tempdir().unwrap(); + test::setup_with_new_libra_in(temp_path.path()).await; + let _guard = ChangeDirGuard::new(temp_path.path()); + + println!("===== SCENARIO: BASIC CHERRY-PICK TEST ====="); + + // --- 1. Create common ancestor commit (C1) --- + fs::write("base.txt", "base").unwrap(); + add::execute(AddArgs { + pathspec: vec!["base.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "C1: Initial commit, our common ancestor".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + println!("C1: Created common ancestor."); + + // --- 2. Create and switch to feature branch --- + switch::execute(SwitchArgs { + branch: None, + create: Some("feature".to_string()), + detach: false, + }) + .await; + println!("Switched to new branch 'feature'."); + + // --- 3. Create two commits on feature branch --- + // Commit C2: First target to cherry-pick + fs::write("feature_a.txt", "feature A").unwrap(); + add::execute(AddArgs { + pathspec: vec!["feature_a.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "C2: Add feature_a.txt".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + println!("C2: Added feature_a.txt on feature branch."); + + // Get C2 commit hash for cherry-picking later + let c2_commit = Head::current_commit() + .await + .expect("Should have current commit"); + + // Commit C3: Second target to cherry-pick + fs::write("feature_b.txt", "feature B").unwrap(); + add::execute(AddArgs { + pathspec: vec!["feature_b.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "C3: Add feature_b.txt".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + println!("C3: Added feature_b.txt on feature branch."); + + // --- 4. Switch back to master branch --- + switch::execute(SwitchArgs { + branch: Some("master".to_string()), + create: None, + detach: false, + }) + .await; + println!("Switched back to master."); + + // --- 5. Verify initial state on master --- + println!("\nCherry-pick test repo is ready. Current state:"); + let files: Vec<_> = fs::read_dir(".") + .unwrap() + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with('.') && name.ends_with(".txt") { + Some(name) + } else { + None + } + }) + .collect(); + for file in &files { + println!("{file}"); + } + + // Should only have base.txt on master + assert!( + PathBuf::from("base.txt").exists(), + "base.txt should exist on master" + ); + assert!( + !PathBuf::from("feature_a.txt").exists(), + "feature_a.txt should not exist on master before cherry-pick" + ); + assert!( + !PathBuf::from("feature_b.txt").exists(), + "feature_b.txt should not exist on master before cherry-pick" + ); + + // --- 6. Cherry-pick C2 (feature_a.txt) with --no-commit flag --- + println!("\n--- Cherry-picking C2 with --no-commit ---"); + cherry_pick::execute(cherry_pick::CherryPickArgs { + commits: vec![c2_commit.to_string()], + no_commit: true, + }) + .await; + + // --- 7. Verify state after cherry-pick --no-commit --- + println!("Files after cherry-pick --no-commit:"); + let files_after_cherry_pick: Vec<_> = fs::read_dir(".") + .unwrap() + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with('.') && name.ends_with(".txt") { + Some(name) + } else { + None + } + }) + .collect(); + for file in &files_after_cherry_pick { + println!("{file}"); + } + + // Should now have both base.txt and feature_a.txt + assert!( + PathBuf::from("base.txt").exists(), + "base.txt should still exist" + ); + assert!( + PathBuf::from("feature_a.txt").exists(), + "feature_a.txt should exist after cherry-pick" + ); + assert!( + !PathBuf::from("feature_b.txt").exists(), + "feature_b.txt should not exist (not cherry-picked)" + ); + + // Verify content of cherry-picked file + let feature_a_content = fs::read_to_string("feature_a.txt").unwrap(); + assert_eq!( + feature_a_content, "feature A", + "feature_a.txt should have correct content" + ); + + // Check that changes are staged but not committed (no new commit created) + let _ = Head::current_commit().await.expect("Should have HEAD"); + + // The head should still be the same as before cherry-pick since we used --no-commit + // In a real test, we might want to check the index status here + + println!("Cherry-pick --no-commit test passed"); + + println!("\nAll cherry-pick tests completed successfully!"); +} + +/// Test cherry-pick with automatic commit +#[tokio::test] +#[serial] +async fn test_cherry_pick_with_commit() { + let temp_path = tempdir().unwrap(); + test::setup_with_new_libra_in(temp_path.path()).await; + let _guard = ChangeDirGuard::new(temp_path.path()); + + // Create base commit + fs::write("base.txt", "base content").unwrap(); + add::execute(AddArgs { + pathspec: vec!["base.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "Base commit".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + + // Create feature branch and commit + switch::execute(SwitchArgs { + branch: None, + create: Some("feature".to_string()), + detach: false, + }) + .await; + + fs::write("feature.txt", "feature content").unwrap(); + add::execute(AddArgs { + pathspec: vec!["feature.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "Feature commit".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + + let feature_commit = Head::current_commit() + .await + .expect("Should have current commit"); + + // Switch back to master + switch::execute(SwitchArgs { + branch: Some("master".to_string()), + create: None, + detach: false, + }) + .await; + + let head_before = Head::current_commit() + .await + .expect("Should have HEAD before cherry-pick"); + + // Cherry-pick with automatic commit + cherry_pick::execute(cherry_pick::CherryPickArgs { + commits: vec![feature_commit.to_string()], + no_commit: false, + }) + .await; + + // Verify new commit was created + let head_after = Head::current_commit() + .await + .expect("Should have HEAD after cherry-pick"); + assert_ne!( + head_before, head_after, + "A new commit should have been created" + ); + + // Verify file was cherry-picked + assert!( + PathBuf::from("feature.txt").exists(), + "feature.txt should exist after cherry-pick" + ); + let content = fs::read_to_string("feature.txt").unwrap(); + assert_eq!( + content, "feature content", + "feature.txt should have correct content" + ); + + println!("Cherry-pick with commit test passed"); +} + +/// Test cherry-pick multiple commits +#[tokio::test] +#[serial] +async fn test_cherry_pick_multiple_commits() { + let temp_path = tempdir().unwrap(); + test::setup_with_new_libra_in(temp_path.path()).await; + let _guard = ChangeDirGuard::new(temp_path.path()); + + // Create base commit + fs::write("base.txt", "base").unwrap(); + add::execute(AddArgs { + pathspec: vec!["base.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "Base commit".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + + // Create feature branch + switch::execute(SwitchArgs { + branch: None, + create: Some("feature".to_string()), + detach: false, + }) + .await; + + // Create first feature commit + fs::write("file1.txt", "content1").unwrap(); + add::execute(AddArgs { + pathspec: vec!["file1.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "Feature commit 1".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + let commit1 = Head::current_commit().await.expect("Should have commit1"); + + // Create second feature commit + fs::write("file2.txt", "content2").unwrap(); + add::execute(AddArgs { + pathspec: vec!["file2.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: "Feature commit 2".to_string(), + allow_empty: false, + conventional: false, + amend: false, + signoff: false, + disable_pre: false, + }) + .await; + let commit2 = Head::current_commit().await.expect("Should have commit2"); + + // Switch back to master + switch::execute(SwitchArgs { + branch: Some("master".to_string()), + create: None, + detach: false, + }) + .await; + + // Cherry-pick both commits + cherry_pick::execute(cherry_pick::CherryPickArgs { + commits: vec![commit1.to_string(), commit2.to_string()], + no_commit: false, + }) + .await; + + // Verify both files exist + assert!( + PathBuf::from("file1.txt").exists(), + "file1.txt should exist" + ); + assert!( + PathBuf::from("file2.txt").exists(), + "file2.txt should exist" + ); + + let content1 = fs::read_to_string("file1.txt").unwrap(); + let content2 = fs::read_to_string("file2.txt").unwrap(); + assert_eq!( + content1, "content1", + "file1.txt should have correct content" + ); + assert_eq!( + content2, "content2", + "file2.txt should have correct content" + ); + + println!("Multiple commits cherry-pick test passed"); +} + +/// Test error cases for cherry-pick +#[tokio::test] +#[serial] +async fn test_cherry_pick_errors() { + let temp_path = tempdir().unwrap(); + test::setup_with_new_libra_in(temp_path.path()).await; + let _guard = ChangeDirGuard::new(temp_path.path()); + + // Test cherry-picking non-existent commit should fail gracefully + cherry_pick::execute(cherry_pick::CherryPickArgs { + commits: vec!["nonexistent".to_string()], + no_commit: false, + }) + .await; + + println!("Error handling test completed"); +} diff --git a/libra/tests/command/commit_test.rs b/libra/tests/command/commit_test.rs index 54a682483..dc5d96cbc 100644 --- a/libra/tests/command/commit_test.rs +++ b/libra/tests/command/commit_test.rs @@ -19,7 +19,7 @@ async fn test_execute_commit_with_empty_index_fail() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; } @@ -42,7 +42,7 @@ async fn test_execute_commit() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; @@ -68,7 +68,7 @@ async fn test_execute_commit() { conventional: false, amend: true, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; @@ -110,7 +110,7 @@ async fn test_execute_commit() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; @@ -139,7 +139,7 @@ async fn test_execute_commit() { conventional: false, amend: true, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; diff --git a/libra/tests/command/mod.rs b/libra/tests/command/mod.rs index eeedde207..bcd4c140b 100644 --- a/libra/tests/command/mod.rs +++ b/libra/tests/command/mod.rs @@ -27,6 +27,7 @@ use tempfile::tempdir; mod add_test; mod branch_test; mod checkout_test; +mod cherry_pick_test; mod clone_test; mod commit_test; mod config_test; diff --git a/libra/tests/command/reset_test.rs b/libra/tests/command/reset_test.rs index 978aba922..c09e6d5eb 100644 --- a/libra/tests/command/reset_test.rs +++ b/libra/tests/command/reset_test.rs @@ -24,7 +24,7 @@ async fn setup_standard_repo(temp_path: &std::path::Path) -> (SHA1, SHA1, SHA1, conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }) .await; let commit1 = Head::current_commit().await.unwrap(); @@ -55,7 +55,7 @@ async fn setup_standard_repo(temp_path: &std::path::Path) -> (SHA1, SHA1, SHA1, conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }) .await; let commit2 = Head::current_commit().await.unwrap(); @@ -86,7 +86,7 @@ async fn setup_standard_repo(temp_path: &std::path::Path) -> (SHA1, SHA1, SHA1, conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }) .await; let commit3 = Head::current_commit().await.unwrap(); @@ -117,7 +117,7 @@ async fn setup_standard_repo(temp_path: &std::path::Path) -> (SHA1, SHA1, SHA1, conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }) .await; let commit4 = Head::current_commit().await.unwrap(); diff --git a/libra/tests/command/revert_test.rs b/libra/tests/command/revert_test.rs index 62845cd7b..1542f3158 100644 --- a/libra/tests/command/revert_test.rs +++ b/libra/tests/command/revert_test.rs @@ -38,6 +38,7 @@ async fn test_basic_revert() { conventional: false, amend: false, signoff: false, + disable_pre: false, }) .await; println!("C1: Added 1.txt"); @@ -59,6 +60,7 @@ async fn test_basic_revert() { conventional: false, amend: false, signoff: false, + disable_pre: false, }) .await; println!("C2: Modified 1.txt"); @@ -81,6 +83,7 @@ async fn test_basic_revert() { conventional: false, amend: false, signoff: false, + disable_pre: false, }) .await; println!("C3: Removed 1.txt, Added 2.txt"); @@ -177,6 +180,7 @@ async fn test_revert_no_commit() { conventional: false, amend: false, signoff: false, + disable_pre: false, }) .await; @@ -196,6 +200,7 @@ async fn test_revert_no_commit() { conventional: false, amend: false, signoff: false, + disable_pre: false, }) .await; @@ -220,6 +225,7 @@ async fn test_revert_no_commit() { conventional: false, amend: false, signoff: false, + disable_pre: false, }) .await; @@ -252,6 +258,7 @@ async fn test_revert_root_commit() { conventional: false, amend: false, signoff: false, + disable_pre: false, }) .await; diff --git a/libra/tests/command/switch_test.rs b/libra/tests/command/switch_test.rs index a43101a83..596eac92c 100644 --- a/libra/tests/command/switch_test.rs +++ b/libra/tests/command/switch_test.rs @@ -42,7 +42,7 @@ async fn test_switch_function() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; } @@ -85,7 +85,7 @@ async fn test_switch_function() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; @@ -162,7 +162,7 @@ async fn test_detach_head_basic() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; } @@ -202,7 +202,7 @@ async fn test_detach_head_basic() { conventional: false, amend: false, signoff: false, - disable_pre:true, + disable_pre: true, }; commit::execute(args).await; }