diff --git a/git-rs/merge.rs b/git-rs/merge.rs new file mode 100644 index 0000000000000000000000000000000000000000..ef1fe015f438011d6e5a1d7b0a39e9cdc1e5709b --- /dev/null +++ b/git-rs/merge.rs @@ -0,0 +1,106 @@ +use clap::Parser; +use mercury::internal::object::commit::Commit; + +use crate::{ + internal::{branch::Branch, head::Head}, + utils::util, +}; + +use super::{ + branch::get_target_commit, + load_object, log, + restore::{self, RestoreArgs}, +}; + +#[derive(Parser, Debug)] +pub struct MergeArgs { + /// The branch to merge into the current branch + pub branch: String, +} + +pub async fn execute(args: MergeArgs) { + let target_commit_hash = get_target_commit(&args.branch).await; + if target_commit_hash.is_err() { + eprintln!("{}", target_commit_hash.err().unwrap()); + return; + } + let commit_hash = target_commit_hash.unwrap(); + + let target_commit: Commit = load_object(&commit_hash).unwrap(); + let current_commit: Commit = load_object(&Head::current_commit().await.unwrap()).unwrap(); + let lca = lca_commit(¤t_commit, &target_commit).await; + + if lca.is_none() { + eprintln!("fatal: fatal: refusing to merge unrelated histories"); + return; + } + let lca = lca.unwrap(); + + if lca.id == target_commit.id { + // no need to merge + println!("Already up to date."); + } else if lca.id == current_commit.id { + println!( + "Updating {}..{}", + ¤t_commit.id.to_plain_str()[..6], + &target_commit.id.to_plain_str()[..6] + ); + // fast-forward merge + merge_ff(target_commit).await; + } else { + // didn't support yet + eprintln!("fatal: Not possible to fast-forward merge, try merge manually"); + } +} + +async fn lca_commit(lhs: &Commit, rhs: &Commit) -> Option { + let lhs_reachable = log::get_reachable_commits(lhs.id.to_plain_str()).await; + let rhs_reachable = log::get_reachable_commits(rhs.id.to_plain_str()).await; + + // Commit `eq` is based on tree_id, so we shouldn't use it here + + for commit in lhs_reachable.iter() { + if commit.id == rhs.id { + return Some(commit.to_owned()); + } + } + + for commit in rhs_reachable.iter() { + if commit.id == lhs.id { + return Some(commit.to_owned()); + } + } + + for lhs_parrent in lhs_reachable.iter() { + for rhs_parrent in rhs_reachable.iter() { + if lhs_parrent.id == rhs_parrent.id { + return Some(lhs_parrent.to_owned()); + } + } + } + None +} + +/// try merge in fast-forward mode, if it's not possible, do nothing +async fn merge_ff(commit: Commit) { + 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_plain_str(), 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 + restore::execute(RestoreArgs { + worktree: true, + staged: true, + source: None, + pathspec: vec![util::working_dir_string()], + }) + .await; +} diff --git a/git-rs/mod.rs b/git-rs/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..9116810e18572b344f8f40b00ecbdcd8b6d86888 --- /dev/null +++ b/git-rs/mod.rs @@ -0,0 +1,142 @@ +pub mod add; +pub mod branch; +pub mod clone; +pub mod commit; +pub mod fetch; +pub mod index_pack; +pub mod init; +pub mod log; +pub mod merge; +pub mod pull; +pub mod push; +pub mod remote; +pub mod remove; +pub mod restore; +pub mod status; +pub mod switch; + +use crate::internal::protocol::https_client::BasicAuth; +use crate::utils::util; +use mercury::{errors::GitError, hash::SHA1, internal::object::ObjectTrait}; +use rpassword::read_password; +use std::io; +use std::io::Write; + +// impl load for all objects +fn load_object(hash: &SHA1) -> Result +where + T: ObjectTrait, +{ + let storage = util::objects_storage(); + let data = storage.get(hash)?; + T::from_bytes(data.to_vec(), *hash) +} + +// impl save for all objects +fn save_object(object: &T, ojb_id: &SHA1) -> Result<(), GitError> +where + T: ObjectTrait, +{ + let storage = util::objects_storage(); + let data = object.to_data()?; + storage.put(ojb_id, &data, object.get_type())?; + Ok(()) +} + +/// Ask for username and password (CLI interaction) +fn ask_username_password() -> (String, String) { + print!("username: "); + // Normally your OS will buffer output by line when it's connected to a terminal, + // which is why it usually flushes when a newline is written to stdout. + io::stdout().flush().unwrap(); // ensure the prompt is shown + let mut username = String::new(); + io::stdin().read_line(&mut username).unwrap(); + username = username.trim().to_string(); + + print!("password: "); + io::stdout().flush().unwrap(); + let password = read_password().unwrap(); // hide password + (username, password) +} + +/// same as ask_username_password, but return BasicAuth +pub fn ask_basic_auth() -> BasicAuth { + let (username, password) = ask_username_password(); + BasicAuth { username, password } +} + +/// Format commit message with GPG signature
+/// There must be a `blank line`(\n) before `message`, or remote unpack failed.
+/// If there is `GPG signature`, +/// `blank line` should be placed between `signature` and `message` +pub fn format_commit_msg(msg: &str, gpg_sig: Option<&str>) -> String { + match gpg_sig { + None => { + format!("\n{}", msg) + } + Some(gpg) => { + format!("{}\n\n{}", gpg, msg) + } + } +} +/// parse commit message +pub fn parse_commit_msg(msg_gpg: &str) -> (String, Option) { + const GPG_SIG_START: &str = "gpgsig -----BEGIN PGP SIGNATURE-----"; + const GPG_SIG_END: &str = "-----END PGP SIGNATURE-----"; + let gpg_start = msg_gpg.find(GPG_SIG_START); + let gpg_end = msg_gpg.find(GPG_SIG_END).map(|end| end + GPG_SIG_END.len()); + let gpg_sig = match (gpg_start, gpg_end) { + (Some(start), Some(end)) => { + if start < end { + Some(msg_gpg[start..end].to_string()) + } else { + None + } + } + _ => None, + }; + match gpg_sig { + Some(gpg) => { + // skip the leading '\n\n' (blank line) + let msg = msg_gpg[gpg_end.unwrap()..].to_string(); + assert!(msg.starts_with("\n\n"), "commit message format error"); + let msg = msg[2..].to_string(); + (msg, Some(gpg)) + } + None => { + assert!(msg_gpg.starts_with('\n'), "commit message format error"); + let msg = msg_gpg[1..].to_string(); // skip the leading '\n' (blank line) + (msg, None) + } + } +} + +#[cfg(test)] +mod test { + use mercury::internal::object::commit::Commit; + + use super::*; + use crate::utils::test; + #[tokio::test] + async fn test_save_load_object() { + test::setup_with_new_libra().await; + let object = Commit::from_tree_id(SHA1::new(&vec![1; 20]), vec![], "Commit_1"); + save_object(&object, &object.id).unwrap(); + let _ = load_object::(&object.id).unwrap(); + } + + #[test] + fn test_format_and_parse_commit_msg() { + let msg = "commit message"; + let gpg_sig = "gpgsig -----BEGIN PGP SIGNATURE-----\ncontent\n-----END PGP SIGNATURE-----"; + let msg_gpg = format_commit_msg(msg, Some(gpg_sig)); + let (msg_, gpg_sig_) = parse_commit_msg(&msg_gpg); + assert_eq!(msg, msg_); + assert_eq!(gpg_sig, gpg_sig_.unwrap()); + + let msg_gpg = format_commit_msg(msg, None); + let (msg_, gpg_sig_) = parse_commit_msg(&msg_gpg); + assert_eq!(msg, msg_); + assert_eq!(None, gpg_sig_); + } +} diff --git a/git-rs/pull.rs b/git-rs/pull.rs new file mode 100644 index 0000000000000000000000000000000000000000..a8294b23e2fc629d018d45c2e2b404e3b62df409 --- /dev/null +++ b/git-rs/pull.rs @@ -0,0 +1,31 @@ +use crate::internal::{config::Config, head::Head}; + +use super::{fetch, merge}; +use clap::Parser; +#[derive(Parser, Debug)] +pub struct PullArgs; + +pub async fn execute(args: PullArgs) { + let _ = args; + let fetch_args = fetch::FetchArgs::parse_from(Vec::::new()); + fetch::execute(fetch_args).await; + + let head = Head::current().await; + match head { + Head::Branch(name) => match Config::branch_config(&name).await { + Some(branch_config) => { + let merge_args = merge::MergeArgs { + branch: format!("{}/{}", branch_config.remote, branch_config.merge), + }; + merge::execute(merge_args).await; + } + None => { + eprintln!("There is no tracking information for the current branch."); + eprintln!("hint: set up a tracking branch with `libra branch --set-upstream-to=/`") + } + }, + _ => { + eprintln!("You are not currently on a branch."); + } + } +} diff --git a/git-rs/push.rs b/git-rs/push.rs new file mode 100644 index 0000000000000000000000000000000000000000..84748b4d85c1e8e23a075dc0e5103c86cc0f8617 --- /dev/null +++ b/git-rs/push.rs @@ -0,0 +1,337 @@ +use std::collections::{HashSet, VecDeque}; +use std::str::FromStr; +use bytes::BytesMut; +use clap::Parser; +use tokio::sync::mpsc; +use url::Url; +use ceres::protocol::ServiceType::ReceivePack; +use ceres::protocol::smart::{add_pkt_line_string, read_pkt_line}; +use mercury::errors::GitError; +use mercury::hash::SHA1; +use mercury::internal::object::blob::Blob; +use mercury::internal::object::commit::Commit; +use mercury::internal::object::tree::{Tree, TreeItemMode}; +use mercury::internal::pack::encode::PackEncoder; +use mercury::internal::pack::entry::Entry; +use crate::command::{ask_basic_auth, branch}; +use crate::internal::branch::Branch; +use crate::internal::config::Config; +use crate::internal::head::Head; +use crate::internal::protocol::https_client::{BasicAuth, HttpsClient}; +use crate::internal::protocol::ProtocolClient; +use crate::utils::object_ext::{BlobExt, CommitExt, TreeExt}; + +#[derive(Parser, Debug)] +pub struct PushArgs { // TODO --force + /// repository, e.g. origin + #[clap(requires("refspec"))] + repository: Option, + /// ref to push, e.g. master + #[clap(requires("repository"))] + refspec: Option, + + #[clap(long, short = 'u', requires("refspec"), requires("repository"))] + set_upstream: bool, +} + +pub async fn execute(args: PushArgs) { + if args.repository.is_some() ^ args.refspec.is_some() { // must provide both or none + eprintln!("fatal: both repository and refspec should be provided"); + return; + } + if args.set_upstream && args.refspec.is_none() { + eprintln!("fatal: --set-upstream requires a branch name"); + return; + } + + let branch = match Head::current().await { + Head::Branch(name) => name, + Head::Detached(_) => panic!("fatal: HEAD is detached while pushing"), + }; + + let repository = match args.repository { + Some(repo) => repo, + None => { + // e.g. [branch "master"].remote = origin + let remote = Config::get("branch", Some(&branch), "remote").await; + if let Some(remote) = remote { + remote + } else { + eprintln!("fatal: no remote configured for branch '{}'", branch); + return; + } + } + }; + let repo_url = Config::get("remote", Some(&repository), "url").await; + if repo_url.is_none() { + eprintln!("fatal: remote '{}' not found, please use 'libra remote add'", repository); + return; + } + let repo_url = repo_url.unwrap(); + + let branch = args.refspec.unwrap_or(branch); + let commit_hash = Branch::find_branch(&branch, None).await.unwrap().commit.to_plain_str(); + + println!("pushing {}({}) to {}({})", branch, commit_hash, repository, repo_url); + + let url = Url::parse(&repo_url).unwrap(); + let client = HttpsClient::from_url(&url); + let mut refs = client.discovery_reference(ReceivePack, None).await; + let mut auth: Option = None; + while let Err(e) = refs { // retry if unauthorized + if let GitError::UnAuthorized(_) = e { + auth = Some(ask_basic_auth()); + refs = client.discovery_reference(ReceivePack, auth.clone()).await; + } else { + eprintln!("fatal: {}", e); + return; + } + } + let refs = refs.unwrap(); + + let tracked_branch = Config::get("branch", Some(&branch), "merge") + .await // New branch may not have tracking branch + .unwrap_or_else(|| format!("refs/heads/{}", branch)); + + let tracked_ref = refs.iter().find(|r| r._ref == tracked_branch); + // [0; 20] if new branch + let remote_hash = tracked_ref.map(|r| r._hash.clone()).unwrap_or(SHA1::default().to_plain_str()); + if remote_hash == commit_hash { + println!("Everything up-to-date"); + return; + } + + let mut data = BytesMut::new(); + add_pkt_line_string(&mut data, format!("{} {} {}\0report-status\n", + remote_hash, + commit_hash, + tracked_branch)); + data.extend_from_slice(b"0000"); + tracing::debug!("{:?}", data); + + // TODO 考虑remote有多个refs,可以少发一点commits + let objs = incremental_objs( + SHA1::from_str(&commit_hash).unwrap(), + SHA1::from_str(&remote_hash).unwrap() + ); + println!("Counting objects: {}", objs.len()); + + // let (tx, rx) = mpsc::channel::(); + let (entry_tx, entry_rx) = mpsc::channel(1_000_000); + let (stream_tx, mut stream_rx) = mpsc::channel(1_000_000); + + let encoder = PackEncoder::new(objs.len(), 5, stream_tx); + encoder.encode_async(entry_rx).await.unwrap(); + + for entry in objs { + // TODO progress bar + entry_tx.send(entry).await.unwrap(); + } + drop(entry_tx); + println!("Delta compression done."); + + let mut pack_data = Vec::new(); + while let Some(chunk) = stream_rx.recv().await { + pack_data.extend(chunk); + } + data.extend_from_slice(&pack_data); + + let res = client.send_pack(data.freeze(), auth).await.unwrap(); + + if res.status() != 200 { + eprintln!("status code: {}", res.status()); + } + let mut data = res.bytes().await.unwrap(); + let (_, pkt_line) = read_pkt_line(&mut data); + if pkt_line != "unpack ok\n" { + eprintln!("fatal: unpack failed"); + return; + } + let (_, pkt_line) = read_pkt_line(&mut data); + if !pkt_line.starts_with("ok".as_ref()) { + eprintln!("fatal: ref update failed [{:?}]", pkt_line); + return; + } + let (len, _) = read_pkt_line(&mut data); + assert_eq!(len, 0); + + println!("Push success"); + + // set after push success + if args.set_upstream { + branch::set_upstream(&branch, &format!("{}/{}", repository, branch)).await; + } +} + +/// collect all commits from `commit_id` to root commit +fn collect_history_commits(commit_id: &SHA1) -> HashSet { + if commit_id == &SHA1::default() { // 0000...0000 means not exist + return HashSet::new(); + } + + let mut commits = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(*commit_id); + while let Some(commit) = queue.pop_front() { + commits.insert(commit); + + let commit = Commit::load(&commit); + for parent in commit.parent_commit_ids.iter() { + queue.push_back(*parent); + } + } + commits +} + +fn incremental_objs(local_ref: SHA1, remote_ref: SHA1) -> HashSet { + // just fast-forward optimization + if remote_ref != SHA1::default() { // remote exists + let mut commit = Commit::load(&local_ref); + let mut commits = Vec::new(); + let mut ok = true; + loop { + commits.push(commit.id); + if commit.id == remote_ref { + break; + } + if commit.parent_commit_ids.len() != 1 { // merge commit or root commit + ok = false; + break; + } + // update commit to it's only parent + commit = Commit::load(&commit.parent_commit_ids[0]); + } + if ok { // fast-forward + let mut objs = HashSet::new(); + commits.reverse(); // from old to new + for i in 0..commits.len() - 1 { + let old_tree = Commit::load(&commits[i]).tree_id; + let new_commit = Commit::load(&commits[i + 1]); + objs.extend(diff_tree_objs(Some(&old_tree), &new_commit.tree_id)); + objs.insert(new_commit.into()); + } + return objs; + } + } + + + let mut objs = HashSet::new(); + let exist_commits = collect_history_commits(&remote_ref); + let mut queue = VecDeque::new(); + if !exist_commits.contains(&local_ref) { + queue.push_back(local_ref); + } + let mut root_commit = None; + + while let Some(commit) = queue.pop_front() { + let commit = Commit::load(&commit); + let parents = &commit.parent_commit_ids; + if parents.is_empty() { + if root_commit.is_none() { + root_commit = Some(commit.id); + } else { + eprintln!("fatal: multiple root commits"); + } + } + for parent in parents.iter() { + objs.extend(diff_tree_objs(Some(parent), &commit.tree_id)); + if !exist_commits.contains(parent) { + queue.push_back(*parent); + } + } + objs.insert(commit.into()); + } + + // root commit has no parent + if let Some(root_commit) = root_commit { + let root_tree = Commit::load(&root_commit).tree_id; + objs.extend(diff_tree_objs(None, &root_tree)); + } + + objs +} + +/// calc objects that in `new_tree` but not in `old_tree` +/// - if `old_tree` is None, return all objects in `new_tree` (include tree itself) +fn diff_tree_objs(old_tree: Option<&SHA1>, new_tree: &SHA1) -> HashSet { + let mut objs = HashSet::new(); + if let Some(old_tree) = old_tree { + if old_tree == new_tree { + return objs; + } + } + + let new_tree = Tree::load(new_tree); + objs.insert(new_tree.clone().into()); // tree itself + + let old_items = match old_tree { + Some(tree) => { + let tree = Tree::load(tree); + tree.tree_items.iter() + .map(|item| item.id) + .collect::>() + } + None => HashSet::new() + }; + + for item in new_tree.tree_items.iter() { + if !old_items.contains(&item.id) { + match item.mode { + TreeItemMode::Tree => { + objs.extend(diff_tree_objs(None, &item.id)); //TODO optimize, find same name tree + } + _ => { + let blob = Blob::load(&item.id); + objs.insert(blob.into()); + } + } + } + } + + objs +} + +#[cfg(test)] +mod test{ + use super::*; + #[test] + fn test_parse_args_success() { + let args = vec!["push"]; + let args = PushArgs::parse_from(args); + assert_eq!(args.repository, None); + assert_eq!(args.refspec, None); + assert!(!args.set_upstream); + + let args = vec!["push", "origin", "master"]; + let args = PushArgs::parse_from(args); + assert_eq!(args.repository, Some("origin".to_string())); + assert_eq!(args.refspec, Some("master".to_string())); + assert!(!args.set_upstream); + + let args = vec!["push", "-u", "origin", "master"]; + let args = PushArgs::parse_from(args); + assert_eq!(args.repository, Some("origin".to_string())); + assert_eq!(args.refspec, Some("master".to_string())); + assert!(args.set_upstream); + } + + #[test] + fn test_parse_args_fail() { + let args = vec!["push", "-u"]; + let args = PushArgs::try_parse_from(args); + assert!(args.is_err()); + + let args = vec!["push", "-u", "origin"]; + let args = PushArgs::try_parse_from(args); + assert!(args.is_err()); + + let args = vec!["push", "-u", "master"]; + let args = PushArgs::try_parse_from(args); + assert!(args.is_err()); + + let args = vec!["push", "origin"]; + let args = PushArgs::try_parse_from(args); + assert!(args.is_err()); + } + +} \ No newline at end of file diff --git a/git-rs/remote.rs b/git-rs/remote.rs new file mode 100644 index 0000000000000000000000000000000000000000..06c4f7c92a36cf85a0190db3945abc92f7ccdb58 --- /dev/null +++ b/git-rs/remote.rs @@ -0,0 +1,64 @@ +use crate::internal::config::Config; +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum RemoteCmds { + /// Add a remote + Add { + /// The name of the remote + name: String, + /// The URL of the remote + url: String, + }, + /// Remove a remote + Remove { + /// The name of the remote + name: String, + }, + /// List remotes + #[command(name = "-v")] + List, + /// Show current remote repository + Show, +} + +pub async fn execute(command: RemoteCmds) { + match command { + RemoteCmds::Add { name, url } => { + Config::insert("remote", Some(&name), "url", &url).await; + } + RemoteCmds::Remove { name } => { + if let Err(e) = Config::remove_remote(&name).await { + eprintln!("{}", e); + } + } + RemoteCmds::List => { + let remotes = Config::all_remote_configs().await; + for remote in remotes { + show_remote_verbose(&remote.name).await; + } + } + RemoteCmds::Show => { + let remotes = Config::all_remote_configs().await; + for remote in remotes { + println!("{}", remote.name); + } + } + } +} + +async fn show_remote_verbose(remote: &str) { + // There can be multiple URLs for a remote, like Gitee & GitHub + let urls = Config::get_all("remote", Some(remote), "url").await; + match urls.first() { + Some(url) => { + println!("{} {} (fetch)", remote, url); + } + None => { + eprintln!("fatal: no URL configured for remote '{}'", remote); + } + } + for url in urls { + println!("{} {} (push)", remote, url); + } +} diff --git a/git-rs/remove.rs b/git-rs/remove.rs new file mode 100644 index 0000000000000000000000000000000000000000..a3a09df4304537e764caa1eaeb8116e7dbe1660c --- /dev/null +++ b/git-rs/remove.rs @@ -0,0 +1,100 @@ +use std::fs; +use std::path::PathBuf; + +use clap::Parser; +use colored::Colorize; + +use mercury::errors::GitError; + +use mercury::internal::index::Index; +use crate::utils::path_ext::PathExt; +use crate::utils::{path, util}; + +#[derive(Parser, Debug)] +pub struct RemoveArgs { + /// file or dir to remove + pathspec: Vec, + /// whether to remove from index + #[clap(long)] + cached: bool, + /// indicate recursive remove dir + #[clap(short, long)] + recursive: bool, +} + +pub fn execute(args: RemoveArgs) -> Result<(), GitError> { + if !util::check_repo_exist() { + return Ok(()); + } + let idx_file = path::index(); + let mut index = Index::load(&idx_file)?; + // check if pathspec is all in index + if !validate_pathspec(&args.pathspec, &index) { + return Ok(()); + } + let dirs = get_dirs(&args.pathspec, &index); + if !dirs.is_empty() && !args.recursive { + println!("fatal: not removing '{}' recursively without -r", dirs[0].bright_blue()); // Git print first + return Ok(()); + } + + for path_str in args.pathspec.iter() { + let path = PathBuf::from(path_str); + let path_wd = path.to_workdir().to_string_or_panic(); + if dirs.contains(path_str) { + // dir + let removed = index.remove_dir_files(&path_wd); + for file in removed.iter() { // to workdir + println!("rm '{}'", file.bright_green()); + } + if !args.cached { + fs::remove_dir_all(&path)?; + } + } else { + // file + index.remove(&path_wd, 0); + println!("rm '{}'", path_wd.bright_green()); + if !args.cached { + fs::remove_file(&path)?; + } + } + } + index.save(&idx_file)?; + Ok(()) +} + +/// check if pathspec is all valid(in index) +/// - if path is a dir, check if any file in the dir is in index +fn validate_pathspec(pathspec: &[String], index: &Index) -> bool { + if pathspec.is_empty() { + println!("fatal: No pathspec was given. Which files should I remove?"); + return false; + } + for path_str in pathspec.iter() { + let path = PathBuf::from(path_str); + let path_wd = path.to_workdir().to_string_or_panic(); + if !index.tracked(&path_wd, 0) { + // not tracked, but path may be a directory + // check if any tracked file in the directory + if !index.contains_dir_file(&path_wd) { + println!("fatal: pathspec '{}' did not match any files", path_str); + return false; + } + } + } + true +} + +/// run after `validate_pathspec` +fn get_dirs(pathspec: &[String], index: &Index) -> Vec { + let mut dirs = Vec::new(); + for path_str in pathspec.iter() { + let path = PathBuf::from(path_str); + let path_wd = path.to_workdir().to_string_or_panic(); + // valid but not tracked, means a dir + if !index.tracked(&path_wd, 0) { + dirs.push(path_str.clone()); + } + } + dirs +} \ No newline at end of file diff --git a/git-rs/restore.rs b/git-rs/restore.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ebc5660472a744b949108489cd2f80db428f8ad --- /dev/null +++ b/git-rs/restore.rs @@ -0,0 +1,297 @@ +use crate::internal::branch::Branch; +use crate::internal::head::Head; +use mercury::internal::index::{Index, IndexEntry}; +use crate::utils::object_ext::{BlobExt, CommitExt, TreeExt}; +use crate::utils::path_ext::PathExt; +use crate::utils::{path, util}; +use clap::Parser; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use mercury::hash::SHA1; +use mercury::internal::object::blob::Blob; +use mercury::internal::object::commit::Commit; +use mercury::internal::object::tree::Tree; +use mercury::internal::object::types::ObjectType; + +#[derive(Parser, Debug)] +pub struct RestoreArgs { + /// files or dir to restore + #[clap(required = true)] + pub pathspec: Vec, + /// source + #[clap(long, short)] + pub source: Option, + /// worktree + #[clap(long, short = 'W')] + pub worktree: bool, + /// staged + #[clap(long, short = 'S')] + pub staged: bool, +} + +pub async fn execute(args: RestoreArgs) { + if !util::check_repo_exist() { + return; + } + let staged = args.staged; + let mut worktree = args.worktree; + // If neither option is specified, by default the `working tree` is restored. + // Specifying `--staged` will only restore the `index`. Specifying both restores both. + if !staged { + worktree = true; + } + + const HEAD: &str = "HEAD"; // prevent misspelling + let mut source = args.source; + if source.is_none() && staged { + // If `--source` not specified, the contents are restored from `HEAD` if `--staged` is given, + // otherwise from the [index]. + source = Some(HEAD.to_string()); + } + + let storage = util::objects_storage(); + let target_commit: Option = match source { + None => { + assert!(!staged); // pre-processed ↑ + None // Index + } + Some(ref src) => { + // ref: prevent moving `source` + if src == HEAD { + // Default Source + Head::current_commit().await + } else if Branch::exists(src).await { + // Branch Name, e.g. master + Some(Branch::find_branch(src, None).await.unwrap().commit) + } else { + // [Commit Hash, e.g. a1b2c3d4] || [Wrong Branch Name] + let objs = storage.search(src); + // TODO hash can be `commit` or `tree` + if objs.len() != 1 || !storage.is_object_type(&objs[0], ObjectType::Commit) { + None // Wrong Commit Hash + } else { + Some(objs[0]) + } + } + } + }; + + // to workdir path + let target_blobs: Vec<(PathBuf, SHA1)> = { + // `source` has been pre-process before ↑ + if source.is_none() { + // only this situation, restore from [Index] + assert!(!staged); + let index = Index::load(path::index()).unwrap(); + index + .tracked_entries(0) + .into_iter() + .map(|entry| (PathBuf::from(&entry.name), entry.hash)) + .collect() + } else { + // restore from commit hash + if let Some(commit) = target_commit { + let tree_id = Commit::load(&commit).tree_id; + let tree = Tree::load(&tree_id); + tree.get_plain_items() + } else { + let src = source.unwrap(); + if storage.search(&src).len() != 1 { + eprintln!("fatal: could not resolve {}", src); + } else { + eprintln!("fatal: reference is not a commit: {}", src); + } + return; + } + } + }; + + // String to PathBuf + let paths = args + .pathspec + .iter() + .map(PathBuf::from) + .collect::>(); + // restore worktree and staged respectively + // The order is very important + // `restore_worktree` will decide whether to delete the file based on whether it is tracked in the index. + if worktree { + restore_worktree(&paths, &target_blobs); + } + if staged { + restore_index(&paths, &target_blobs); + } +} + +/// to HashMap +/// - `blobs`: to workdir +fn preprocess_blobs(blobs: &[(PathBuf, SHA1)]) -> HashMap { + // TODO maybe can be HashMap<&PathBuf, &SHA1> + blobs + .iter() + .map(|(path, hash)| (path.clone(), *hash)) + .collect() +} + +/// restore a blob to file +/// - `path` : to workdir +fn restore_to_file(hash: &SHA1, path: &PathBuf) { + let blob = Blob::load(hash); + let path_abs = util::workdir_to_absolute(path); + util::write_file(&blob.data, &path_abs).unwrap(); +} + +/// Get the deleted files in the worktree(vs Index), filtered by `filters` +/// - filters: absolute path or relative path to current dir +/// - target_blobs: to workdir path +fn get_worktree_deleted_files_in_filters( + filters: &Vec, + target_blobs: &HashMap, +) -> HashSet { + target_blobs // to workdir + .iter() + .filter(|(path, _)| { + let path = util::workdir_to_absolute(path); // to absolute path + !path.exists() && path.sub_of_paths(filters) // in filters & target but not in workdir + }) + .map(|(path, _)| path.clone()) + .collect() // HashSet auto deduplication +} + +/// Restore the worktree +/// - `filter`: abs or relative to current (user input) +/// - `target_blobs`: to workdir path +pub fn restore_worktree(filter: &Vec, target_blobs: &[(PathBuf, SHA1)]) { + let target_blobs = preprocess_blobs(target_blobs); + let deleted_files = get_worktree_deleted_files_in_filters(filter, &target_blobs); + + { + // validate input pathspec(filter) + for path in filter { + // abs or relative to cur + if !path.exists() { + //TODO bug problem: 路径设计大问题,全部统一为to workdir + if !target_blobs + .iter() + .any(|(p, _)| util::is_sub_path(p.workdir_to_absolute(), path)) + { + // not in target_blobs & worktree, illegal path + eprintln!( + "fatal: pathspec '{}' did not match any files", + path.display() + ); + return; // once fatal occurs, nothing should be done + } + } + } + } + + // to workdir path + let mut file_paths = util::integrate_pathspec(filter); + file_paths.extend(deleted_files); + + let index = Index::load(path::index()).unwrap(); + for path_wd in &file_paths { + let path_abs = util::workdir_to_absolute(path_wd); + if !path_abs.exists() { + // file not exist, deleted or illegal + if target_blobs.contains_key(path_wd) { + // file in target_blobs (deleted), need to restore + restore_to_file(&target_blobs[path_wd], path_wd); + } else { + // not in target_commit and workdir (illegal path), user input + unreachable!("It should be checked before"); + } + } else { + // file exists + let path_wd_str = path_wd.to_string_or_panic(); + let hash = util::calc_file_blob_hash(&path_abs).unwrap(); + if target_blobs.contains_key(path_wd) { + // both in target & worktree: 1. modified 2. same + if hash != target_blobs[path_wd] { + // modified + restore_to_file(&target_blobs[path_wd], path_wd); + } // else: same, keep + } else { + // not in target but in worktree: New file + if index.tracked(&path_wd_str, 0) { + // tracked, need to delete + fs::remove_file(&path_abs).unwrap(); + util::clear_empty_dir(&path_abs); // clean empty dir in cascade + } // else: untracked, keep + } + } + } +} + +/// Get the deleted files in the `index`(vs target_blobs), filtered by `filters` +fn get_index_deleted_files_in_filters( + index: &Index, + filters: &Vec, + target_blobs: &HashMap, +) -> HashSet { + target_blobs + .iter() + .filter(|(path_wd, _)| { + // to workdir + let path_abs = util::workdir_to_absolute(path_wd); // to absolute path + !index.tracked(&path_wd.to_string_or_panic(), 0) + && util::is_sub_of_paths(path_abs, filters) + }) + .map(|(path, _)| path.clone()) + .collect() // HashSet auto deduplication +} + +pub fn restore_index(filter: &Vec, target_blobs: &[(PathBuf, SHA1)]) { + let target_blobs = preprocess_blobs(target_blobs); + + let idx_file = path::index(); + let mut index = Index::load(&idx_file).unwrap(); + let deleted_files_index = get_index_deleted_files_in_filters(&index, filter, &target_blobs); + + let mut file_paths = util::filter_to_fit_paths(&index.tracked_files(), filter); + file_paths.extend(deleted_files_index); // maybe we should not integrate them rater than deal separately + + for path in &file_paths { + // to workdir + let path_str = path.to_string_or_panic(); + if !index.tracked(&path_str, 0) { + // file not exist in index + if target_blobs.contains_key(path) { + // file in target_blobs (deleted), need to restore + let hash = target_blobs[path]; + let blob = Blob::load(&hash); + index.add(IndexEntry::new_from_blob( + path_str, + hash, + blob.data.len() as u32, + )); + } else { + eprintln!( + "fatal: pathspec '{}' did not match any files", + path.display() + ); + continue; // TODO once fatal occurs, nothing should be done + } + } else { + // file exists in index: 1. modified 2. same 3. need to deleted + if target_blobs.contains_key(path) { + let hash = target_blobs[path]; + if !index.verify_hash(&path_str, 0, &hash) { + // modified + let blob = Blob::load(&hash); + index.update(IndexEntry::new_from_blob( + path_str, + hash, + blob.data.len() as u32, + )); + } // else: same, keep + } else { + // not in target but in index: need to delete + index.remove(&path_str, 0); // TODO all stages + } + } + } + index.save(&idx_file).unwrap(); // DO NOT forget to save +} diff --git a/git-rs/status.rs b/git-rs/status.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a1462850ba6b3bc1eb9e7f1862427eebf92f4d9 --- /dev/null +++ b/git-rs/status.rs @@ -0,0 +1,176 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use colored::Colorize; +use path_abs::PathInfo; + +use mercury::internal::object::commit::Commit; +use mercury::internal::object::tree::Tree; + +use crate::internal::head::Head; +use mercury::internal::index::Index; +use crate::utils::object_ext::{CommitExt, TreeExt}; +use crate::utils::{path, util}; + +/// path: to workdir +#[derive(Debug, Default, Clone)] +pub struct Changes { + pub new: Vec, + pub modified: Vec, + pub deleted: Vec, +} +impl Changes { + pub fn is_empty(&self) -> bool { + self.new.is_empty() && self.modified.is_empty() && self.deleted.is_empty() + } + + /// to relative path(to cur_dir) + pub fn to_relative(&self) -> Changes { + let mut change = self.clone(); + [&mut change.new, &mut change.modified, &mut change.deleted] + .into_iter() + .for_each(|paths| { + *paths = paths.iter().map(util::workdir_to_current).collect(); + }); + change + } +} + +/** + * 2 parts: + * 1. unstaged + * 2. staged to be committed + */ +pub async fn execute() { + if !util::check_repo_exist() { + return; + } + // TODO .gitignore + match Head::current().await { + Head::Detached(commit) => { + println!("HEAD detached at {}", String::from_utf8_lossy(&commit.0[0..7])); + } + Head::Branch(branch) => { + println!("On branch {}", branch); + } + } + + if Head::current_commit().await.is_none() { + println!("\nNo commits yet\n"); + } + + // to cur_dir relative path + let staged = changes_to_be_committed().await.to_relative(); + let unstaged = changes_to_be_staged().to_relative(); + if staged.is_empty() && unstaged.is_empty() { + println!("nothing to commit, working tree clean"); + return; + } + + if !staged.is_empty() { + println!("Changes to be committed:"); + println!(" use \"libra restore --staged ...\" to unstage"); + staged.deleted.iter().for_each(|f| { + let str = format!("\tdeleted: {}", f.display()); + println!("{}", str.bright_green()); + }); + staged.modified.iter().for_each(|f| { + let str = format!("\tmodified: {}", f.display()); + println!("{}", str.bright_green()); + }); + staged.new.iter().for_each(|f| { + let str = format!("\tnew file: {}", f.display()); + println!("{}", str.bright_green()); + }); + } + + if !unstaged.deleted.is_empty() || !unstaged.modified.is_empty() { + println!("Changes not staged for commit:"); + println!(" use \"libra add ...\" to update what will be committed"); + println!(" use \"libra restore ...\" to discard changes in working directory"); + unstaged.deleted.iter().for_each(|f| { + let str = format!("\tdeleted: {}", f.display()); + println!("{}", str.bright_red()); + }); + unstaged.modified.iter().for_each(|f| { + let str = format!("\tmodified: {}", f.display()); + println!("{}", str.bright_red()); + }); + } + if !unstaged.new.is_empty() { + println!("Untracked files:"); + println!(" use \"libra add ...\" to include in what will be committed"); + unstaged.new.iter().for_each(|f| { + let str = format!("\t{}", f.display()); + println!("{}", str.bright_red()); + }); + } +} + +/** + * Compare the difference between `index` and the last `Commit Tree` + */ +pub async fn changes_to_be_committed() -> Changes { + let mut changes = Changes::default(); + let index = Index::load(path::index()).unwrap(); + let head_commit = Head::current_commit().await; + let tracked_files = index.tracked_files(); + + if head_commit.is_none() { // no commit yet + changes.new = tracked_files; + return changes; + } + + let head_commit = head_commit.unwrap(); + let commit = Commit::load(&head_commit); + let tree = Tree::load(&commit.tree_id); + let tree_files = tree.get_plain_items(); + + for (item_path, item_hash) in tree_files.iter() { + let item_str = item_path.to_str().unwrap(); + if index.tracked(item_str, 0) { + if !index.verify_hash(item_str, 0, item_hash) { + changes.modified.push(item_path.clone()); + } + } else { + // in the last commit but not in the index + changes.deleted.push(item_path.clone()); + } + } + let tree_files_set: HashSet = tree_files.into_iter().map(|(path, _)| path).collect(); + // `new` means the files in index but not in the last commit + changes.new = tracked_files.into_iter() + .filter(|path| !tree_files_set.contains(path)) + .collect(); + + changes +} + +/// Compare the difference between `index` and the `workdir` +pub fn changes_to_be_staged() -> Changes { + let mut changes = Changes::default(); + let workdir = util::working_dir(); + let index = Index::load(path::index()).unwrap(); + let tracked_files = index.tracked_files(); + for file in tracked_files.iter() { + let file_str = file.to_str().unwrap(); + let file_abs = util::workdir_to_absolute(file); + if !file_abs.exists() { + changes.deleted.push(file.clone()); + } else if index.is_modified(file_str, 0, &workdir) { + // only calc the hash if the file is modified (metadata), for optimization + let file_hash = util::calc_file_blob_hash(&file_abs).unwrap(); + if !index.verify_hash(file_str, 0, &file_hash) { + changes.modified.push(file.clone()); + } + } + } + let files = util::list_workdir_files().unwrap(); // to workdir + for file in files.iter() { + if !index.tracked(file.to_str().unwrap(), 0) { + // file not tracked in `index` + changes.new.push(file.clone()); + } + } + changes +} \ No newline at end of file