From 020f2fdca2292fe0b4ca1a291e3cfed6fd48bb3d Mon Sep 17 00:00:00 2001
From: yyjeqhc <1772413353@qq.com>
Date: Fri, 25 Jul 2025 10:56:22 +0800
Subject: [PATCH] feat: libra add rebase subcommand.
---
.../docs/libra/command/rebase/index.mdx | 27 +-
libra/src/cli.rs | 3 +
libra/src/command/mod.rs | 1 +
libra/src/command/rebase.rs | 495 ++++++++++++++++++
libra/tests/command/mod.rs | 1 +
libra/tests/command/rebase_test.rs | 238 +++++++++
6 files changed, 761 insertions(+), 4 deletions(-)
create mode 100644 libra/src/command/rebase.rs
create mode 100644 libra/tests/command/rebase_test.rs
diff --git a/aria/contents/docs/libra/command/rebase/index.mdx b/aria/contents/docs/libra/command/rebase/index.mdx
index e916c1bd3..09f7f36e2 100644
--- a/aria/contents/docs/libra/command/rebase/index.mdx
+++ b/aria/contents/docs/libra/command/rebase/index.mdx
@@ -1,8 +1,27 @@
---
title: The [rebase] Command
-description:
+description: reapply commits on top of another base
---
-
- **Currently, libra only supports fast-forward merge.**
-
+### Usage
+
+libra rebase [OPTIONS]
+
+### Arguments
+
+- - The new "base" commit or branch. The commits from the current branch will be reapplied on top of this commit.
+
+### Description
+
+ Rebasing is the process of moving a sequence of commits to a new base commit. This command rewrites the project history by creating fresh commits that apply the same set of changes but from a different starting point.
+
+### Example
+
+ \- libra rebase master
+ \- libra rebase
+
+
+ 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 a856895c3..c0def9812 100644
--- a/libra/src/cli.rs
+++ b/libra/src/cli.rs
@@ -51,6 +51,8 @@ enum Commands {
Commit(command::commit::CommitArgs),
#[command(about = "Switch branches")]
Switch(command::switch::SwitchArgs),
+ #[command(about = "Reapply commits on top of another base tip")]
+ Rebase(command::rebase::RebaseArgs),
#[command(about = "Merge changes")]
Merge(command::merge::MergeArgs),
#[command(about = "Reset current HEAD to specified state")]
@@ -122,6 +124,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> Result<(), GitError> {
Commands::Branch(args) => command::branch::execute(args).await,
Commands::Commit(args) => command::commit::execute(args).await,
Commands::Switch(args) => command::switch::execute(args).await,
+ Commands::Rebase(args) => command::rebase::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,
diff --git a/libra/src/command/mod.rs b/libra/src/command/mod.rs
index 4fa69c2d6..8a9ffbfe0 100644
--- a/libra/src/command/mod.rs
+++ b/libra/src/command/mod.rs
@@ -14,6 +14,7 @@ pub mod log;
pub mod merge;
pub mod pull;
pub mod push;
+pub mod rebase;
pub mod remote;
pub mod remove;
pub mod reset;
diff --git a/libra/src/command/rebase.rs b/libra/src/command/rebase.rs
new file mode 100644
index 000000000..264602e6e
--- /dev/null
+++ b/libra/src/command/rebase.rs
@@ -0,0 +1,495 @@
+use crate::command::{load_object, save_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::object::commit::Commit;
+use mercury::internal::object::tree::Tree;
+use std::collections::{HashMap, HashSet};
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// Command-line arguments for the rebase operation
+#[derive(Parser, Debug)]
+pub struct RebaseArgs {
+ /// The upstream branch to rebase the current branch onto.
+ /// This can be a branch name, commit hash, or other Git reference.
+ #[clap(required = true)]
+ pub upstream: String,
+}
+
+/// Execute the rebase command
+///
+/// Rebase moves or combines a sequence of commits to a new base commit.
+/// This implementation performs a linear rebase by:
+/// 1. Finding the common ancestor between current branch and upstream
+/// 2. Collecting all commits from the common ancestor to current HEAD
+/// 3. Replaying each commit on top of the upstream branch
+/// 4. Updating the current branch reference to point to the final commit
+///
+/// The process maintains commit order but changes their parent relationships,
+/// effectively "moving" the branch to start from the upstream commit.
+pub async fn execute(args: RebaseArgs) {
+ if !util::check_repo_exist() {
+ return;
+ }
+
+ // Get the current branch that will be moved to the new base
+ let current_branch_name = match Head::current().await {
+ Head::Branch(name) if !name.is_empty() => name,
+ _ => {
+ eprintln!("fatal: not on a branch or in detached HEAD state, cannot rebase");
+ return;
+ }
+ };
+
+ // Get the current HEAD commit that represents the tip of the branch to rebase
+ let head_to_rebase_id = match Head::current_commit().await {
+ Some(id) => id,
+ None => {
+ eprintln!("fatal: current branch '{current_branch_name}' has no commits");
+ return;
+ }
+ };
+
+ // Resolve the upstream reference to a concrete commit ID
+ let upstream_id = match resolve_branch_or_commit(&args.upstream).await {
+ Ok(id) => id,
+ Err(e) => {
+ eprintln!("fatal: {e}");
+ return;
+ }
+ };
+
+ // Find the merge base (common ancestor) between current branch and upstream
+ // This determines which commits need to be replayed
+ let base_id = match find_merge_base(&head_to_rebase_id, &upstream_id).await {
+ Ok(Some(id)) => id,
+ _ => {
+ eprintln!("fatal: no common ancestor found");
+ return;
+ }
+ };
+
+ // Check if rebase is actually needed
+ if base_id == head_to_rebase_id {
+ println!(
+ "Branch '{}' is already based on '{}'. No rebase needed.",
+ current_branch_name, args.upstream
+ );
+ return;
+ }
+ if base_id == upstream_id {
+ println!("Current branch is ahead of upstream. No rebase needed.");
+ return;
+ }
+
+ // Collect all commits that need to be replayed from base to current HEAD
+ let commits_to_replay = match collect_commits_to_replay(&base_id, &head_to_rebase_id).await {
+ Ok(commits) if !commits.is_empty() => commits,
+ _ => {
+ println!("No commits to rebase on branch '{current_branch_name}'.",);
+ return;
+ }
+ };
+ println!("Found common ancestor: {}", &base_id.to_string()[..7]);
+ println!(
+ "Rebasing {} commits from '{}' onto '{}'...",
+ commits_to_replay.len(),
+ current_branch_name,
+ args.upstream
+ );
+
+ // Replay each commit on top of the upstream branch
+ // Each commit is applied as a three-way merge and creates a new commit
+ let mut new_base_id = upstream_id;
+ for commit_id in commits_to_replay {
+ match replay_commit(&commit_id, &new_base_id).await {
+ Ok(replayed_commit_id) => {
+ new_base_id = replayed_commit_id;
+ let original_commit: Commit = load_object(&commit_id).unwrap();
+ println!(
+ "Applied: {} {}",
+ &new_base_id.to_string()[..7],
+ original_commit.message.lines().next().unwrap_or("")
+ );
+ }
+ Err(e) => {
+ eprintln!(
+ "error: could not apply {}: {}",
+ &commit_id.to_string()[..7],
+ e
+ );
+ eprintln!("Rebase failed.");
+ // TODO: Implement proper conflict resolution and recovery mechanisms
+ // Currently, we just abort the rebase without cleanup or rollback
+ return;
+ }
+ }
+ }
+
+ // Update the current branch reference to point to the final replayed commit
+ Branch::update_branch(¤t_branch_name, &new_base_id.to_string(), None).await;
+
+ // Reset the working directory and index to match the final state
+ // This ensures that the workspace reflects the rebased commits
+ let final_commit: Commit = load_object(&new_base_id).unwrap();
+ let final_tree: Tree = load_object(&final_commit.tree_id).unwrap();
+
+ let index_file = path::index();
+ let mut index = mercury::internal::index::Index::new();
+ rebuild_index_from_tree(&final_tree, &mut index, "").unwrap();
+ index.save(&index_file).unwrap();
+ reset_workdir_to_index(&index).unwrap();
+
+ // Update HEAD to point to the current branch (not strictly necessary but good practice)
+ Head::update(Head::Branch(current_branch_name.clone()), None).await;
+ println!(
+ "Successfully rebased branch '{}' onto '{}'.",
+ current_branch_name, args.upstream
+ );
+}
+
+/// Resolve a branch name or commit reference to a SHA1 hash
+///
+/// This function first tries to find a branch with the given name,
+/// then falls back to resolving it as a commit reference (hash, HEAD, etc.).
+/// This allows the rebase command to work with both branch names and commit hashes.
+async fn resolve_branch_or_commit(reference: &str) -> Result {
+ // First try to resolve as a branch name
+ if let Some(branch) = Branch::find_branch(reference, None).await {
+ return Ok(branch.commit);
+ }
+ // Fall back to commit hash resolution
+ match util::get_commit_base(reference).await {
+ Ok(id) => Ok(id),
+ Err(_) => Err(format!("invalid reference: {reference}")),
+ }
+}
+
+/// Replay a single commit on top of a new parent commit
+///
+/// This function performs a three-way merge to apply the changes from one commit
+/// onto a different base commit. The three points of the merge are:
+/// - Base: The original parent of the commit being replayed
+/// - Theirs: The commit being replayed (contains the changes to apply)
+/// - Ours: The new parent commit (where we want to apply the changes)
+///
+/// The result is a new commit with the same changes but a different parent.
+async fn replay_commit(commit_to_replay_id: &SHA1, new_parent_id: &SHA1) -> Result {
+ let commit_to_replay: Commit = load_object(commit_to_replay_id).map_err(|e| e.to_string())?;
+ let original_parent_id = commit_to_replay
+ .parent_commit_ids
+ .first()
+ .ok_or_else(|| "commit to replay has no parents".to_string())?;
+
+ // Load the three trees needed for the three-way merge
+ // Base tree: state before the original commit
+ let base_tree: Tree = load_object(
+ &load_object::(original_parent_id)
+ .map_err(|e| e.to_string())?
+ .tree_id,
+ )
+ .map_err(|e| e.to_string())?;
+
+ // Their tree: state after the original commit (the changes we want to apply)
+ let their_tree: Tree = load_object(&commit_to_replay.tree_id).map_err(|e| e.to_string())?;
+
+ // Our tree: current state of the new parent (where we apply changes)
+ let our_tree: Tree = load_object(
+ &load_object::(new_parent_id)
+ .map_err(|e| e.to_string())?
+ .tree_id,
+ )
+ .map_err(|e| e.to_string())?;
+
+ // Calculate what changed between base and their trees
+ let diff = diff_trees(&their_tree, &base_tree);
+ let mut merged_items: HashMap = our_tree.get_plain_items().into_iter().collect();
+
+ // Apply the changes to our tree
+ for (path, their_hash, _base_hash) in diff {
+ if let Some(hash) = their_hash {
+ // File was added or modified: use the new content
+ merged_items.insert(path, hash);
+ } else {
+ // File was deleted: remove it from our tree
+ merged_items.remove(&path);
+ }
+ }
+ // TODO: Implement proper three-way merge with conflict detection
+ // Currently using simplified logic that always takes "theirs" changes
+ // without checking for conflicts with "ours" changes
+
+ // Create a new tree with the merged content
+ let new_tree_id = create_tree_from_items_map(&merged_items)?;
+
+ // Create new commit with the same message but different parent and tree
+ let new_commit =
+ Commit::from_tree_id(new_tree_id, vec![*new_parent_id], &commit_to_replay.message);
+ save_object(&new_commit, &new_commit.id).map_err(|e| e.to_string())?;
+ Ok(new_commit.id)
+}
+
+/// Find the merge base (common ancestor) of two commits
+///
+/// This function implements a simple merge base algorithm:
+/// 1. Traverse all ancestors of the first commit and store them in a set
+/// 2. Traverse ancestors of the second commit until we find one in the set
+/// 3. Return the first common ancestor found
+///
+/// Note: This returns the first common ancestor found, not necessarily the
+/// best common ancestor. A more sophisticated algorithm would find the
+/// lowest common ancestor (LCA).
+///
+/// TODO: Implement proper LCA algorithm for better merge base detection
+/// TODO: Optimize performance for large repositories with many commits
+async fn find_merge_base(commit1_id: &SHA1, commit2_id: &SHA1) -> Result