diff --git a/libra/Cargo.toml b/libra/Cargo.toml index 59188463c..31fb67e12 100644 --- a/libra/Cargo.toml +++ b/libra/Cargo.toml @@ -42,6 +42,7 @@ sea-orm = { workspace = true, features = [ serde = { workspace = true } serde_json = { workspace = true } sha1 = { workspace = true } +similar = "2.6.0" tokio = { workspace = true, features = ["rt-multi-thread", "rt", "macros"] } tokio-util = { version = "0.7.11", features = ["io"] } tracing = { workspace = true } diff --git a/libra/README.md b/libra/README.md index 9cf810a77..5cd4d5cce 100644 --- a/libra/README.md +++ b/libra/README.md @@ -17,6 +17,7 @@ Commands: restore Restore working tree files status Show the working tree status log Show commit logs + diff Show changes between commits, commit and working tree, etc branch List, create, or delete branches commit Record changes to the repository switch Switch branches @@ -65,7 +66,7 @@ achieving unified management. - [x] `restore` - [ ] `reset` - [x] `branch` -- [ ] `diff` +- [x] `diff` - [x] `merge` - [ ] `rebase` - [x] `index-pack` diff --git a/libra/src/cli.rs b/libra/src/cli.rs index 0be0a9e31..84d11b99c 100644 --- a/libra/src/cli.rs +++ b/libra/src/cli.rs @@ -56,6 +56,8 @@ enum Commands { Fetch(command::fetch::FetchArgs), #[command(about = "Fetch from and integrate with another repository or a local branch")] Pull(command::pull::PullArgs), + #[command(about = "Show different between files")] + Diff(command::diff::DiffArgs), #[command(subcommand, about = "Manage set of tracked repositories")] Remote(command::remote::RemoteCmds), @@ -106,6 +108,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> Result<(), GitError> { Commands::Push(args) => command::push::execute(args).await, Commands::IndexPack(args) => command::index_pack::execute(args), Commands::Fetch(args) => command::fetch::execute(args).await, + Commands::Diff(args) => command::diff::execute(args).await, Commands::Remote(cmd) => command::remote::execute(cmd).await, Commands::Pull(args) => command::pull::execute(args).await, } diff --git a/libra/src/command/branch.rs b/libra/src/command/branch.rs index 56aeea33b..0314f10d8 100644 --- a/libra/src/command/branch.rs +++ b/libra/src/command/branch.rs @@ -1,10 +1,9 @@ use crate::{ - internal::{branch::Branch, config::Config, head::Head}, - utils::{self, client_storage::ClientStorage}, + command::get_target_commit, internal::{branch::Branch, config::Config, head::Head} }; use clap::Parser; use colored::Colorize; -use mercury::{hash::SHA1, internal::object::commit::Commit}; +use mercury::internal::object::commit::Commit; use crate::command::load_object; @@ -99,7 +98,7 @@ pub async fn create_branch(new_branch: String, branch_or_commit: Option) match commit { Ok(commit) => commit, Err(e) => { - eprintln!("{}", e); + eprintln!("fatal: {}", e); return; } } @@ -186,26 +185,6 @@ async fn list_branches(remotes: bool) { } } -pub async fn get_target_commit(branch_or_commit: &str) -> Result> { - let possible_branches = Branch::search_branch(branch_or_commit).await; - if possible_branches.len() > 1 { - return Err("fatal: Ambiguous branch name".into()); - // TODO: git have a priority list of branches to use, continue with ambiguity, we didn't implement it yet - } - - if possible_branches.is_empty() { - let storage = ClientStorage::init(utils::path::objects()); - let possible_commits = storage.search(branch_or_commit); - if possible_commits.len() > 1 || possible_commits.is_empty() { - return Err( - format!("fatal: {} is not something we can merge", branch_or_commit).into(), - ); - } - Ok(possible_commits[0]) - } else { - Ok(possible_branches[0].commit) - } -} fn is_valid_git_branch_name(name: &str) -> bool { // 检查是否包含不允许的字符 diff --git a/libra/src/command/diff.rs b/libra/src/command/diff.rs new file mode 100644 index 000000000..1cfa6accd --- /dev/null +++ b/libra/src/command/diff.rs @@ -0,0 +1,327 @@ +use std::{ + collections::HashMap, + fmt, + io::{self, Write}, + path::PathBuf, +}; + +use clap::Parser; +use mercury::{ + hash::SHA1, + internal::{ + index::Index, + object::{blob::Blob, commit::Commit, tree::Tree, types::ObjectType}, + pack::utils::calculate_object_hash, + }, +}; +use similar; + +use crate::{ + command::{ + get_target_commit, load_object, + status::{self, changes_to_be_committed}, + }, + internal::head::Head, + utils::{object_ext::TreeExt, path, util}, +}; + +#[cfg(unix)] +use std::process::{Command, Stdio}; + +#[derive(Parser, Debug)] +pub struct DiffArgs { + #[clap(long, help = "Old commit, defaults is staged or HEAD")] + pub old: Option, + + #[clap(long, help = "New commit, default is working directory")] + #[clap(requires = "old", group = "op_new")] + pub new: Option, + + #[clap(long, help = "use stage as new commit")] + #[clap(group = "op_new")] + pub staged: bool, + + #[clap(help = "Files to compare")] + pathspec: Vec, + + #[clap(long)] + pub output: Option, +} + +pub async fn execute(args: DiffArgs) { + if !util::check_repo_exist() { + return; + } + tracing::debug!("diff args: {:?}", args); + let index = Index::load(path::index()).unwrap(); + #[cfg(unix)] + let mut child = Command::new("less") + .arg("-R") + .arg("-F") + .stdin(Stdio::piped()) + .spawn() + .expect("failed to execute process"); + + let mut w = match args.output { + Some(ref path) => { + let file = std::fs::File::create(path) + .map_err(|e| { + eprintln!( + "fatal: could not open to file '{}' for writing: {}", + path, e + ); + }) + .unwrap(); + Some(file) + } + None => None, + }; + + let old_blobs = match args.old { + Some(ref source) => match get_target_commit(source).await { + Ok(commit_hash) => get_commit_blobs(&commit_hash).await, + Err(e) => { + eprintln!("fatal: {}, can't use as diff old source", e); + return; + } + }, + None => { + // if the staged is not empty, use it as old commit. Otherwise, use HEAD + if status::changes_to_be_committed().await.is_empty() { + let commit_hash = Head::current_commit().await.unwrap(); + get_commit_blobs(&commit_hash).await + } else { + let changes = changes_to_be_committed().await; + // diff didn't show untracked or deleted files + get_files_blobs(&changes.modified) + } + } + }; + + let new_blobs = match args.new { + Some(ref source) => match get_target_commit(source).await { + Ok(commit_hash) => get_commit_blobs(&commit_hash).await, + Err(e) => { + eprintln!("fatal: {}, can't use as diff new source", e); + return; + } + }, + None => { + let files = if args.staged { + // use staged as new commit + index.tracked_files() + } else { + // use working directory as new commit + util::list_workdir_files().unwrap() + }; + get_files_blobs(&files) + } + }; + + // use pathspec to filter files + let paths: Vec = args + .pathspec + .iter() + .map(|s| { + let path = { + let _path = PathBuf::from(s); + if _path.is_absolute() { + _path + } else { + std::env::current_dir().unwrap().join(_path) // proces path such as `./` + } + }; + util::to_workdir_path(&path) + }) + .collect(); + + let mut buf: Vec = Vec::new(); + // filter files, cross old and new files, and pathspec + diff(old_blobs, new_blobs, paths.into_iter().collect(), &mut buf).await; + + match w { + Some(ref mut file) => { + file.write_all(&buf).unwrap(); + } + None => { + #[cfg(unix)] + { + let stdin = child.stdin.as_mut().unwrap(); + stdin.write_all(&buf).unwrap(); + child.wait().unwrap(); + } + #[cfg(not(unix))] + { + io::stdout().write_all(&buf).unwrap(); + } + } + } +} + +pub async fn diff( + old_blobs: Vec<(PathBuf, SHA1)>, + new_blobs: Vec<(PathBuf, SHA1)>, + filter: Vec, + w: &mut dyn io::Write, +) { + let old_blobs: HashMap = old_blobs.into_iter().collect(); + let read_content = |file: &PathBuf, hash: &SHA1| { + // read content from blob or file + match load_object::(hash) { + Ok(blob) => String::from_utf8(blob.data).unwrap(), + Err(_) => { + let file = util::workdir_to_absolute(file); + std::fs::read_to_string(&file) + .map_err(|e| { + eprintln!("fatal: could not read file '{}': {}", file.display(), e); + }) + .unwrap() + } + } + }; + // filter files, cross old and new files, and pathspec + for (new_file, new_hash) in new_blobs { + // if new_file did't start with any path in filter, skip it + if !filter.is_empty() && !filter.iter().any(|path| new_file.starts_with(path)) { + continue; + } + match old_blobs.get(&new_file) { + Some(old_hash) => { + if old_hash == &new_hash { + continue; + } + let old_content = read_content(&new_file, old_hash); + let new_content = read_content(&new_file, &new_hash); + writeln!( + w, + "diff --git a/{} b/{}", + new_file.display(), + new_file.display() // files name is always the same, current did't support rename + ) + .unwrap(); + writeln!( + w, + "index {}..{}", + &old_hash.to_plain_str()[0..8], + &new_hash.to_plain_str()[0..8] + ) + .unwrap(); + + diff_result(&old_content, &new_content, w); + } + None => { + continue; + } + } + } +} + +async fn get_commit_blobs(commit_hash: &SHA1) -> Vec<(PathBuf, SHA1)> { + let commit = load_object::(commit_hash).unwrap(); + let tree = load_object::(&commit.tree_id).unwrap(); + tree.get_plain_items() +} + +// diff need to print hash even if the file is not added +fn get_files_blobs(files: &[PathBuf]) -> Vec<(PathBuf, SHA1)> { + files + .iter() + .map(|p| { + let path = util::workdir_to_absolute(p); + let data = std::fs::read(&path).unwrap(); + (p.to_owned(), calculate_object_hash(ObjectType::Blob, &data)) + }) + .collect() +} + +struct Line(Option); + +impl fmt::Display for Line { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self.0 { + None => write!(f, " "), + Some(idx) => write!(f, "{:<4}", idx + 1), + } + } +} + +fn diff_result(old: &str, new: &str, w: &mut dyn io::Write) { + let diff = similar::TextDiff::from_lines(old, new); + for (idx, group) in diff.grouped_ops(3).iter().enumerate() { + if idx > 0 { + println!("{:-^1$}", "-", 80); + } + for op in group { + for change in diff.iter_changes(op) { + let sign = match change.tag() { + similar::ChangeTag::Delete => "-", + similar::ChangeTag::Insert => "+", + similar::ChangeTag::Equal => " ", + }; + write!( + w, + "{}{} |{}", + Line(change.old_index()), + Line(change.new_index()), + sign + ) + .unwrap(); + write!(w, "{}", change.value()).unwrap(); + if change.missing_newline() { + writeln!(w).unwrap(); + } + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + fn test_args() { + { + let args = DiffArgs::try_parse_from(["diff", "--old", "old", "--new", "new", "paths"]); + assert!(args.is_ok()); + let args = args.unwrap(); + // println!("{:?}", args); + assert_eq!(args.old, Some("old".to_string())); + assert_eq!(args.new, Some("new".to_string())); + assert_eq!(args.pathspec, vec!["paths".to_string()]); + } + { + // --staged didn't require --old + let args = + DiffArgs::try_parse_from(["diff", "--staged", "pathspec", "--output", "output"]); + let args = args.unwrap(); + assert_eq!(args.old, None); + assert!(args.staged); + } + { + // --staged conflicts with --new + let args = DiffArgs::try_parse_from([ + "diff", "--old", "old", "--new", "new", "--staged", "paths", + ]); + assert!(args.is_err()); + assert!(args.err().unwrap().kind() == clap::error::ErrorKind::ArgumentConflict); + } + { + // --new requires --old + let args = DiffArgs::try_parse_from([ + "diff", "--new", "new", "pathspec", "--output", "output", + ]); + assert!(args.is_err()); + assert!(args.err().unwrap().kind() == clap::error::ErrorKind::MissingRequiredArgument); + } + } + + #[test] + fn test_diff_result() { + let old = "Hello World\nThis is the second line.\nThis is the third."; + let new = "Hallo Welt\nThis is the second line.\nThis is life.\nMoar and more"; + let mut buf = Vec::new(); + diff_result(old, new, &mut buf); + let result = String::from_utf8(buf).unwrap(); + println!("{}", result); + } +} diff --git a/libra/src/command/log.rs b/libra/src/command/log.rs index 264446495..8524555ee 100644 --- a/libra/src/command/log.rs +++ b/libra/src/command/log.rs @@ -55,6 +55,7 @@ pub async fn execute(args: LogArgs) { #[cfg(unix)] let mut process = Command::new("less") // create a pipe to less .arg("-R") // raw control characters + .arg("-F") .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .spawn() diff --git a/libra/src/command/merge.rs b/libra/src/command/merge.rs index fc1f53807..bcc7fd058 100644 --- a/libra/src/command/merge.rs +++ b/libra/src/command/merge.rs @@ -7,7 +7,7 @@ use crate::{ }; use super::{ - branch::get_target_commit, + get_target_commit, load_object, log, restore::{self, RestoreArgs}, }; diff --git a/libra/src/command/mod.rs b/libra/src/command/mod.rs index a98f6101f..d1ca948a3 100644 --- a/libra/src/command/mod.rs +++ b/libra/src/command/mod.rs @@ -2,9 +2,11 @@ pub mod add; pub mod branch; pub mod clone; pub mod commit; +pub mod diff; pub mod fetch; pub mod index_pack; pub mod init; +pub mod lfs; pub mod log; pub mod merge; pub mod pull; @@ -14,18 +16,22 @@ pub mod remove; pub mod restore; pub mod status; pub mod switch; -pub mod lfs; +use crate::internal::branch::Branch; +use crate::internal::head::Head; use crate::internal::protocol::https_client::BasicAuth; +use crate::utils; +use crate::utils::client_storage::ClientStorage; +use crate::utils::object_ext::BlobExt; use crate::utils::util; +use mercury::internal::object::blob::Blob; use mercury::{errors::GitError, hash::SHA1, internal::object::ObjectTrait}; use rpassword::read_password; use std::io; use std::io::Write; use std::path::Path; -use mercury::internal::object::blob::Blob; -use crate::utils; -use crate::utils::object_ext::BlobExt; + +const HEAD: &str = "HEAD"; // impl load for all objects fn load_object(hash: &SHA1) -> Result @@ -84,6 +90,7 @@ pub fn format_commit_msg(msg: &str, gpg_sig: Option<&str>) -> String { } } } + /// parse commit message pub fn parse_commit_msg(msg_gpg: &str) -> (String, Option) { const GPG_SIG_START: &str = "gpgsig -----BEGIN PGP SIGNATURE-----"; @@ -119,7 +126,7 @@ pub fn parse_commit_msg(msg_gpg: &str) -> (String, Option) { /// Calculate the hash of a file blob /// - for `lfs` file: calculate hash of the pointer data pub fn calc_file_blob_hash(path: impl AsRef) -> io::Result { - let blob = if utils::lfs::is_lfs_tracked(&path) { + let blob = if utils::lfs::is_lfs_tracked(&path) { let (pointer, _) = utils::lfs::generate_pointer_file(&path); Blob::from_content(&pointer) } else { @@ -128,6 +135,32 @@ pub fn calc_file_blob_hash(path: impl AsRef) -> io::Result { Ok(blob.id) } +/// Get the commit hash from branch name or commit hash, support remote branch +pub async fn get_target_commit(branch_or_commit: &str) -> Result> { + if branch_or_commit == HEAD { + return Ok(Head::current_commit().await.unwrap()); + } + + let possible_branches = Branch::search_branch(branch_or_commit).await; + if possible_branches.len() > 1 { + return Err("Ambiguous branch name".into()); + // TODO: git have a priority list of branches to use, continue with ambiguity, we didn't implement it yet + } + + if possible_branches.is_empty() { + let storage = ClientStorage::init(utils::path::objects()); + let possible_commits = storage.search(branch_or_commit); + if possible_commits.len() > 1 || possible_commits.is_empty() { + return Err( + "Ambiguous commit hash".into(), + ); + } + Ok(possible_commits[0]) + } else { + Ok(possible_branches[0].commit) + } +} + #[cfg(test)] mod test { use mercury::internal::object::commit::Commit; diff --git a/lunar/src-tauri/src/main.rs b/lunar/src-tauri/src/main.rs index f5b2f94d0..a3846451b 100644 --- a/lunar/src-tauri/src/main.rs +++ b/lunar/src-tauri/src/main.rs @@ -245,7 +245,10 @@ fn push_to_new_remote(app: tauri::AppHandle, repo_path: PathBuf) -> Result<(), S } fn main() { - let params = MegaStartParams::default(); + // let params = MegaStartParams::default(); + let params = MegaStartParams { + bootstrap_node: Some("http://gitmono.org/relay".to_string()), + }; tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init())