diff --git a/aria/contents/docs/libra/command/reflog/index.mdx b/aria/contents/docs/libra/command/reflog/index.mdx new file mode 100644 index 000000000..5ee5e3a9e --- /dev/null +++ b/aria/contents/docs/libra/command/reflog/index.mdx @@ -0,0 +1,61 @@ +--- +title: The reflog Command +description: Manage reflog information +--- + +### Usage + +`libra reflog [OPTIONS]` + +### Subcommands + +- `show` + Show the reflog history. This is the default action if no subcommand is provided. + +- `delete` + Delete specific entries from the reflog. + +- `exists` + Check if a ref has a reflog. + +### Description + +The libra reflog command is used to manage the log of reference changes. It tracks when the tips of branches and other references were updated in the local repository. It's a powerful tool for recovering lost work and undoing mistakes, acting as a "safety net" for your repository. + +- To show the history of HEAD, simply run: libra reflog or libra reflog show + +- To show the history of a specific branch: libra reflog show `` + +- To delete the second-to-last entry of the HEAD reflog: libra reflog delete HEAD@{1} + +### Options (reflog show) + +- `` + The reference to show the log for (e.g., HEAD, main, origin/main). Defaults to HEAD. + +- `--pretty=` + Format the output of the reflog entries. Supported formats are: + + oneline: The default, compact format (` : `). + + short: Shows commit hash, author, and subject. + + medium: Shows commit hash, author, date, and full message. + + full: Shows author and committer information. + +- `-h`, `--help` + Print help for the reflog command or a specific subcommand. + +### Options (reflog delete) + + `...` + One or more reflog entries to delete (e.g., HEAD@{2}, main@{5}). This argument is required. + + + The `reflog` command is indispensable for recovering from situations where you might think you've lost work, such as + after a faulty `reset` or `rebase`. It tracks local movements of references, so + it won't be available in bare or freshly cloned repositories until some actions are performed. + Refs https://git-scm.com/docs/git-reflog for more information. + + diff --git a/aria/contents/docs/libra/internal/scheme/index.mdx b/aria/contents/docs/libra/internal/scheme/index.mdx index a512a8ee7..02584e38d 100644 --- a/aria/contents/docs/libra/internal/scheme/index.mdx +++ b/aria/contents/docs/libra/internal/scheme/index.mdx @@ -7,7 +7,7 @@ description: Libra use `sea-orm` to interact with sqlite database. The data model is defined in [`libra/src/internal/model`](https://github.com/web3infra-foundation/mega/tree/main/libra/src/internal/model), with two tables: `config` and `reference`. -The `config` table is used to store the configuration of the project, which corresponds to the `config` file in git. The `reference` table is used to store the reference of the project, which corresponds to the `HEAD` and `refs/*` files in git. +The `config` table is used to store the configuration of the project, which corresponds to the `config` file in git. The `reference` table is used to store the reference of the project, which corresponds to the `HEAD` and `refs/*` files in git. This `reflog` is used to record the history of changes to references, corresponding to the files in the `.git/logs/` directory in git. The relationship between the git file and the sqlite table is as follows: @@ -36,11 +36,19 @@ The relationship between the git file and the sqlite table is as follows: | ------------ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.gitconfig` | Ini format configuration | Config(configuration=”core”; name=null; key=”filemode”;value=”true” )

Config(configuration=”core”; name=null; key=”ignorecase”;value=”false” )

Config(configuration=”remote”; name=”origin”; key=”url”;value=”url.git” )

Config(configuration=”remote”; name=”null”; key=”fetch”;value=”+refs……” ) | +#### reflog: + +The `reflog` table records every movement of the `HEAD` and branch references. Whenever a reference (like `HEAD` or `refs/heads/main`) is updated, a new record is inserted into this table to describe the change. + +| Category | Description | Database format | +| --------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.git/logs/HEAD`
`.git/logs/refs/heads/` | Records the history of reference changes | Reflog(
ref_name=``,
old_oid=``,
new_oid=``,
committer_name=``,
committer_email=``,
timestamp=``,
action=``,
message=``
) | + ### Business Model Design For decoupling, the `sea-orm` model is not directly used in the code, and the business model is redefined (located in `libra/src/internal`), and common CRUD operations are implemented. -Currently, the `config`, `head` and `reference` models are implemented. +Currently, the `config`, `reference` and `reflog` models are implemented. ### SQL Statement @@ -52,6 +60,7 @@ CREATE TABLE IF NOT EXISTS `config` ( `key` TEXT NOT NULL, `value` TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS `reference` ( `id` INTEGER PRIMARY KEY AUTOINCREMENT, -- name can't be '' @@ -64,6 +73,19 @@ CREATE TABLE IF NOT EXISTS `reference` ( (kind <> 'Tag' OR (kind = 'Tag' AND remote IS NULL)) ) ); + +CREATE TABLE IF NOT EXISTS `reflog` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `ref_name` TEXT NOT NULL, + `old_oid` TEXT NOT NULL, + `new_oid` TEXT NOT NULL, + `committer_name` TEXT NOT NULL, + `committer_email` TEXT NOT NULL, + `timestamp` INTEGER NOT NULL, + `action` TEXT NOT NULL, + `message` TEXT NOT NULL +); + -- (name, kind, remote) as unique key when remote is not null CREATE UNIQUE INDEX idx_name_kind_remote ON `reference`(`name`, `kind`, `remote`) WHERE `remote` IS NOT NULL; @@ -71,4 +93,6 @@ WHERE `remote` IS NOT NULL; -- (name, kind) as unique key when remote is null CREATE UNIQUE INDEX idx_name_kind ON `reference`(`name`, `kind`) WHERE `remote` IS NULL; + +CREATE INDEX idx_ref_name_timestamp ON `reflog`(`ref_name`, `timestamp`); ``` diff --git a/aria/lib/routes-config.ts b/aria/lib/routes-config.ts index 8b05a46c8..4e809dd43 100644 --- a/aria/lib/routes-config.ts +++ b/aria/lib/routes-config.ts @@ -53,6 +53,7 @@ export const ROUTES: EachRoute[] = [ { title: "pull", href: "/pull" }, { title: "push", href: "/push" }, { title: "rebase", href: "/rebase" }, + { title: "reflog", href: "/reflog" }, { title: "remote", href: "/remote" }, { title: "reset", href: "/reset" }, { title: "restore", href: "/restore" }, diff --git a/libra/sql/sqlite_20240331_init.sql b/libra/sql/sqlite_20240331_init.sql index 3269e6836..070d0340e 100644 --- a/libra/sql/sqlite_20240331_init.sql +++ b/libra/sql/sqlite_20240331_init.sql @@ -17,10 +17,23 @@ CREATE TABLE IF NOT EXISTS `reference` ( (kind <> 'Tag' OR (kind = 'Tag' AND remote IS NULL)) ) ); +CREATE TABLE IF NOT EXISTS `reflog` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `ref_name` TEXT NOT NULL, + `old_oid` TEXT NOT NULL, + `new_oid` TEXT NOT NULL, + `committer_name` TEXT NOT NULL, + `committer_email` TEXT NOT NULL, + `timestamp` INTEGER NOT NULL, + `action` TEXT NOT NULL, + `message` TEXT NOT NULL +); -- (name, kind, remote) as unique key when remote is not null CREATE UNIQUE INDEX idx_name_kind_remote ON `reference`(`name`, `kind`, `remote`) WHERE `remote` IS NOT NULL; -- (name, kind) as unique key when remote is null CREATE UNIQUE INDEX idx_name_kind ON `reference`(`name`, `kind`) -WHERE `remote` IS NULL; \ No newline at end of file +WHERE `remote` IS NULL; + +CREATE INDEX idx_ref_name_timestamp ON `reflog`(`ref_name`, `timestamp`) \ No newline at end of file diff --git a/libra/src/cli.rs b/libra/src/cli.rs index c0def9812..3e2839c0c 100644 --- a/libra/src/cli.rs +++ b/libra/src/cli.rs @@ -73,6 +73,8 @@ enum Commands { Remote(command::remote::RemoteCmds), #[command(about = "Manage repository configurations")] Config(command::config::ConfigArgs), + #[command(about = "Manage the log of reference changes (e.g., HEAD, branches)")] + Reflog(command::reflog::ReflogArgs), // other hidden commands #[command( @@ -137,6 +139,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> Result<(), GitError> { Commands::Pull(args) => command::pull::execute(args).await, Commands::Config(args) => command::config::execute(args).await, Commands::Checkout(args) => command::checkout::execute(args).await, + Commands::Reflog(args) => command::reflog::execute(args).await, } Ok(()) } diff --git a/libra/src/command/cherry_pick.rs b/libra/src/command/cherry_pick.rs index 33f46f1e0..8d0f5c7dd 100644 --- a/libra/src/command/cherry_pick.rs +++ b/libra/src/command/cherry_pick.rs @@ -13,6 +13,8 @@ use mercury::internal::object::tree::{Tree, TreeItem, TreeItemMode}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use sea_orm::{ConnectionTrait}; +use crate::internal::reflog::{with_reflog, ReflogAction, ReflogContext}; /// Arguments for the cherry-pick command #[derive(Parser, Debug)] @@ -203,7 +205,25 @@ async fn create_cherry_pick_commit( ); save_object(&commit, &commit.id).map_err(|e| format!("failed to save commit: {e}"))?; - update_head(&commit.id.to_string()).await; + + // let reflog extract the subject of message. + let action = ReflogAction::CherryPick { source_message: original_commit.message.clone() }; + let context = ReflogContext { + old_oid: parent_id.to_string(), + new_oid: commit.id.to_string(), + action, + }; + + with_reflog( + context, + move |txn| { + Box::pin(async move { + update_head(txn, &commit.id.to_string()).await; + Ok(()) + }) + }, + true, + ).await.map_err(|e| e.to_string())?; Ok(commit.id) } @@ -388,8 +408,8 @@ async fn resolve_commit(reference: &str) -> Result { /// 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; +async fn update_head(db: &C, commit_id: &str) { + if let Head::Branch(name) = Head::current_with_conn(db).await { + Branch::update_branch_with_conn(db, &name, commit_id, None).await; } } diff --git a/libra/src/command/clone.rs b/libra/src/command/clone.rs index 46d872ed0..90363aa46 100644 --- a/libra/src/command/clone.rs +++ b/libra/src/command/clone.rs @@ -1,19 +1,20 @@ +use super::fetch::{self}; use crate::command::restore::RestoreArgs; use crate::command::{self, branch}; use crate::internal::branch::Branch; use crate::internal::config::{Config, RemoteConfig}; use crate::internal::head::Head; +use crate::internal::reflog::{with_reflog, zero_sha1, ReflogAction, ReflogContext}; use crate::utils::path_ext::PathExt; use crate::utils::util; use clap::Parser; use colored::Colorize; use scopeguard::defer; +use sea_orm::DatabaseTransaction; use std::cell::Cell; use std::path::PathBuf; use std::{env, fs}; -use super::fetch::{self}; - const ORIGIN: &str = "origin"; // default remote name, prevent spelling mistakes #[derive(Parser, Debug)] @@ -97,59 +98,95 @@ pub async fn execute(args: CloneArgs) { name: "origin".to_string(), url: remote_repo.clone(), }; - fetch::fetch_repository(&remote_config, args.branch.clone()).await; + fetch::fetch_repository(remote_config, args.branch.clone()).await; /* setup */ - setup(remote_repo.clone(), args.branch.clone()).await; + if let Err(e) = setup_repository(remote_repo.clone(), args.branch.clone()).await { + eprintln!("fatal: {}", e); + return; + } is_success.set(true); } -async fn setup(remote_repo: String, specified_branch: Option) { - // look for remote head and set local HEAD&branch - let remote_head = Head::remote_current(ORIGIN).await; - - // set config: remote.origin.url,it's essential for git - Config::insert("remote", Some(ORIGIN), "url", &remote_repo).await; - // set config: remote.origin.fetch - // todo: temporary ignore fetch option - - if let Some(specified_branch) = specified_branch { - setup_branch(specified_branch).await; - } else if let Some(Head::Branch(name)) = remote_head { - setup_branch(name).await; - } else if let Some(Head::Detached(_)) = remote_head { - eprintln!("fatal: remote HEAD points to a detached commit"); +/// Sets up the local repository after a clone by configuring the remote, +/// setting up the initial branch and HEAD, and creating the first reflog entry. +async fn setup_repository(remote_repo: String, specified_branch: Option) -> Result<(), String> { + let db = crate::internal::db::get_db_conn_instance().await; + let remote_head = Head::remote_current_with_conn(db, ORIGIN).await; + + // Determine which branch to check out. + let branch_to_checkout = match specified_branch { + Some(b_name) => Some(b_name), + None => { + if let Some(Head::Branch(name)) = remote_head { + Some(name) + } else { + None // This case handles empty repos or detached HEADs + } + } + }; + + if let Some(branch_name) = branch_to_checkout { + let origin_branch = Branch::find_branch_with_conn(db, &branch_name, Some(ORIGIN)).await + .ok_or_else(|| format!("fatal: remote branch '{}' not found.", branch_name))?; + + // Prepare the reflog context *before* the transaction + let action = ReflogAction::Clone { from: remote_repo.clone() }; + + let context = ReflogContext { + // In a clone, there is no "old" oid. A zero-hash is the standard representation. + old_oid: zero_sha1().to_string(), + new_oid: origin_branch.commit.to_string(), + action, + }; + + // `insert_ref` is true, as we are creating the initial branch reflog. + with_reflog( + context, + move |txn: &DatabaseTransaction| { + Box::pin(async move { + // 1. Create the local branch pointing to the fetched commit + Branch::update_branch_with_conn(txn, &branch_name, &origin_branch.commit.to_string(), None).await; + + // 2. Set HEAD to point to the new local branch + Head::update_with_conn(txn, Head::Branch(branch_name.to_owned()), None).await; + + // 3. Configure remote tracking for the branch + let merge_ref = format!("refs/heads/{}", branch_name); + Config::insert_with_conn(txn, "branch", Some(&branch_name), "merge", &merge_ref).await; + Config::insert_with_conn(txn, "branch", Some(&branch_name), "remote", ORIGIN).await; + + // 4. Configure the remote URL + Config::insert_with_conn(txn, "remote", Some(ORIGIN), "url", &remote_repo).await; + Ok(()) + }) + }, + true, + ).await.map_err(|e| e.to_string())?; + + // After the DB is set up, restore the working directory + command::restore::execute(RestoreArgs { + worktree: true, + staged: true, + source: None, + pathspec: vec![util::working_dir_string()], + }).await; + } else { println!("warning: You appear to have cloned an empty repository."); - // set config: branch.$name.merge, e.g. - let merge = "refs/heads/master".to_owned(); - Config::insert("branch", Some("master"), "merge", &merge).await; - // set config: branch.$name.remote - Config::insert("branch", Some("master"), "remote", ORIGIN).await; + // We only need to set the remote URL. No reflog is created as there are no commits. + Config::insert("remote", Some(ORIGIN), "url", &remote_repo).await; + + // Optionally set up a default branch config for a future 'master' or 'main' + let default_branch = "master"; + let merge_ref = format!("refs/heads/{}", default_branch); + Config::insert("branch", Some(default_branch), "merge", &merge_ref).await; + Config::insert("branch", Some(default_branch), "remote", ORIGIN).await; } -} -async fn setup_branch(branch_name: String) { - let origin_head_branch = Branch::find_branch(&branch_name, Some(ORIGIN)) - .await - .expect("origin HEAD branch not found"); - - Branch::update_branch(&branch_name, &origin_head_branch.commit.to_string(), None).await; - Head::update(Head::Branch(branch_name.to_owned()), None).await; - - let merge = "refs/heads/".to_owned() + &branch_name; - Config::insert("branch", Some(&branch_name), "merge", &merge).await; - Config::insert("branch", Some(&branch_name), "remote", ORIGIN).await; - - command::restore::execute(RestoreArgs { - worktree: true, - staged: true, - source: None, - pathspec: vec![util::working_dir_string()], - }) - .await; + Ok(()) } /// Unit tests for the clone module diff --git a/libra/src/command/commit.rs b/libra/src/command/commit.rs index cf0cc0b56..7dde7e8f3 100644 --- a/libra/src/command/commit.rs +++ b/libra/src/command/commit.rs @@ -2,10 +2,12 @@ use std::process::Stdio; use std::str::FromStr; use std::{collections::HashSet, path::PathBuf}; +use super::save_object; use crate::command::load_object; use crate::internal::branch::Branch; use crate::internal::config::Config as UserConfig; use crate::internal::head::Head; +use crate::internal::reflog::{with_reflog, ReflogAction, ReflogContext}; use crate::utils::client_storage::ClientStorage; use crate::utils::path; use crate::utils::util; @@ -16,10 +18,9 @@ use mercury::internal::index::Index; use mercury::internal::object::commit::Commit; use mercury::internal::object::tree::{Tree, TreeItem, TreeItemMode}; use mercury::internal::object::ObjectTrait; +use sea_orm::{ConnectionTrait}; use std::process::Command; -use super::save_object; - #[derive(Parser, Debug, Default)] pub struct CommitArgs { #[arg(short, long)] @@ -145,7 +146,7 @@ pub async fn execute(args: CommitArgs) { .unwrap(); /* update HEAD */ - update_head(&commit.id.to_string()).await; + update_head_and_reflog(&commit.id.to_string(), &commit_message).await; return; } @@ -163,7 +164,7 @@ pub async fn execute(args: CommitArgs) { .unwrap(); /* update HEAD */ - update_head(&commit.id.to_string()).await; + update_head_and_reflog(&commit.id.to_string(), &commit_message).await; } /// recursively create tree from index's tracked entries @@ -251,21 +252,46 @@ async fn get_parents_ids() -> Vec { } /// update HEAD to new commit, if in branch, update branch's commit id, if detached head, update head's commit id -async fn update_head(commit_id: &str) { +async fn update_head(db: &C, commit_id: &str) { // let head = reference::Model::current_head(db).await.unwrap(); - match Head::current().await { + match Head::current_with_conn(db).await { Head::Branch(name) => { // in branch - Branch::update_branch(&name, commit_id, None).await; + Branch::update_branch_with_conn(db, &name, commit_id, None).await; } // None => { Head::Detached(_) => { let head = Head::Detached(SHA1::from_str(commit_id).unwrap()); - Head::update(head, None).await; + Head::update_with_conn(db, head, None).await; } } } +async fn update_head_and_reflog(commit_id: &str, commit_message: &str) { + let reflog_context = new_reflog_context(commit_id, commit_message).await; + let commit_id = commit_id.to_string(); + with_reflog(reflog_context, |txn| Box::pin(async move { + update_head(txn, &commit_id).await; + Ok(()) + }), true).await.unwrap(); +} + +async fn new_reflog_context(commit_id: &str, message: &str) -> ReflogContext { + let old_oid = Head::current_commit() + .await + .unwrap_or(SHA1::from_bytes(&[0; 20])) + ._to_string(); + let new_oid = commit_id.to_string(); + let action = ReflogAction::Commit { + message: message.to_string(), + }; + ReflogContext { + old_oid, + new_oid, + action, + } +} + #[cfg(test)] mod test { use std::env; diff --git a/libra/src/command/fetch.rs b/libra/src/command/fetch.rs index 915d378e5..a44b2431a 100644 --- a/libra/src/command/fetch.rs +++ b/libra/src/command/fetch.rs @@ -7,6 +7,7 @@ use std::io; use std::time::Instant; use std::vec; use std::{collections::HashSet, fs, io::Write}; +use sea_orm::{TransactionTrait}; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio_util::io::StreamReader; use url::Url; @@ -23,6 +24,9 @@ use crate::{ }, utils::{self, path_ext::PathExt}, }; +use crate::internal::db::get_db_conn_instance; +use crate::internal::reflog; +use crate::internal::reflog::{zero_sha1, ReflogAction, ReflogContext, ReflogError}; const DEFAULT_REMOTE: &str = "origin"; @@ -46,7 +50,7 @@ pub async fn execute(args: FetchArgs) { if args.all { let remotes = Config::all_remote_configs().await; let tasks = remotes.into_iter().map(|remote| async move { - fetch_repository(&remote, None).await; + fetch_repository(remote, None).await; }); futures::future::join_all(tasks).await; } else { @@ -65,7 +69,7 @@ pub async fn execute(args: FetchArgs) { }; let remote_config = Config::remote_config(&remote).await; match remote_config { - Some(remote_config) => fetch_repository(&remote_config, args.refspec).await, + Some(remote_config) => fetch_repository(remote_config, args.refspec).await, None => { tracing::error!("remote config '{}' not found", remote); eprintln!("fatal: '{remote}' does not appear to be a libra repository"); @@ -76,7 +80,7 @@ pub async fn execute(args: FetchArgs) { /// Fetch from remote repository /// - `branch` is optional, if `None`, fetch all branches -pub async fn fetch_repository(remote_config: &RemoteConfig, branch: Option) { +pub async fn fetch_repository(remote_config: RemoteConfig, branch: Option) { println!( "fetching from {}{}", remote_config.name, @@ -170,7 +174,7 @@ pub async fn fetch_repository(remote_config: &RemoteConfig, branch: Option { // Progress print!("{}", String::from_utf8_lossy(data)); - std::io::stdout().flush().unwrap(); + io::stdout().flush().unwrap(); } 3 => { // Error @@ -183,7 +187,7 @@ pub async fn fetch_repository(remote_config: &RemoteConfig, branch: Option { - let remote_head_ref = ref_heads.iter().find(|r| r._hash == remote_head._hash); - - match remote_head_ref { - Some(remote_head_ref) => { - let remote_head_branch = - remote_head_ref._ref.strip_prefix("refs/heads/").unwrap(); - Head::update( + + // 2. Update the remote's HEAD pointer + if let Some(remote_head) = remote_head { + if let Some(remote_head_ref) = ref_heads.iter().find(|r| r._hash == remote_head._hash) { + if let Some(remote_head_branch) = remote_head_ref._ref.strip_prefix("refs/heads/") { + // This updates `refs/remotes/origin/HEAD` + Head::update_with_conn( + txn, Head::Branch(remote_head_branch.to_owned()), Some(&remote_config.name), - ) - .await; - } - None => { - if branch.is_none() { - eprintln!("remote HEAD not found"); - } else { - // normal: remote HEAD usually points to master - tracing::debug!("Specified branch not found in remote HEAD"); - } + ).await; } + } else if branch.is_none() { + eprintln!("remote HEAD not found"); + } else { + tracing::debug!("Specified branch not found in remote HEAD"); } - } - None => { + } else { tracing::warn!("fetch empty, remote HEAD not found"); } + Ok::<_, ReflogError>(()) + })).await; + + if let Err(e) = transaction_result { + eprintln!("fatal: failed to update references after fetch: {}", e); } } diff --git a/libra/src/command/merge.rs b/libra/src/command/merge.rs index 6616a8ed3..98a74b531 100644 --- a/libra/src/command/merge.rs +++ b/libra/src/command/merge.rs @@ -5,7 +5,8 @@ use crate::{ internal::{branch::Branch, head::Head}, utils::util, }; - +use crate::internal::db::get_db_conn_instance; +use crate::internal::reflog::{with_reflog, zero_sha1, ReflogAction, ReflogContext}; use super::{ get_target_commit, load_object, log, restore::{self, RestoreArgs}, @@ -30,7 +31,7 @@ pub async fn execute(args: MergeArgs) { // If the current HEAD doesn't point to any commit, perform a fast-forward merge directly let current_commit_id = Head::current_commit().await; if current_commit_id.is_none() { - merge_ff(target_commit).await; + merge_ff(target_commit, &args.branch).await; return; } @@ -54,7 +55,7 @@ pub async fn execute(args: MergeArgs) { &target_commit.id.to_string()[..6] ); // fast-forward merge - merge_ff(target_commit).await; + merge_ff(target_commit, &args.branch).await; } else { // didn't support yet eprintln!("fatal: Not possible to fast-forward merge, try merge manually"); @@ -90,25 +91,54 @@ async fn lca_commit(lhs: &Commit, rhs: &Commit) -> Option { } /// try merge in fast-forward mode, if it's not possible, do nothing -async fn merge_ff(commit: Commit) { +async fn merge_ff(target_commit: Commit, target_branch_name: &str) { println!("Fast-forward"); - // fast-forward merge - let head = Head::current().await; - match head { - Head::Branch(branch_name) => { - Branch::update_branch(&branch_name, &commit.id.to_string(), None).await; - } - Head::Detached(_) => { - Head::update(Head::Detached(commit.id), None).await; - } - } - // change the working directory to the commit - // restore all files to worktree from HEAD + let db = get_db_conn_instance().await; + + let old_oid_opt = Head::current_commit_with_conn(db).await; + let current_head_state = Head::current_with_conn(db).await; + + let action = ReflogAction::Merge { + branch: target_branch_name.to_string(), + policy: "fast-forward".to_string(), + }; + let context = ReflogContext { + // If there was no previous commit, this is an initial commit merge (e.g., on an empty branch). + // Use the zero-hash in that case. + old_oid: old_oid_opt.map_or(zero_sha1().to_string(), |id| id.to_string()), + new_oid: target_commit.id.to_string(), + action, + }; + + // Use `with_reflog`. A merge operation should log for the branch. + if let Err(e) = with_reflog( + context, + move |txn: &sea_orm::DatabaseTransaction| { + Box::pin(async move { + match ¤t_head_state { + Head::Branch(branch_name) => { + Branch::update_branch_with_conn(txn, branch_name, &target_commit.id.to_string(), None).await; + } + Head::Detached(_) => { + // Merging into a detached HEAD is unusual but possible. We just move HEAD. + Head::update_with_conn(txn, Head::Detached(target_commit.id), None).await; + } + } + Ok(()) + }) + }, + true).await + { + eprintln!("fatal: {}", e); + return; + }; + + // Only restore the working directory *after* the pointers have been updated. restore::execute(RestoreArgs { worktree: true, staged: true, - source: None, + source: None, // `restore` without source defaults to HEAD, which is now correct. pathspec: vec![util::working_dir_string()], }) - .await; + .await; } diff --git a/libra/src/command/mod.rs b/libra/src/command/mod.rs index 8a9ffbfe0..21c438582 100644 --- a/libra/src/command/mod.rs +++ b/libra/src/command/mod.rs @@ -22,6 +22,7 @@ pub mod restore; pub mod revert; pub mod status; pub mod switch; +pub mod reflog; use crate::internal::branch::Branch; use crate::internal::head::Head; diff --git a/libra/src/command/rebase.rs b/libra/src/command/rebase.rs index 264602e6e..5cecadea3 100644 --- a/libra/src/command/rebase.rs +++ b/libra/src/command/rebase.rs @@ -10,6 +10,10 @@ use mercury::internal::object::tree::Tree; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use sea_orm::{TransactionTrait}; +use crate::internal::db::get_db_conn_instance; +use crate::internal::reflog; +use crate::internal::reflog::{with_reflog, ReflogAction, ReflogContext, ReflogError}; /// Command-line arguments for the rebase operation #[derive(Parser, Debug)] @@ -36,6 +40,8 @@ pub async fn execute(args: RebaseArgs) { return; } + let db = get_db_conn_instance().await; + // 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, @@ -102,13 +108,43 @@ pub async fn execute(args: RebaseArgs) { args.upstream ); + let start_action = ReflogAction::Rebase { + state: "start".to_string(), + details: format!("checkout {}", args.upstream), + }; + let start_context = ReflogContext { + old_oid: head_to_rebase_id.to_string(), + new_oid: upstream_id.to_string(), + action: start_action, + }; + let transaction_result = db.transaction(|txn| { + Box::pin(async move { + reflog::Reflog::insert_single_entry(txn, &start_context, "HEAD").await?; + Head::update_with_conn(txn, Head::Detached(upstream_id), None).await; + Ok::<_, ReflogError>(()) + }) + }).await; + + if let Err(e) = transaction_result { + eprintln!("fatal: failed to start rebase: {}", e); + return; + } + + // This mimics Git's behavior. + Head::update_with_conn(db, Head::Detached(upstream_id), None).await; // Replay each commit on top of the upstream branch // Each commit is applied as a three-way merge and creates a new commit + println!( + "Rebasing {} commits from `{}` onto `{}`...", + commits_to_replay.len(), current_branch_name, args.upstream + ); 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; + // Temporarily move HEAD along with each replayed commit. + Head::update_with_conn(db, Head::Detached(new_base_id), None).await; let original_commit: Commit = load_object(&commit_id).unwrap(); println!( "Applied: {} {}", @@ -117,21 +153,51 @@ pub async fn execute(args: RebaseArgs) { ); } Err(e) => { + // IMPORTANT: If rebase failed, we should reset HEAD back to the original branch. + Head::update_with_conn(db, Head::Branch(current_branch_name), None).await; 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 + eprintln!("Rebase failed. HEAD reset to original state."); 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; + let final_commit_id = new_base_id; + let finish_action = ReflogAction::Rebase { + state: "finish".to_string(), + details: format!("returning to refs/heads/{current_branch_name}"), + }; + let finish_context = ReflogContext { + old_oid: head_to_rebase_id.to_string(), + new_oid: final_commit_id.to_string(), + action: finish_action, + }; + + let branch_name_cloned = current_branch_name.clone(); + if let Err(e) = with_reflog( + finish_context, + move |txn: &sea_orm::DatabaseTransaction| { + Box::pin(async move { + // This is the crucial step: move the original branch from its old position + // to the final replayed commit. + Branch::update_branch_with_conn(txn, &branch_name_cloned, &final_commit_id.to_string(), None).await; + + // Also, re-attach HEAD to the newly moved branch. + Head::update_with_conn(txn, Head::Branch(branch_name_cloned.clone()), None).await; + Ok(()) + }) + }, + true, + ).await + { + eprintln!("fatal: failed to finalize rebase: {e}"); + // Attempt to restore HEAD to a safe state + Head::update_with_conn(db, Head::Detached(upstream_id), None).await; + } // Reset the working directory and index to match the final state // This ensures that the workspace reflects the rebased commits @@ -144,8 +210,6 @@ pub async fn execute(args: RebaseArgs) { 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 diff --git a/libra/src/command/reflog.rs b/libra/src/command/reflog.rs new file mode 100644 index 000000000..4d2bfd8cb --- /dev/null +++ b/libra/src/command/reflog.rs @@ -0,0 +1,285 @@ +use crate::command::{load_object, HEAD}; +use crate::internal::db::get_db_conn_instance; +use crate::internal::model::reflog::Model; +use crate::internal::reflog::{Reflog, ReflogError}; +use crate::internal::config; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use mercury::hash::SHA1; +use mercury::internal::object::commit::Commit; +use sea_orm::sqlx::types::chrono; +use sea_orm::{ConnectionTrait, DbBackend, Statement, TransactionTrait}; +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::str::FromStr; + +#[derive(Parser, Debug)] +pub struct ReflogArgs { + #[clap(subcommand)] + command: Subcommands, +} + +#[derive(Subcommand, Debug, Clone)] +enum Subcommands { + /// show reflog records. + Show { + #[clap(default_value = "HEAD")] + ref_name: String, + #[arg(long = "pretty")] + #[clap(default_value_t = FormatterKind::default())] + pretty: FormatterKind, + }, + /// clear the reflog record of the specified branch. + Delete { + #[clap(required = true, num_args = 1..)] + selectors: Vec, + }, + /// check whether a reference has a reflog record, usually using by automatic scripts. + Exists { + #[clap(required = true)] + ref_name: String, + } +} + +pub async fn execute(args: ReflogArgs) { + match args.command { + Subcommands::Show { ref_name, pretty } => handle_show(&ref_name, pretty).await, + Subcommands::Delete { selectors } => handle_delete(&selectors).await, + Subcommands::Exists { ref_name } => handle_exists(&ref_name).await, + } +} + +async fn handle_show(ref_name: &str, pretty: FormatterKind) { + let db = get_db_conn_instance().await; + + let ref_name = parse_ref_name(ref_name).await; + let logs = match Reflog::find_all(db, &ref_name).await { + Ok(logs) => logs, + Err(e) => { + eprintln!("fatal: failed to get reflog entries: {e}"); + return; + } + }; + + let formatter = ReflogFormatter { + logs: &logs, + kind: pretty, + }; + + #[cfg(unix)] + let mut less = Command::new("less") // create a pipe to less + .arg("-R") // raw control characters + .arg("-F") + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .spawn() + .expect("failed to execute process"); + + #[cfg(unix)] + if let Some(ref mut stdin) = less.stdin { + writeln!(stdin, "{formatter}").expect("fatal: failed to write to stdin"); + } else { + eprintln!("Failed to capture stdin"); + } + + #[cfg(unix)] + let _ = less.wait().expect("failed to wait on child"); + + #[cfg(not(unix))] + println!("{formatter}") +} + +// `partial_ref_name` is the branch name entered by the user. +async fn parse_ref_name(partial_ref_name: &str) -> String { + if partial_ref_name == HEAD { + return HEAD.to_string(); + } + if !partial_ref_name.contains("/") { + return format!("refs/heads/{partial_ref_name}"); + } + let (ref_name, _) = partial_ref_name.split_once("/").unwrap(); + if config::Config::get("remote", Some(ref_name), "url").await.is_some() { + return format!("refs/remotes/{partial_ref_name}"); + } + format!("refs/heads/{partial_ref_name}") +} + +async fn handle_exists(ref_name: &str) { + let db = get_db_conn_instance().await; + let log = Reflog::find_one(db, ref_name) + .await + .expect("fatal: failed to get reflog entry"); + match log { + Some(_) => {} + None => std::process::exit(1), + } +} + +async fn handle_delete(selectors: &[String]) { + let mut groups = HashMap::new(); + for selector in selectors { + if let Some(parsed) = parse_reflog_selector(selector) { + groups + .entry(parsed.0.to_string()) + .or_insert_with(Vec::new) + .push(parsed); + continue; + } + eprintln!("fatal: invalid reflog entry format: {selector}"); + return; + } + + let groups = groups + .into_values() + .map(|mut group| { + group.sort_by(|a, b| b.1.cmp(&a.1)); + group + }) + .collect::>(); + for group in groups { + delete_single_group(&group).await; + } +} + +async fn delete_single_group(group: &[(&str, usize)]) { + let db = get_db_conn_instance().await; + // clone this to move it into async block to make compiler happy :( + let group = group + .iter() + .map(|(s, i)| ((*s).to_string(), *i)) + .collect::>(); + + db.transaction(|txn| Box::pin(async move { + let ref_name = &group[0].0; + let logs = Reflog::find_all(txn, ref_name).await?; + + for (_, index) in &group { + if let Some(entry) = logs.get(*index) { + let id = entry.id; + txn.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM reflog WHERE id = ?;", + [id.into()], + )).await?; + continue; + } + eprintln!("fatal: reflog entry `{ref_name}@{{{index}}}` not found") + } + + Ok::<_, ReflogError>(()) + })).await.expect("fatal: failed to delete reflog entries") +} + +fn parse_reflog_selector(selector: &str) -> Option<(&str, usize)> { + if let (Some(at_brace), Some(end_brace)) = (selector.find("@{"), selector.find('}')) { + if at_brace < end_brace { + let ref_name = &selector[..at_brace]; + let index_str = &selector[at_brace + 2..end_brace]; + + if let Ok(index) = index_str.parse::() { + return Some((ref_name, index)) + } + } + } + None +} + +#[derive(Debug, Copy, Clone)] +enum FormatterKind { + Oneline, + Short, + Medium, + Full, +} + +impl Display for FormatterKind { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Oneline => f.write_str("oneline"), + Self::Short => f.write_str("short"), + Self::Medium => f.write_str("medium"), + Self::Full => f.write_str("full"), + } + } +} + +impl Default for FormatterKind { + fn default() -> Self { + Self::Oneline + } +} + +impl From for FormatterKind { + fn from(value: String) -> Self { + match value.as_str() { + "oneline" => FormatterKind::Oneline, + "short" => FormatterKind::Short, + "medium" => FormatterKind::Medium, + "full" => FormatterKind::Full, + _ => FormatterKind::Oneline, + } + } +} + + +struct ReflogFormatter<'a> { + logs: &'a Vec, + kind: FormatterKind, +} + +impl<'a> Display for ReflogFormatter<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let all = self.logs + .iter() + .enumerate() + .map(|(idx, log)| { + let head = format!("HEAD@{{{}}}", idx); + let new_oid = &log.new_oid[..7]; + + let commit = find_commit(&log.new_oid); + let full_msg = format!("{}: {}", log.action, log.message); + + let author = format!("{} <{}>", commit.author.name, commit.author.email); + let committer = format!("{} <{}>", log.committer_name, log.committer_email); + let commit_msg = &commit.message.trim(); + let datetime = format_datetime(log.timestamp); + + match self.kind { + FormatterKind::Oneline => format!( + "{} {head}: {full_msg}", + new_oid.to_string().bright_magenta(), + ), + FormatterKind::Short => format!( + "{}\nReflog: {head} ({author})\nReflog message: {full_msg}\nAuthor: {author}\n\n {commit_msg}\n", + format!("commit {new_oid}").bright_magenta(), + ), + FormatterKind::Medium => format!( + "{}\nReflog: {head} ({author})\nReflog message: {full_msg}\nAuthor: {author}\nDate: {datetime}\n\n {commit_msg}\n", + format!("commit {new_oid}").bright_magenta(), + ), + FormatterKind::Full => format!( + "{}\nReflog: {head} ({author})\nReflog message: {full_msg}\nAuthor: {author}\nCommit: {committer}\n\n {commit_msg}\n", + format!("commit {new_oid}").bright_magenta(), + ), + } + }) + .collect::>() + .join("\n"); + writeln!(f, "{all}") + } +} + +fn find_commit(commit_hash: &str) -> Commit { + let hash = SHA1::from_str(commit_hash).unwrap(); + load_object::(&hash).unwrap() +} + +fn format_datetime(timestamp: i64) -> String { + let naive = chrono::DateTime::from_timestamp(timestamp, 0).unwrap(); + let local = naive.with_timezone(&chrono::Local); + + let git_format = "%a %b %d %H:%M:%S %Y %z"; + local.format(git_format).to_string() +} \ No newline at end of file diff --git a/libra/src/command/reset.rs b/libra/src/command/reset.rs index 9a4d18e34..d93065c02 100644 --- a/libra/src/command/reset.rs +++ b/libra/src/command/reset.rs @@ -12,6 +12,8 @@ use std::collections::HashSet; use std::fs; use std::path::Path; use std::path::PathBuf; +use crate::internal::db::get_db_conn_instance; +use crate::internal::reflog::{with_reflog, ReflogAction, ReflogContext}; #[derive(Parser, Debug)] pub struct ResetArgs { @@ -78,7 +80,7 @@ pub async fn execute(args: ResetArgs) { }; // Perform reset based on mode - match perform_reset(target_commit_id, mode).await { + match perform_reset(target_commit_id, mode, &args.target).await { Ok(_) => { println!( "HEAD is now at {} {}", @@ -169,24 +171,56 @@ async fn reset_pathspecs(pathspecs: &[String], target: &str) { /// 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; - } +async fn perform_reset( + target_commit_id: SHA1, + mode: ResetMode, + target_ref_str: &str, // e.g, "HEAD~2" +) -> Result<(), String> { + // avoids holding the transaction open while doing read-only preparations. + let db = get_db_conn_instance().await; + let old_oid = Head::current_commit_with_conn(db) + .await + .ok_or_else(|| "Cannot reset: HEAD is unborn and points to no commit.".to_string())?; + + if old_oid == target_commit_id { + println!("HEAD already at {}, nothing to do.", &target_commit_id.to_string()[..7]); + return Ok(()); } + // determine if HEAD is attached to a branch or detached. This is crucial for + // deciding which reference pointer to update in the transaction. + let current_head_state = Head::current_with_conn(db).await; + + let action = ReflogAction::Reset { target: target_ref_str.to_string() }; + let context = ReflogContext { + old_oid: old_oid.to_string(), + new_oid: target_commit_id.to_string(), + action, + }; + + with_reflog( + context, + move |txn| { + Box::pin(async move { + match ¤t_head_state { + // If on a branch, update the branch pointer. HEAD will move with it. + Head::Branch(branch_name) => { + Branch::update_branch_with_conn(txn, branch_name, &target_commit_id.to_string(), None).await; + } + // If in a detached state, update the HEAD pointer directly. + Head::Detached(_) => { + let new_head = Head::Detached(target_commit_id); + Head::update_with_conn(txn, new_head, None).await; + } + } + Ok(()) + }) + }, + true, + ) + .await + .map_err(|e| e.to_string())?; + match mode { ResetMode::Soft => { // Only move HEAD, nothing else to do @@ -198,10 +232,9 @@ async fn perform_reset(target_commit_id: SHA1, mode: ResetMode) -> Result<(), St 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?; + reset_working_directory_to_commit(&target_commit_id, Some(old_oid)).await?; } } - Ok(()) } diff --git a/libra/src/command/switch.rs b/libra/src/command/switch.rs index 8addf6624..cc8712b92 100644 --- a/libra/src/command/switch.rs +++ b/libra/src/command/switch.rs @@ -6,7 +6,8 @@ use crate::{ internal::{branch::Branch, head::Head}, utils::util::{self, get_commit_base}, }; - +use crate::internal::db::get_db_conn_instance; +use crate::internal::reflog::{with_reflog, ReflogAction, ReflogContext}; use super::{ restore::{self, RestoreArgs}, status, @@ -72,28 +73,104 @@ pub async fn check_status() -> bool { /// change the working directory to the version of commit_hash async fn switch_to_commit(commit_hash: SHA1) { + let db = get_db_conn_instance().await; + + let old_head_commit = Head::current_commit_with_conn(db) + .await + .expect("Cannot switch: HEAD is unborn."); + + let from_ref_name = match Head::current_with_conn(db).await { + Head::Branch(name) => name, + Head::Detached(hash) => hash.to_string()[..7].to_string(), // Use short hash for detached HEAD + }; + + let action = ReflogAction::Switch { + from: from_ref_name, + to: commit_hash.to_string()[..7].to_string(), // Use short hash for target commit + }; + let context = ReflogContext { + old_oid: old_head_commit.to_string(), + new_oid: commit_hash.to_string(), + action, + }; + + if let Err(e) = with_reflog( + context, + move |txn: &sea_orm::DatabaseTransaction| { + Box::pin(async move { + let new_head = Head::Detached(commit_hash); + Head::update_with_conn(txn, new_head, None).await; + Ok(()) + }) + }, + false).await { + eprintln!("fatal: {}", e); + return; + }; + + // Only restore the working directory *after* HEAD has been successfully updated. restore_to_commit(commit_hash).await; - // update HEAD - let head = Head::Detached(commit_hash); - Head::update(head, None).await; + println!("HEAD is now at {}", &commit_hash.to_string()[..7]); } async fn switch_to_branch(branch_name: String) { - let target_branch = Branch::find_branch(&branch_name, None).await; - if target_branch.is_none() { - if !Branch::search_branch(&branch_name).await.is_empty() { - eprintln!("fatal: a branch is expected, got remote branch {branch_name}"); - } else { - eprintln!("fatal: branch '{}' not found", &branch_name); + let db = get_db_conn_instance().await; + + let target_branch = match Branch::find_branch_with_conn(db, &branch_name, None).await { + Some(b) => b, + None => { + if !Branch::search_branch(&branch_name).await.is_empty() { + eprintln!("fatal: a branch is expected, got remote branch {branch_name}"); + } else { + eprintln!("fatal: branch '{}' not found", &branch_name); + } + return; } + }; + let target_commit_id = target_branch.commit; + + let old_head_commit = Head::current_commit_with_conn(db) + .await + .expect("Cannot switch: HEAD is unborn."); + + let from_ref_name = match Head::current_with_conn(db).await { + Head::Branch(name) => name, + Head::Detached(hash) => hash.to_string()[..7].to_string(), + }; + + if from_ref_name == branch_name { + println!("Already on '{}'", branch_name); return; } - let commit_id = target_branch.unwrap().commit; - restore_to_commit(commit_id).await; - // update HEAD - // let mut head: ActiveModel = reference::Model::current_head(db).await.unwrap().into(); - let head = Head::Branch(branch_name); - Head::update(head, None).await; + + let action = ReflogAction::Switch { + from: from_ref_name, + to: branch_name.clone(), + }; + let context = ReflogContext { + old_oid: old_head_commit.to_string(), + new_oid: target_commit_id.to_string(), + action, + }; + + + // `log_for_branch` is `false`. This is the key insight for `switch`/`checkout`. + if let Err(e) = with_reflog( + context, + move |txn: &sea_orm::DatabaseTransaction| { + Box::pin(async move { + let new_head = Head::Branch(branch_name.clone()); + Head::update_with_conn(txn, new_head, None).await; + Ok(()) + }) + }, + false).await { + eprintln!("fatal: {}", e); + return; + } + + restore_to_commit(target_commit_id).await; + println!("Switched to branch '{}'", target_branch.name); } async fn restore_to_commit(commit_id: SHA1) { diff --git a/libra/src/internal/branch.rs b/libra/src/internal/branch.rs index 21b2a38e7..c916d812a 100644 --- a/libra/src/internal/branch.rs +++ b/libra/src/internal/branch.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use sea_orm::ActiveModelTrait; +use sea_orm::{ActiveModelTrait, ConnectionTrait}; use sea_orm::ActiveValue::Set; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; @@ -16,8 +16,11 @@ pub struct Branch { pub remote: Option, } -async fn query_reference(branch_name: &str, remote: Option<&str>) -> Option { - let db_conn = get_db_conn_instance().await; +// `_with_conn` version of the helper function +async fn query_reference_with_conn(db: &C, branch_name: &str, remote: Option<&str>) -> Option +where + C: ConnectionTrait, +{ reference::Entity::find() .filter(reference::Column::Name.eq(branch_name)) .filter(reference::Column::Kind.eq(reference::ConfigKind::Branch)) @@ -25,23 +28,46 @@ async fn query_reference(branch_name: &str, remote: Option<&str>) -> Option reference::Column::Remote.eq(remote), None => reference::Column::Remote.is_null(), }) - .one(db_conn) + .one(db) .await .unwrap() } +/* + * ================================================================================= + * NOTE: Transaction Safety Pattern (`_with_conn`) + * ================================================================================= + * + * This module follows the `_with_conn` pattern for transaction safety. + * + * - Public functions (e.g., `find_branch`, `update_branch`) acquire a new database + * connection from the pool and are suitable for single, non-transactional operations. + * + * - `*_with_conn` variants (e.g., `find_branch_with_conn`, `update_branch_with_conn`) + * accept an existing connection or transaction handle (`&C where C: ConnectionTrait`). + * + * **WARNING**: To use these functions within a database transaction (e.g., inside + * a `db.transaction(|txn| { ... })` block), you MUST call the `*_with_conn` + * variant, passing the transaction handle `txn`. Calling a public version from + * inside a transaction will try to acquire a second connection from the pool, + * leading to a deadlock. + * + * Correct Usage (in a transaction): `Branch::update_branch_with_conn(txn, ...).await;` + * Incorrect Usage (in a transaction): `Branch::update_branch(...).await;` // DEADLOCK! + */ impl Branch { - /// list all remote branches - pub async fn list_branches(remote: Option<&str>) -> Vec { - let db_conn = get_db_conn_instance().await; - + // `_with_conn` version for `list_branches` + pub async fn list_branches_with_conn(db: &C, remote: Option<&str>) -> Vec + where + C: ConnectionTrait, + { let branches = reference::Entity::find() .filter(reference::Column::Kind.eq(reference::ConfigKind::Branch)) .filter(match remote { Some(remote) => reference::Column::Remote.eq(remote), None => reference::Column::Remote.is_null(), }) - .all(db_conn) + .all(db) .await .unwrap(); @@ -55,15 +81,33 @@ impl Branch { .collect() } + /// list all remote branches + pub async fn list_branches(remote: Option<&str>) -> Vec { + let db_conn = get_db_conn_instance().await; + Self::list_branches_with_conn(db_conn, remote).await + } + + // `_with_conn` version for `exists` + pub async fn exists_with_conn(db: &C, branch_name: &str) -> bool + where + C: ConnectionTrait, + { + let branch = Self::find_branch_with_conn(db, branch_name, None).await; + branch.is_some() + } + /// is the branch exists pub async fn exists(branch_name: &str) -> bool { - let branch = Self::find_branch(branch_name, None).await; - branch.is_some() + let db_conn = get_db_conn_instance().await; + Self::exists_with_conn(db_conn, branch_name).await } - /// get the branch by name - pub async fn find_branch(branch_name: &str, remote: Option<&str>) -> Option { - let branch = query_reference(branch_name, remote).await; + // `_with_conn` version for `find_branch` + pub async fn find_branch_with_conn(db: &C, branch_name: &str, remote: Option<&str>) -> Option + where + C: ConnectionTrait, + { + let branch = query_reference_with_conn(db, branch_name, remote).await; match branch { Some(branch) => Some(Branch { name: branch.name.as_ref().unwrap().clone(), @@ -74,25 +118,33 @@ impl Branch { } } - /// search branch with full name, return vec of branches - /// e.g. `origin/sub/master/feature` may means `origin/sub/master` + `feature` or `origin/sub` + `master/feature` - /// so we need to search all possible branches - pub async fn search_branch(branch_name: &str) -> Vec { - let mut branch_name = branch_name.to_string(); + /// get the branch by name + pub async fn find_branch(branch_name: &str, remote: Option<&str>) -> Option { + let db_conn = get_db_conn_instance().await; + Self::find_branch_with_conn(db_conn, branch_name, remote).await + } + + // `_with_conn` version for `search_branch` + pub async fn search_branch_with_conn(db: &C, branch_name: &str) -> Vec + where + C: ConnectionTrait, + { + let mut branch_name_str = branch_name.to_string(); let mut remote = String::new(); let mut branches = vec![]; - if let Some(branch) = Self::find_branch(&branch_name, None).await { + if let Some(branch) = Self::find_branch_with_conn(db, &branch_name_str, None).await { branches.push(branch) } - while let Some(index) = branch_name.find('/') { + while let Some(index) = branch_name_str.find('/') { if !remote.is_empty() { remote += "/"; } - remote += branch_name.get(..index).unwrap(); - branch_name = branch_name.get(index + 1..).unwrap().to_string(); - let branch = Self::find_branch(&branch_name, Some(&remote)).await; + remote += branch_name_str.get(..index).unwrap(); + branch_name_str = branch_name_str.get(index + 1..).unwrap().to_string(); + // Important: Call the `_with_conn` variant inside the loop + let branch = Self::find_branch_with_conn(db, &branch_name_str, Some(&remote)).await; if let Some(branch) = branch { branches.push(branch); } @@ -100,16 +152,26 @@ impl Branch { branches } - pub async fn update_branch(branch_name: &str, commit_hash: &str, remote: Option<&str>) { + /// search branch with full name, return vec of branches + /// e.g. `origin/sub/master/feature` may means `origin/sub/master` + `feature` or `origin/sub` + `master/feature` + /// so we need to search all possible branches + pub async fn search_branch(branch_name: &str) -> Vec { let db_conn = get_db_conn_instance().await; - // check if branch exists - let branch = query_reference(branch_name, remote).await; + Self::search_branch_with_conn(db_conn, branch_name).await + } + + // `_with_conn` version for `update_branch` + pub async fn update_branch_with_conn(db: &C, branch_name: &str, commit_hash: &str, remote: Option<&str>) + where + C: ConnectionTrait, + { + let branch = query_reference_with_conn(db, branch_name, remote).await; match branch { Some(branch) => { let mut branch: reference::ActiveModel = branch.into(); branch.commit = Set(Some(commit_hash.to_owned())); - branch.update(db_conn).await.unwrap(); + branch.update(db).await.unwrap(); } None => { reference::ActiveModel { @@ -119,18 +181,31 @@ impl Branch { remote: Set(remote.map(|s| s.to_owned())), ..Default::default() } - .insert(db_conn) - .await - .unwrap(); + .insert(db) + .await + .unwrap(); } } } - pub async fn delete_branch(branch_name: &str, remote: Option<&str>) { + pub async fn update_branch(branch_name: &str, commit_hash: &str, remote: Option<&str>) { let db_conn = get_db_conn_instance().await; + Self::update_branch_with_conn(db_conn, branch_name, commit_hash, remote).await + } + + // `_with_conn` version for `delete_branch` + pub async fn delete_branch_with_conn(db: &C, branch_name: &str, remote: Option<&str>) + where + C: ConnectionTrait, + { let branch: reference::ActiveModel = - query_reference(branch_name, remote).await.unwrap().into(); - branch.delete(db_conn).await.unwrap(); + query_reference_with_conn(db, branch_name, remote).await.unwrap().into(); + branch.delete(db).await.unwrap(); + } + + pub async fn delete_branch(branch_name: &str, remote: Option<&str>) { + let db_conn = get_db_conn_instance().await; + Self::delete_branch_with_conn(db_conn, branch_name, remote).await } } diff --git a/libra/src/internal/config.rs b/libra/src/internal/config.rs index e0992663b..5cae82f86 100644 --- a/libra/src/internal/config.rs +++ b/libra/src/internal/config.rs @@ -1,15 +1,15 @@ use std::collections::HashSet; use std::mem::swap; +use sea_orm::entity::ActiveModelTrait; use sea_orm::ActiveValue::Set; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; +use sea_orm::{ + ColumnTrait, ConnectionTrait, EntityTrait, ModelTrait, QueryFilter, +}; use crate::internal::db::get_db_conn_instance; use crate::internal::head::Head; -use crate::internal::model::config; -use crate::internal::model::config::Model; - -use super::model::config::ActiveModel; +use crate::internal::model::config::{self, ActiveModel, Model}; pub struct Config; @@ -24,10 +24,37 @@ pub struct BranchConfig { pub remote: String, } +/* + * ================================================================================= + * NOTE: Transaction Safety Pattern (`_with_conn`) + * ================================================================================= + * + * This module follows the `_with_conn` pattern for transaction safety. + * + * - Public functions (e.g., `get`, `update`) acquire a new database + * connection from the pool and are suitable for single, non-transactional operations. + * + * - `*_with_conn` variants (e.g., `get_with_conn`, `update_with_conn`) + * accept an existing connection or transaction handle (`&C where C: ConnectionTrait`). + * + * **WARNING**: To use these functions within a database transaction (e.g., inside + * a `db.transaction(|txn| { ... })` block), you MUST call the `*_with_conn` + * variant, passing the transaction handle `txn`. Calling a public version from + * inside a transaction will try to acquire a second connection from the pool, + * leading to a deadlock. + * + * Correct Usage (in a transaction): `Config::update_with_conn(txn, ...).await;` + * Incorrect Usage (in a transaction): `Config::update(...).await;` // DEADLOCK! + */ impl Config { - // todo accept a db connect or a transaction from outside - pub async fn insert(configuration: &str, name: Option<&str>, key: &str, value: &str) { - let db = get_db_conn_instance().await; + // _with_conn version for insert + pub async fn insert_with_conn( + db: &C, + configuration: &str, + name: Option<&str>, + key: &str, + value: &str, + ) { let config = ActiveModel { configuration: Set(configuration.to_owned()), name: Set(name.map(|s| s.to_owned())), @@ -38,9 +65,14 @@ impl Config { config.save(db).await.unwrap(); } - // Update one configuration entry in database using given configuration, name, key and value - pub async fn update(configuration: &str, name: Option<&str>, key: &str, value: &str) -> Model { - let db = get_db_conn_instance().await; + // _with_conn version for update + pub async fn update_with_conn( + db: &C, + configuration: &str, + name: Option<&str>, + key: &str, + value: &str, + ) -> Model { let mut config: ActiveModel = config::Entity::find() .filter(config::Column::Configuration.eq(configuration)) .filter(match name { @@ -57,8 +89,13 @@ impl Config { config.update(db).await.unwrap() } - async fn query(configuration: &str, name: Option<&str>, key: &str) -> Vec { - let db = get_db_conn_instance().await; + // _with_conn version for query + async fn query_with_conn( + db: &C, + configuration: &str, + name: Option<&str>, + key: &str, + ) -> Vec { config::Entity::find() .filter(config::Column::Configuration.eq(configuration)) .filter(match name { @@ -71,24 +108,28 @@ impl Config { .unwrap() } - /// Get one configuration value - pub async fn get(configuration: &str, name: Option<&str>, key: &str) -> Option { - let values = Self::query(configuration, name, key).await; + // _with_conn version for get + pub async fn get_with_conn( + db: &C, + configuration: &str, + name: Option<&str>, + key: &str, + ) -> Option { + let values = Self::query_with_conn(db, configuration, name, key).await; values.first().map(|c| c.value.to_owned()) } - /// Get remote repo name by branch name - /// - You may need to `[branch::set-upstream]` if return `None` - pub async fn get_remote(branch: &str) -> Option { - // e.g. [branch "master"].remote = origin - Config::get("branch", Some(branch), "remote").await + // _with_conn version for get_remote + pub async fn get_remote_with_conn(db: &C, branch: &str) -> Option { + Config::get_with_conn(db, "branch", Some(branch), "remote").await } - /// Get remote repo name of current branch - /// - `Error` if `HEAD` is detached - pub async fn get_current_remote() -> Result, ()> { - match Head::current().await { - Head::Branch(name) => Ok(Config::get_remote(&name).await), + // _with_conn version for get_current_remote + pub async fn get_current_remote_with_conn( + db: &C, + ) -> Result, ()> { + match Head::current_with_conn(db).await { + Head::Branch(name) => Ok(Config::get_remote_with_conn(db, &name).await), Head::Detached(_) => { eprintln!("fatal: HEAD is detached, cannot get remote"); Err(()) @@ -96,34 +137,38 @@ impl Config { } } - pub async fn get_remote_url(remote: &str) -> String { - match Config::get("remote", Some(remote), "url").await { + // _with_conn version for get_remote_url + pub async fn get_remote_url_with_conn(db: &C, remote: &str) -> String { + match Config::get_with_conn(db, "remote", Some(remote), "url").await { Some(url) => url, None => panic!("fatal: No URL configured for remote '{remote}'."), } } - /// return `None` if no remote is set - pub async fn get_current_remote_url() -> Option { - match Config::get_current_remote().await.unwrap() { - Some(remote) => Some(Config::get_remote_url(&remote).await), + // _with_conn version for get_current_remote_url + pub async fn get_current_remote_url_with_conn(db: &C) -> Option { + match Config::get_current_remote_with_conn(db).await.unwrap() { + Some(remote) => Some(Config::get_remote_url_with_conn(db, &remote).await), None => None, } } - /// Get all configuration values - /// - e.g. remote.origin.url can be multiple - pub async fn get_all(configuration: &str, name: Option<&str>, key: &str) -> Vec { - Self::query(configuration, name, key) + // _with_conn version for get_all + pub async fn get_all_with_conn( + db: &C, + configuration: &str, + name: Option<&str>, + key: &str, + ) -> Vec { + Self::query_with_conn(db, configuration, name, key) .await .iter() .map(|c| c.value.to_owned()) .collect() } - /// Get literally all the entries in database without any filtering - pub async fn list_all() -> Vec<(String, String)> { - let db = get_db_conn_instance().await; + // _with_conn version for list_all + pub async fn list_all_with_conn(db: &C) -> Vec<(String, String)> { config::Entity::find() .all(db) .await @@ -141,16 +186,16 @@ impl Config { .collect() } - /// Delete one or all configuration using given key and value pattern - pub async fn remove_config( + // _with_conn version for remove_config + pub async fn remove_config_with_conn( + db: &C, configuration: &str, name: Option<&str>, key: &str, valuepattern: Option<&str>, delete_all: bool, ) { - let db = get_db_conn_instance().await; - let entries: Vec = Self::query(configuration, name, key).await; + let entries: Vec = Self::query_with_conn(db, configuration, name, key).await; for e in entries { let _res = match valuepattern { Some(vp) => { @@ -168,12 +213,11 @@ impl Config { } } - /// Delete all the configuration entries using given configuration field (--remove-section) - // pub async fn remove_by_section(configuration: &str) { - // unimplemented!(); - // } - pub async fn remove_remote(name: &str) -> Result<(), String> { - let db = get_db_conn_instance().await; + // _with_conn version for remove_remote + pub async fn remove_remote_with_conn( + db: &C, + name: &str, + ) -> Result<(), String> { let remote = config::Entity::find() .filter(config::Column::Configuration.eq("remote")) .filter(config::Column::Name.eq(name)) @@ -190,8 +234,8 @@ impl Config { Ok(()) } - pub async fn all_remote_configs() -> Vec { - let db = get_db_conn_instance().await; + // _with_conn version for all_remote_configs + pub async fn all_remote_configs_with_conn(db: &C) -> Vec { let remotes = config::Entity::find() .filter(config::Column::Configuration.eq("remote")) .all(db) @@ -219,8 +263,11 @@ impl Config { .collect() } - pub async fn remote_config(name: &str) -> Option { - let db = get_db_conn_instance().await; + // _with_conn version for remote_config + pub async fn remote_config_with_conn( + db: &C, + name: &str, + ) -> Option { let remote = config::Entity::find() .filter(config::Column::Configuration.eq("remote")) .filter(config::Column::Name.eq(name)) @@ -233,8 +280,11 @@ impl Config { }) } - pub async fn branch_config(name: &str) -> Option { - let db = get_db_conn_instance().await; + // _with_conn version for branch_config + pub async fn branch_config_with_conn( + db: &C, + name: &str, + ) -> Option { let config_entries = config::Entity::find() .filter(config::Column::Configuration.eq("branch")) .filter(config::Column::Name.eq(name)) @@ -271,4 +321,95 @@ impl Config { Some(branch_config) } } -} + + pub async fn insert(configuration: &str, name: Option<&str>, key: &str, value: &str) { + let db = get_db_conn_instance().await; + Self::insert_with_conn(db, configuration, name, key, value).await; + } + + // Update one configuration entry in database using given configuration, name, key and value + pub async fn update(configuration: &str, name: Option<&str>, key: &str, value: &str) -> Model { + let db = get_db_conn_instance().await; + Self::update_with_conn(db, configuration, name, key, value).await + } + + /// Get one configuration value + pub async fn get(configuration: &str, name: Option<&str>, key: &str) -> Option { + let db = get_db_conn_instance().await; + Self::get_with_conn(db, configuration, name, key).await + } + + /// Get remote repo name by branch name + /// - You may need to `[branch::set-upstream]` if return `None` + pub async fn get_remote(branch: &str) -> Option { + let db = get_db_conn_instance().await; + Self::get_remote_with_conn(db, branch).await + } + + /// Get remote repo name of current branch + /// - `Error` if `HEAD` is detached + pub async fn get_current_remote() -> Result, ()> { + let db = get_db_conn_instance().await; + Self::get_current_remote_with_conn(db).await + } + + pub async fn get_remote_url(remote: &str) -> String { + let db = get_db_conn_instance().await; + Self::get_remote_url_with_conn(db, remote).await + } + + /// return `None` if no remote is set + pub async fn get_current_remote_url() -> Option { + let db = get_db_conn_instance().await; + Self::get_current_remote_url_with_conn(db).await + } + + /// Get all configuration values + /// - e.g. remote.origin.url can be multiple + pub async fn get_all(configuration: &str, name: Option<&str>, key: &str) -> Vec { + let db = get_db_conn_instance().await; + Self::get_all_with_conn(db, configuration, name, key).await + } + + /// Get literally all the entries in database without any filtering + pub async fn list_all() -> Vec<(String, String)> { + let db = get_db_conn_instance().await; + Self::list_all_with_conn(db).await + } + + /// Delete one or all configuration using given key and value pattern + pub async fn remove_config( + configuration: &str, + name: Option<&str>, + key: &str, + valuepattern: Option<&str>, + delete_all: bool, + ) { + let db = get_db_conn_instance().await; + Self::remove_config_with_conn(db, configuration, name, key, valuepattern, delete_all).await; + } + + /// Delete all the configuration entries using given configuration field (--remove-section) + // pub async fn remove_by_section(configuration: &str) { + // unimplemented!(); + // } + pub async fn remove_remote(name: &str) -> Result<(), String> { + let db = get_db_conn_instance().await; + Self::remove_remote_with_conn(db, name).await + } + + pub async fn all_remote_configs() -> Vec { + let db = get_db_conn_instance().await; + Self::all_remote_configs_with_conn(db).await + } + + pub async fn remote_config(name: &str) -> Option { + let db = get_db_conn_instance().await; + Self::remote_config_with_conn(db, name).await + } + + pub async fn branch_config(name: &str) -> Option { + let db = get_db_conn_instance().await; + Self::branch_config_with_conn(db, name).await + } +} \ No newline at end of file diff --git a/libra/src/internal/head.rs b/libra/src/internal/head.rs index 422eb66f3..2c19c4d46 100644 --- a/libra/src/internal/head.rs +++ b/libra/src/internal/head.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +use sea_orm::ConnectionTrait; use sea_orm::ActiveValue::Set; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; @@ -15,73 +16,121 @@ pub enum Head { Branch(String), } +/* + * ================================================================================= + * NOTE: Transaction Safety Pattern (`_with_conn`) + * ================================================================================= + * + * This module follows the `_with_conn` pattern for transaction safety. + * + * - Public functions (e.g., `get`, `update`) acquire a new database + * connection from the pool and are suitable for single, non-transactional operations. + * + * - `*_with_conn` variants (e.g., `get_with_conn`, `update_with_conn`) + * accept an existing connection or transaction handle (`&C where C: ConnectionTrait`). + * + * **WARNING**: To use these functions within a database transaction (e.g., inside + * a `db.transaction(|txn| { ... })` block), you MUST call the `*_with_conn` + * variant, passing the transaction handle `txn`. Calling a public version from + * inside a transaction will try to acquire a second connection from the pool, + * leading to a deadlock. + * + * Correct Usage (in a transaction): `Head::update_with_conn(txn, ...).await;` + * Incorrect Usage (in a transaction): `Head::update(...).await;` // DEADLOCK! + */ + impl Head { - async fn query_local_head() -> reference::Model { - let db_conn = get_db_conn_instance().await; + async fn query_local_head_with_conn(db: &C) -> reference::Model + where + C: ConnectionTrait, + { reference::Entity::find() .filter(reference::Column::Kind.eq(reference::ConfigKind::Head)) .filter(reference::Column::Remote.is_null()) - .one(db_conn) + .one(db) .await .unwrap() .expect("fatal: storage broken, HEAD not found") } - async fn query_remote_head(remote: &str) -> Option { - let db_conn = get_db_conn_instance().await; + async fn query_remote_head_with_conn(db: &C, remote: &str) -> Option + where + C: ConnectionTrait, + { reference::Entity::find() .filter(reference::Column::Kind.eq(reference::ConfigKind::Head)) .filter(reference::Column::Remote.eq(remote)) - .one(db_conn) + .one(db) .await .unwrap() } - pub async fn current() -> Head { - let head = Self::query_local_head().await; + pub async fn current_with_conn(db: &C) -> Head + where + C: ConnectionTrait, + { + let head = Self::query_local_head_with_conn(db).await; match head.name { Some(name) => Head::Branch(name), None => { - // detached head let commit_hash = head.commit.expect("detached head without commit"); Head::Detached(SHA1::from_str(commit_hash.as_str()).unwrap()) } } } - pub async fn remote_current(remote: &str) -> Option { - match Self::query_remote_head(remote).await { - Some(head) => match head.name { - Some(name) => Some(Head::Branch(name)), + pub async fn current() -> Head { + let db_conn = get_db_conn_instance().await; + Self::current_with_conn(db_conn).await + } + + pub async fn remote_current_with_conn(db: &C, remote: &str) -> Option + where + C: ConnectionTrait, + { + match Self::query_remote_head_with_conn(db, remote).await { + Some(head) => Some(match head.name { + Some(name) => Head::Branch(name), None => { let commit_hash = head.commit.expect("detached head without commit"); - Some(Head::Detached( - SHA1::from_str(commit_hash.as_str()).unwrap(), - )) + Head::Detached(SHA1::from_str(commit_hash.as_str()).unwrap()) } - }, + }), None => None, } } - /// get the commit hash of the current head, return `None` if no commit - pub async fn current_commit() -> Option { - match Self::current().await { + pub async fn remote_current(remote: &str) -> Option { + let db_conn = get_db_conn_instance().await; + Self::remote_current_with_conn(db_conn, remote).await + } + + pub async fn current_commit_with_conn(db: &C) -> Option + where + C: ConnectionTrait, + { + match Self::current_with_conn(db).await { Head::Detached(commit_hash) => Some(commit_hash), Head::Branch(name) => { - let branch = Branch::find_branch(&name, None).await; + let branch = Branch::find_branch_with_conn(db, &name, None).await; branch.map(|b| b.commit) } } } - // HEAD is unique, update if exists, insert if not - pub async fn update(new_head: Self, remote: Option<&str>) { + /// get the commit hash of current head, return `None` if no commit + pub async fn current_commit() -> Option { let db_conn = get_db_conn_instance().await; + Self::current_commit_with_conn(db_conn).await + } + pub async fn update_with_conn(db: &C, new_head: Self, remote: Option<&str>) + where + C: ConnectionTrait, + { let head = match remote { - Some(remote) => Self::query_remote_head(remote).await, - None => Some(Self::query_local_head().await), + Some(remote) => Self::query_remote_head_with_conn(db, remote).await, + None => Some(Self::query_local_head_with_conn(db).await), }; match head { Some(head) => { @@ -100,10 +149,9 @@ impl Head { head.commit = Set(None); } } - head.update(db_conn).await.unwrap(); + head.update(db).await.unwrap(); } None => { - // // insert let mut head = reference::ActiveModel { kind: Set(reference::ConfigKind::Head), ..Default::default() @@ -119,8 +167,14 @@ impl Head { head.name = Set(Some(branch_name)); } } - head.save(db_conn).await.unwrap(); + head.save(db).await.unwrap(); } } } -} + + // HEAD is unique, update if exists, insert if not + pub async fn update(new_head: Self, remote: Option<&str>) { + let db_conn = get_db_conn_instance().await; + Self::update_with_conn(db_conn, new_head, remote).await; + } +} \ No newline at end of file diff --git a/libra/src/internal/mod.rs b/libra/src/internal/mod.rs index 6e10f8ad5..0b9196aa7 100644 --- a/libra/src/internal/mod.rs +++ b/libra/src/internal/mod.rs @@ -4,3 +4,4 @@ pub mod db; pub mod head; pub mod model; pub mod protocol; +pub mod reflog; diff --git a/libra/src/internal/model/mod.rs b/libra/src/internal/model/mod.rs index 859d1a755..54ac06908 100644 --- a/libra/src/internal/model/mod.rs +++ b/libra/src/internal/model/mod.rs @@ -1,2 +1,3 @@ pub mod config; pub mod reference; +pub mod reflog; diff --git a/libra/src/internal/model/reflog.rs b/libra/src/internal/model/reflog.rs new file mode 100644 index 000000000..38f65cd98 --- /dev/null +++ b/libra/src/internal/model/reflog.rs @@ -0,0 +1,21 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "reflog")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = true)] + pub id: i64, + pub ref_name: String, + pub old_oid: String, + pub new_oid: String, + pub timestamp: i64, + pub committer_name: String, + pub committer_email: String, + pub action: String, + pub message: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/libra/src/internal/reflog.rs b/libra/src/internal/reflog.rs new file mode 100644 index 000000000..8ff6f1639 --- /dev/null +++ b/libra/src/internal/reflog.rs @@ -0,0 +1,336 @@ +use crate::internal::config; +use crate::internal::db::get_db_conn_instance; +use crate::internal::head::Head; +use crate::internal::model::reflog; +use crate::internal::model::reflog::{ActiveModel, Model}; +use sea_orm::{ActiveModelTrait, DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait}; +use sea_orm::{ColumnTrait, ConnectionTrait, DbBackend, DbErr, Statement, TransactionError}; +use std::fmt::{Debug, Display, Formatter}; +use std::future::Future; +use std::pin::Pin; +use std::time::{SystemTime, UNIX_EPOCH}; +use mercury::hash::SHA1; + +const HEAD: &str = "HEAD"; + +#[derive(Debug)] +pub struct ReflogContext { + pub old_oid: String, + pub new_oid: String, + pub action: ReflogAction, +} + +#[derive(Debug)] +pub enum ReflogError { + DatabaseError(DbErr), + TransactionError(TransactionError), +} + +impl Display for ReflogError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseError(e) => write!(f, "Database error: {}", e), + Self::TransactionError(e) => write!(f, "Transaction error: {}", e), + } + } +} + +impl From for ReflogError { + fn from(err: DbErr) -> Self { + ReflogError::DatabaseError(err) + } +} + +impl From> for ReflogError { + fn from(err: TransactionError) -> Self { + ReflogError::TransactionError(err) + } +} +impl Display for ReflogContext { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.action { + ReflogAction::Commit { message } => write!(f, "{}", message.lines().next().unwrap_or("(no commit message)")), + ReflogAction::Switch { from, to } => write!(f, "moving from {from} to {to}"), + ReflogAction::Checkout { from, to } => write!(f, "moving from {from} to {to}"), + ReflogAction::Reset { target } => write!(f, "moving to {target}"), + ReflogAction::Merge { branch, policy } => write!(f, "merge {branch}:{policy}"), + ReflogAction::CherryPick { source_message } => write!(f, "{}", source_message.trim().lines().next().unwrap_or("(no commit message)")), + ReflogAction::Fetch => write!(f, "fast-forward"), + ReflogAction::Pull => write!(f, "fast-forward"), + ReflogAction::Rebase { state, details } => write!(f, "({state}) {details}"), + ReflogAction::Clone { from } => write!(f, "from {from}"), + } + } +} + +#[derive(Debug)] +pub enum ReflogAction { + Commit { message: String }, + Reset { target: String }, + Checkout { from: String, to: String }, + Switch { from: String, to: String }, + Merge { branch: String, policy: String }, + CherryPick { source_message: String, }, + Rebase { state: String, details: String }, + Fetch, + Pull, + Clone { from: String }, +} + +#[derive(Copy, Clone)] +pub enum ReflogActionKind { + Commit, + Reset, + // we don't need `checkout` because we have `switch`, + Checkout, + Switch, + Merge, + CherryPick, + Rebase, + Fetch, + // pull is a combination of `fetch` and `merge`, maybe we don't need to do anything... + Pull, + Clone, +} + +impl Display for ReflogActionKind { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Commit => write!(f, "commit"), + Self::Reset => write!(f, "reset"), + Self::Checkout => write!(f, "checkout"), + Self::Switch => write!(f, "switch"), + Self::Merge => write!(f, "merge"), + Self::CherryPick => write!(f, "cherry-pick"), + Self::Rebase => write!(f, "rebase"), + Self::Fetch => write!(f, "fetch"), + Self::Pull => write!(f, "pull"), + Self::Clone => write!(f, "clone"), + } + } +} + +impl ReflogAction { + fn kind(&self) -> ReflogActionKind { + match self { + Self::Commit { .. } => ReflogActionKind::Commit, + Self::Reset { .. } => ReflogActionKind::Reset, + Self::Switch { .. } => ReflogActionKind::Switch, + Self::Merge { .. } => ReflogActionKind::Merge, + Self::Pull => ReflogActionKind::Pull, + Self::Clone { .. } => ReflogActionKind::Clone, + Self::CherryPick { .. } => ReflogActionKind::CherryPick, + Self::Rebase { .. } => ReflogActionKind::Rebase, + Self::Checkout { .. } => ReflogActionKind::Checkout, + Self::Fetch => ReflogActionKind::Fetch, + } + } +} + +pub struct Reflog; + +impl Reflog { + pub async fn insert_single_entry(db: &C, context: &ReflogContext, ref_to_log: &str) -> Result<(), ReflogError> { + // considering that there are many commands that have not yet used user configs, + // we just set default user info. + let name = config::Config::get_with_conn(db, "user", None, "name") + .await + .unwrap_or("mega".to_string()); + let email = config::Config::get_with_conn(db, "user", None, "email") + .await + .unwrap_or("admin@mega.org".to_string()); + let message = context.to_string(); + + let model = ActiveModel { + ref_name: Set(ref_to_log.to_string()), + old_oid: Set(context.old_oid.clone()), + new_oid: Set(context.new_oid.clone()), + action: Set(context.action.kind().to_string()), + committer_name: Set(name), + committer_email: Set(email), + timestamp: Set(timestamp_seconds()), + message: Set(message), + ..Default::default() + }; + + model.save(db).await?; + Ok(()) + } + + /// insert a reflog record. + /// see `ReflogContext` + pub async fn insert(db: &DatabaseTransaction, context: ReflogContext, insert_ref: bool) -> Result<(), ReflogError> { + ensure_reflog_table_exists(db).await?; + let head = Head::current_with_conn(db).await; + + Self::insert_single_entry(db, &context, HEAD).await?; + + if let Head::Branch(branch_name) = head { + if insert_ref { + let full_branch_ref = format!("refs/heads/{}", branch_name); + Self::insert_single_entry(db, &context, &full_branch_ref).await?; + } + } + Ok(()) + } + + pub async fn find_all(db: &C, ref_name: &str) -> Result, ReflogError> { + Ok(reflog::Entity::find() + .filter(reflog::Column::RefName.eq(ref_name)) + .order_by_desc(reflog::Column::Timestamp) + .all(db) + .await?) + } + + pub async fn find_one(db: &C, ref_name: &str) -> Result, ReflogError> { + Ok(reflog::Entity::find() + .filter(reflog::Column::RefName.eq(ref_name)) + .order_by_desc(reflog::Column::Timestamp) + .one(db) + .await?) + } +} + +fn timestamp_seconds() -> i64 { + let now = SystemTime::now(); + let since_the_epoch = now.duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + since_the_epoch.as_secs() as i64 +} + +/// Executes a database operation within a transaction and records a reflog entry upon success. +/// +/// This function acts as a safe, atomic wrapper for any operation that needs to be +/// recorded in the reflog. It ensures that the core operation and the creation of its +/// corresponding reflog entry either both succeed and are committed, or both fail and +/// are rolled back. This prevents inconsistent states where an action is performed +/// but not logged. +/// +/// # Example +/// +/// Here is how you would use `with_reflog` to wrap a `commit` operation. +/// +/// ```rust,ignore +/// // 1. First, prepare the context for the reflog entry. +/// let reflog_context = ReflogContext { +/// old_oid: "previous_commit_hash".to_string(), +/// new_oid: "new_commit_hash".to_string(), +/// action: ReflogAction::Commit { +/// message: message.to_string(), +/// } +/// }; +/// +/// // 2. Define the core database operation as an async closure. +/// // Note that all DB calls inside MUST use the provided `txn` handle. +/// let core_operation = |txn: &DatabaseTransaction| Box::pin(async move { +/// // This is where you move the branch pointer, update HEAD, etc. +/// // IMPORTANT: Use `_with_conn` variants of your helper functions. +/// Branch::update_branch_with_conn(txn, "main", "new_commit_hash", None).await; +/// Head::update_with_conn(txn, Head::Branch("main".to_string()), None).await; +/// +/// // The closure must return a Result compatible with DbErr. +/// // You can use `ReflogError`. +/// Ok(()) +/// }); +/// +/// // 3. Execute the wrapper. +/// match with_reflog(reflog_context, core_operation, true).await { +/// Ok(_) => println!("Commit and reflog recorded successfully."), +/// Err(e) => eprintln!("Operation failed: {:?}", e), +/// } +/// ``` +/// # Parameters +/// +/// * `context`: A `ReflogContext` struct... +/// * `operation`: An asynchronous closure that performs the core database work... +/// * `insert_ref`: A boolean flag. If `true`, a reflog entry will be created for the +/// current branch in addition to HEAD. If `false`, only HEAD will be logged. This should +/// be `false` for operations like `checkout` that only move HEAD. +pub async fn with_reflog( + context: ReflogContext, + operation: F, + insert_ref: bool, +) -> Result<(), ReflogError> +where + for<'b> F: FnOnce(&'b DatabaseTransaction) -> Pin> + Send + 'b>>, + F: Send + 'static, +{ + let db = get_db_conn_instance().await; + db.transaction(|txn| { + Box::pin(async move { + operation(txn).await + .map_err(ReflogError::from)?; + Reflog::insert(txn, context, insert_ref).await?; + Ok::<_, ReflogError>(()) + }) + }) + .await + .map_err(|err| match err { + TransactionError::Connection(err) => ReflogError::from(err), + TransactionError::Transaction(err) => err, + }) +} + +/// Check whether the current libra repo have a `reflog` table +async fn reflog_table_exists(db_conn: &C) -> Result { + let stmt = Statement::from_sql_and_values( + DbBackend::Sqlite, + r#" + SELECT COUNT(*) + FROM sqlite_master + WHERE type='table' AND name=?; + "#, + ["reflog".into()] + ); + + if let Some(result) = db_conn.query_one(stmt).await? { + let count = result.try_get_by_index(0).unwrap_or(0); + if count == 0 { + return Ok(false); + } + } + + Ok(true) +} + +/// Ensures that the 'reflog' table and its associated indexes exist in the database. +/// If they do not exist, they will be created. +async fn ensure_reflog_table_exists(db: &C) -> Result<(), ReflogError> { + if reflog_table_exists(db).await? { + return Ok(()); + } + + println!("Warning: The current libra repo does not have a `reflog` table, creating one..."); + let create_table_stmt = Statement::from_string( + DbBackend::Sqlite, + r#" + CREATE TABLE IF NOT EXISTS `reflog` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `ref_name` TEXT NOT NULL, + `old_oid` TEXT NOT NULL, + `new_oid` TEXT NOT NULL, + `committer_name` TEXT NOT NULL, + `committer_email` TEXT NOT NULL, + `timestamp` INTEGER NOT NULL, + `action` TEXT NOT NULL, + `message` TEXT NOT NULL + ); + "#.to_string(), + ); + + db.execute(create_table_stmt).await?; + + let create_index_stmt = Statement::from_string( + DbBackend::Sqlite, + r#" + CREATE INDEX IF NOT EXISTS idx_ref_name_timestamp ON `reflog`(`ref_name`, `timestamp`); + "#.to_string(), + ); + + db.execute(create_index_stmt).await?; + Ok(()) +} + +pub fn zero_sha1() -> SHA1 { + SHA1::from_bytes(&[0; 20]) +} \ No newline at end of file