diff --git a/git-rs/add.rs b/git-rs/add.rs new file mode 100644 index 0000000000000000000000000000000000000000..526092ce3e14c7c5e3a90620d746f5438a659a16 --- /dev/null +++ b/git-rs/add.rs @@ -0,0 +1,141 @@ +use std::path::{Path, PathBuf}; +use clap::Parser; +use mercury::internal::object::blob::Blob; +use crate::command::status; +use mercury::internal::index::{Index, IndexEntry}; +use crate::utils::object_ext::BlobExt; + +use crate::utils::{path, util}; + +#[derive(Parser, Debug)] +pub struct AddArgs { + /// ... files & dir to add content from. + #[clap(required = false)] + pub pathspec: Vec, + + /// Update the index not only where the working tree has a file matching but also where the index already has an entry. This adds, modifies, and removes index entries to match the working tree. + /// + /// If no is given when -A option is used, all files in the entire working tree are updated + #[clap(short = 'A', long, group = "mode")] + pub all: bool, + + /// Update the index just where it already has an entry matching . + /// This removes as well as modifies index entries to match the working tree, but adds no new files. + #[clap(short, long, group = "mode")] + pub update: bool, + + /// more detailed output + #[clap(short, long)] + pub verbose: bool, +} + +pub async fn execute(args: AddArgs) { + // TODO .gitignore + if !util::check_repo_exist() { + return; + } + + // `String` to `PathBuf` + let mut paths: Vec = args.pathspec.iter().map(PathBuf::from).collect(); + if args.pathspec.is_empty() { + if !args.all && !args.update { + println!("Nothing specified, nothing added."); + return; + } else { + // add all files in the entire working tree + paths.push(util::working_dir()); + } // '-A' and '-u' cannot be used together + } + + // index vs worktree + let mut changes = status::changes_to_be_staged(); // to workdir + // filter paths to fit `pathspec` that user inputs + changes.new = util::filter_to_fit_paths(&changes.new, &paths); + // if `--all` & is given, it will update `index` as well, so no need to filter `deleted` & `modified` + if args.pathspec.is_empty() || !args.all { + changes.modified = util::filter_to_fit_paths(&changes.modified, &paths); + changes.deleted = util::filter_to_fit_paths(&changes.deleted, &paths); + } + + let mut files = changes.modified; + files.extend(changes.deleted); + // `--update` only operates on tracked files, not including `new` files + if !args.update { + files.extend(changes.new); + } + + let index_file = path::index(); + let mut index = Index::load(&index_file).unwrap(); + for file in &files { + add_a_file(file, &mut index, args.verbose).await; + } + index.save(&index_file).unwrap(); +} + +/// `file` path must relative to the working directory +async fn add_a_file(file: &Path, index: &mut Index, verbose: bool) { + let workdir = util::working_dir(); + if !util::is_sub_path(file, &workdir) { + // file is not in the working directory + // TODO check this earlier, once fatal occurs, nothing should be done + println!("fatal: '{}' is outside workdir at '{}'", file.display(), workdir.display()); + return; + } + if util::is_sub_path(file, util::storage_path()) { + // file is in `.libra` + // Git won't print this + println!("warning: '{}' is inside '{}' repo, which will be ignored by `add`", file.display(), util::ROOT_DIR); + return; + } + + let file_abs = util::workdir_to_absolute(file); + let file_str = file.to_str().unwrap(); + if !file_abs.exists() { + if index.tracked(file_str, 0) { + // file is removed + index.remove(file_str, 0); + if verbose { + println!("removed: {}", file_str); + } + } else { + // TODO do this check earlier, once fatal occurs, nothing should be done + // file is not tracked && not exists, which means wrong pathspec + println!("fatal: pathspec '{}' did not match any files", file.display()); + } + } else { + // file exists + if !index.tracked(file_str, 0) { + // file is not tracked + let blob = Blob::from_file(&file_abs); + blob.save(); + index.add(IndexEntry::new_from_file(file, blob.id, &workdir).unwrap()); + if verbose { + println!("add(new): {}", file.display()); + } + } else { + // file is tracked, maybe modified + if index.is_modified(file_str, 0, &workdir) { + // file is modified(meta), but content may not change + let blob = Blob::from_file(&file_abs); + if !index.verify_hash(file_str, 0, &blob.id) { + // content is changed + blob.save(); + index.update(IndexEntry::new_from_file(file, blob.id, &workdir).unwrap()); + if verbose { + println!("add(modified): {}", file.display()); + } + } + } + } + } +} +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[should_panic] + fn test_args_parse_update_conflict_with_all() { + let _ = AddArgs::parse_from(["test", "-A", "-u"]); + } +} diff --git a/git-rs/branch.rs b/git-rs/branch.rs new file mode 100644 index 0000000000000000000000000000000000000000..e86f2a21a0347c73f284976f61000ec926047d37 --- /dev/null +++ b/git-rs/branch.rs @@ -0,0 +1,390 @@ +use crate::{ + internal::{branch::Branch, config::Config, head::Head}, + utils::{self, client_storage::ClientStorage}, +}; +use clap::Parser; +use colored::Colorize; +use mercury::{hash::SHA1, internal::object::commit::Commit}; + +use crate::command::load_object; + +#[derive(Parser, Debug)] +pub struct BranchArgs { + /// new branch name + #[clap(group = "sub")] + new_branch: Option, + + /// base branch name or commit hash + #[clap(requires = "new_branch")] + commit_hash: Option, + + /// list all branches + #[clap(short, long, group = "sub", default_value = "true")] + list: bool, + + /// force delete branch + #[clap(short = 'D', long, group = "sub")] + delete: Option, + + /// Set up 's tracking information + #[clap(short = 'u', long, group = "sub")] + set_upstream_to: Option, + + /// show current branch + #[clap(long, group = "sub")] + show_curren: bool, + + /// show remote branches + #[clap(short, long)] // TODO limit to required `list` option, even in default + remotes: bool, +} +pub async fn execute(args: BranchArgs) { + if args.new_branch.is_some() { + create_branch(args.new_branch.unwrap(), args.commit_hash).await; + } else if args.delete.is_some() { + delete_branch(args.delete.unwrap()).await; + } else if args.show_curren { + show_current_branch().await; + } else if args.set_upstream_to.is_some() { + match Head::current().await { + Head::Branch(name) => set_upstream(&name, &args.set_upstream_to.unwrap()).await, + Head::Detached(_) => { + eprintln!("fatal: HEAD is detached"); + return; + } + }; + } else if args.list { + // 兜底list + list_branches(args.remotes).await; + } else { + panic!("should not reach here") + } +} + +pub async fn set_upstream(branch: &str, upstream: &str) { + let branch_config = Config::branch_config(branch).await; + if branch_config.is_none() { + let (remote, remote_branch) = match upstream.split_once('/') { + Some((remote, branch)) => (remote, branch), + None => { + eprintln!("fatal: invalid upstream '{}'", upstream); + return; + } + }; + Config::insert("branch", Some(branch), "remote", remote).await; + // set upstream branch (tracking branch) + Config::insert("branch", Some(branch), "merge", + &format!("refs/heads/{}", remote_branch)).await; + } + println!("Branch '{}' set up to track remote branch '{}'", branch, upstream); +} + +pub async fn create_branch(new_branch: String, branch_or_commit: Option) { + tracing::debug!("create branch: {} from {:?}", new_branch, branch_or_commit); + + if !is_valid_git_branch_name(&new_branch) { + eprintln!("fatal: invalid branch name: {}", new_branch); + return; + } + + // check if branch exists + let branch = Branch::find_branch(&new_branch, None).await; + if branch.is_some() { + panic!("fatal: A branch named '{}' already exists.", new_branch); + } + + let commit_id = match branch_or_commit { + Some(branch_or_commit) => { + let commit = get_target_commit(&branch_or_commit).await; + match commit { + Ok(commit) => commit, + Err(e) => { + eprintln!("{}", e); + return; + } + } + } + None => Head::current_commit().await.unwrap(), + }; + tracing::debug!("base commit_id: {}", commit_id); + + // check if commit_hash exists + let _ = load_object::(&commit_id) + .unwrap_or_else(|_| panic!("fatal: not a valid object name: '{}'", commit_id)); + + // create branch + Branch::update_branch(&new_branch, &commit_id.to_plain_str(), None).await; +} + +async fn delete_branch(branch_name: String) { + let _ = Branch::find_branch(&branch_name, None) + .await + .unwrap_or_else(|| panic!("fatal: branch '{}' not found", branch_name)); + let head = Head::current().await; + + if let Head::Branch(name) = head { + if name == branch_name { + panic!( + "fatal: Cannot delete the branch '{}' which you are currently on", + branch_name + ); + } + } + + Branch::delete_branch(&branch_name, None).await; +} + +async fn show_current_branch() { + // let head = reference::Model::current_head(&db).await.unwrap(); + let head = Head::current().await; + match head { + Head::Detached(commit_hash) => { + println!("HEAD detached at {}", &commit_hash.to_plain_str()[..8]); + } + Head::Branch(name) => { + println!("{}", name); + } + } +} + +async fn list_branches(remotes: bool) { + let branches = match remotes { + true => { + // list all remote branches + let remote_configs = Config::all_remote_configs().await; + let mut branches = vec![]; + for remote in remote_configs { + let remote_branches = Branch::list_branches(Some(&remote.name)).await; + branches.extend(remote_branches); + } + branches + } + false => Branch::list_branches(None).await, + }; + + let head = Head::current().await; + if let Head::Detached(commit) = head { + let s = "HEAD detached at ".to_string() + &commit.to_plain_str()[..8]; + let s = s.green(); + println!("{}", s); + }; + let head_name = match head { + Head::Branch(name) => name, + Head::Detached(_) => "".to_string(), + }; + for branch in branches { + let name = branch + .remote + .map(|remote| remote + "/" + &branch.name) + .unwrap_or_else(|| branch.name.clone()); + + if head_name == name { + println!("* {}", name.green()); + } else { + println!(" {}", name); + }; + } +} + +pub async fn get_target_commit(branch_or_commit: &str) -> Result> { + let posible_branchs = Branch::search_branch(branch_or_commit).await; + if posible_branchs.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 posible_branchs.is_empty() { + let storage = ClientStorage::init(utils::path::objects()); + let posible_commits = storage.search(branch_or_commit); + if posible_commits.len() > 1 || posible_commits.is_empty() { + return Err( + format!("fatal: {} is not something we can merge", branch_or_commit).into(), + ); + } + Ok(posible_commits[0]) + } else { + Ok(posible_branchs[0].commit) + } +} + +fn is_valid_git_branch_name(name: &str) -> bool { + // 检查是否包含不允许的字符 + if name.contains(&[' ', '\t', '\\', ':', '"', '?', '*', '['][..]) + || name.chars().any(|c| c.is_ascii_control()) + { + return false; + } + + // 检查其他Git规则 + if name.starts_with('/') + || name.ends_with('/') + || name.ends_with('.') + || name.contains("//") + || name.contains("..") + { + return false; + } + + // 检查特殊的Git保留字 + if name == "HEAD" || name.contains("@{") { + return false; + } + + // 检查是否是空字符串或只包含点 + if name.trim().is_empty() || name.trim() == "." { + return false; + } + + true +} + +#[cfg(test)] +mod tests { + + use crate::{ + command::commit::{self, CommitArgs}, + utils::test, + }; + + use super::*; + + #[tokio::test] + async fn test_branch() { + test::setup_with_new_libra().await; + + let commit_args = CommitArgs { + message: "first".to_string(), + allow_empty: true, + }; + commit::execute(commit_args).await; + let first_commit_id = Branch::find_branch("master", None).await.unwrap().commit; + + let commit_args = CommitArgs { + message: "second".to_string(), + allow_empty: true, + }; + commit::execute(commit_args).await; + let second_commit_id = Branch::find_branch("master", None).await.unwrap().commit; + + { + // create branch with first commit + let first_branch_name = "first_branch".to_string(); + let args = BranchArgs { + new_branch: Some(first_branch_name.clone()), + commit_hash: Some(first_commit_id.to_plain_str()), + list: false, + delete: None, + set_upstream_to: None, + show_curren: false, + remotes: false, + }; + execute(args).await; + + // check branch exist + match Head::current().await { + Head::Branch(current_branch) => { + assert_ne!(current_branch, first_branch_name) + } + _ => panic!("should be branch"), + }; + + let first_branch = Branch::find_branch(&first_branch_name, None).await.unwrap(); + assert!(first_branch.commit == first_commit_id); + assert!(first_branch.name == first_branch_name); + } + + { + // create second branch with current branch + let second_branch_name = "second_branch".to_string(); + let args = BranchArgs { + new_branch: Some(second_branch_name.clone()), + commit_hash: None, + list: false, + delete: None, + set_upstream_to: None, + show_curren: false, + remotes: false, + }; + execute(args).await; + let second_branch = Branch::find_branch(&second_branch_name, None) + .await + .unwrap(); + assert!(second_branch.commit == second_commit_id); + assert!(second_branch.name == second_branch_name); + } + + // show current branch + println!("show current branch"); + let args = BranchArgs { + new_branch: None, + commit_hash: None, + list: false, + delete: None, + set_upstream_to: None, + show_curren: true, + remotes: false, + }; + execute(args).await; + + // list branches + println!("list branches"); + execute(BranchArgs::parse_from([""])).await; // default list + } + + #[tokio::test] + async fn test_create_branch_from_remote() { + test::setup_with_new_libra().await; + test::init_debug_logger(); + + let args = CommitArgs { + message: "first".to_string(), + allow_empty: true, + }; + commit::execute(args).await; + let hash = Head::current_commit().await.unwrap(); + Branch::update_branch("master", &hash.to_plain_str(), Some("origin")).await; // create remote branch + assert!(get_target_commit("origin/master").await.is_ok()); + + let args = BranchArgs { + new_branch: Some("test_new".to_string()), + commit_hash: Some("origin/master".into()), + list: false, + delete: None, + set_upstream_to: None, + show_curren: false, + remotes: false, + }; + execute(args).await; + + let branch = Branch::find_branch("test_new", None) + .await + .expect("branch create failed found"); + assert_eq!(branch.commit, hash); + } + + #[tokio::test] + async fn test_invalid_branch_name() { + test::setup_with_new_libra().await; + test::init_debug_logger(); + + let args = CommitArgs { + message: "first".to_string(), + allow_empty: true, + }; + commit::execute(args).await; + + let args = BranchArgs { + new_branch: Some("@{mega}".to_string()), + commit_hash: None, + list: false, + delete: None, + set_upstream_to: None, + show_curren: false, + remotes: false, + }; + execute(args).await; + + let branch = Branch::find_branch("new", None).await; + assert!(branch.is_none(), "invalid branch should not be created"); + } +} diff --git a/git-rs/clone.rs b/git-rs/clone.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b599ab8dac86c7436870f45f283013a83aeafff --- /dev/null +++ b/git-rs/clone.rs @@ -0,0 +1,128 @@ +use std::path::PathBuf; +use std::{env, fs}; + +use crate::command; +use crate::command::restore::RestoreArgs; +use crate::internal::branch::Branch; +use crate::internal::config::{Config, RemoteConfig}; +use crate::internal::head::Head; +use clap::Parser; + +use crate::utils::path_ext::PathExt; +use crate::utils::util; + +use super::fetch::{self}; + +const ORIGIN: &str = "origin"; // default remote name, prevent spelling mistakes + +#[derive(Parser, Debug)] +pub struct CloneArgs { + /// The remote repository location to clone from, usually a URL with HTTPS or SSH + pub remote_repo: String, + + /// The local path to clone the repository to + pub local_path: Option, +} + +pub async fn execute(args: CloneArgs) { + let mut remote_repo = args.remote_repo; // https://gitee.com/caiqihang2024/image-viewer2.0.git + // must end with '/' or Url::join will work incorrectly + if !remote_repo.ends_with('/') { + remote_repo.push('/'); + } + let local_path = args.local_path.unwrap_or_else(|| { + let repo_name = util::get_repo_name_from_url(&remote_repo).unwrap(); + util::cur_dir().join(repo_name).to_string_or_panic() + }); + + /* create local path */ + let local_path = PathBuf::from(local_path); + { + if local_path.exists() && !util::is_empty_dir(&local_path) { + eprintln!( + "fatal: destination path '{}' already exists and is not an empty directory.", + local_path.display() + ); + return; + } + + // make sure the directory exists + if let Err(e) = fs::create_dir_all(&local_path) { + eprintln!( + "fatal: could not create directory '{}': {}", + local_path.display(), + e + ); + return; + } + let repo_name = local_path.file_name().unwrap().to_str().unwrap(); + println!("Cloning into '{}'", repo_name); + } + + // CAUTION: change [current_dir] to the repo directory + env::set_current_dir(&local_path).unwrap(); + command::init::execute().await; + + /* fetch remote */ + let remote_config = RemoteConfig { + name: "origin".to_string(), + url: remote_repo.clone(), + }; + fetch::fetch_repository(&remote_config).await; + + /* setup */ + setup(remote_repo.clone()).await; +} + +async fn setup(remote_repo: String) { + // look for remote head and set local HEAD&branch + let remote_head = Head::remote_current(ORIGIN).await; + + match remote_head { + Some(Head::Branch(name)) => { + let origin_head_branch = Branch::find_branch(&name, Some(ORIGIN)) + .await + .expect("origin HEAD branch not found"); + + Branch::update_branch(&name, &origin_head_branch.commit.to_plain_str(), None).await; + Head::update(Head::Branch(name.to_owned()), None).await; + + // set config: remote.origin.url + Config::insert("remote", Some(ORIGIN), "url", &remote_repo).await; + // set config: remote.origin.fetch + // todo: temporary ignore fetch option + + // set config: branch.$name.merge, e.g. + let merge = "refs/heads/".to_owned() + &name; + Config::insert("branch", Some(&name), "merge", &merge).await; + // set config: branch.$name.remote + Config::insert("branch", Some(&name), "remote", ORIGIN).await; + + // restore all files to worktree from HEAD + command::restore::execute(RestoreArgs { + worktree: true, + staged: true, + source: None, + pathspec: vec![util::working_dir_string()], + }) + .await; + } + Some(Head::Detached(_)) => { + eprintln!("fatal: remote HEAD points to a detached commit"); + } + None => { + println!("warning: You appear to have cloned an empty repository."); + + // set config: remote.origin.url + Config::insert("remote", Some(ORIGIN), "url", &remote_repo).await; + // set config: remote.origin.fetch + // todo: temporary ignore fetch option + + // 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; + } + } +} diff --git a/git-rs/commit.rs b/git-rs/commit.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9546cc26ac189f36008380affe48503d18b072a --- /dev/null +++ b/git-rs/commit.rs @@ -0,0 +1,258 @@ +use std::str::FromStr; +use std::{collections::HashSet, path::PathBuf}; + +use crate::internal::branch::Branch; +use crate::internal::head::Head; +use crate::utils::client_storage::ClientStorage; +use crate::utils::path; +use crate::utils::util; +use mercury::internal::index::Index; +use clap::Parser; +use mercury::hash::SHA1; +use mercury::internal::object::commit::Commit; +use mercury::internal::object::tree::{Tree, TreeItem, TreeItemMode}; +use mercury::internal::object::ObjectTrait; + +use super::{format_commit_msg, save_object}; + +#[derive(Parser, Debug)] +pub struct CommitArgs { + #[arg(short, long)] + pub message: String, + + #[arg(long)] + pub allow_empty: bool, +} + +pub async fn execute(args: CommitArgs) { + /* check args */ + let index = Index::load(path::index()).unwrap(); + let storage = ClientStorage::init(path::objects()); + let tracked_entries = index.tracked_entries(0); + if tracked_entries.is_empty() && !args.allow_empty { + panic!("fatal: no changes added to commit, use --allow-empty to override"); + } + + /* Create tree */ + let tree = create_tree(&index, &storage, "".into()).await; + + /* Create & save commit objects */ + let parents_commit_ids = get_parents_ids().await; + // There must be a `blank line`(\n) before `message`, or remote unpack failed + let commit = Commit::from_tree_id(tree.id, parents_commit_ids, &format_commit_msg(&args.message, None)); + + // TODO default signature created in `from_tree_id`, wait `git config` to set correct user info + + storage + .put(&commit.id, &commit.to_data().unwrap(), commit.get_type()) + .unwrap(); + + /* update HEAD */ + update_head(&commit.id.to_plain_str()).await; +} + +/// recursively create tree from index's tracked entries +async fn create_tree(index: &Index, storage: &ClientStorage, current_root: PathBuf) -> Tree { + // blob created when add file to index + let get_blob_entry = |path: &PathBuf| { + let name = util::path_to_string(path); + let mete = index.get(&name, 0).unwrap(); + let filename = path.file_name().unwrap().to_str().unwrap().to_string(); + + TreeItem { + name: filename, + mode: TreeItemMode::tree_item_type_from_bytes(format!("{:o}", mete.mode).as_bytes()) + .unwrap(), + id: mete.hash, + } + }; + + let mut tree_items: Vec = Vec::new(); + let mut processed_path: HashSet = HashSet::new(); + let path_entries: Vec = index + .tracked_entries(0) + .iter() + .map(|file| PathBuf::from(file.name.clone())) + .filter(|path| path.starts_with(¤t_root)) + .collect(); + for path in path_entries.iter() { + let in_current_path = path.parent().unwrap() == current_root; + if in_current_path { + let item = get_blob_entry(path); + tree_items.push(item); + } else { + if path.components().count() == 1 { + continue; + } + // next level tree + let process_path = path + .components() + .nth(current_root.components().count()) + .unwrap() + .as_os_str() + .to_str() + .unwrap(); + + if processed_path.contains(process_path) { + continue; + } + processed_path.insert(process_path.to_string()); + + let sub_tree = Box::pin(create_tree( + index, + storage, + current_root.clone().join(process_path), + )) + .await; + tree_items.push(TreeItem { + name: process_path.to_string(), + mode: TreeItemMode::Tree, + id: sub_tree.id, + }); + } + } + let tree = { + // `from_tree_items` can't create empty tree, so use `from_bytes` instead + if tree_items.is_empty() { + // git create a no zero hash for empty tree, didn't know method. use default SHA1 temporarily + Tree::from_bytes(vec![], SHA1::default()).unwrap() + } else { + Tree::from_tree_items(tree_items).unwrap() + } + }; + // save + save_object(&tree, &tree.id).unwrap(); + tree +} + +/// get current head commit id as parent, if in branch, get branch's commit id, if detached head, get head's commit id +async fn get_parents_ids() -> Vec { + // let current_commit_id = reference::Model::current_commit_hash(db).await.unwrap(); + let current_commit_id = Head::current_commit().await; + match current_commit_id { + Some(id) => vec![id], + None => vec![], // first commit + } +} + +/// 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) { + // let head = reference::Model::current_head(db).await.unwrap(); + match Head::current().await { + Head::Branch(name) => { + // in branch + Branch::update_branch(&name, commit_id, None).await; + } + // None => { + Head::Detached(_) => { + let head = Head::Detached(SHA1::from_str(commit_id).unwrap()); + Head::update(head, None).await; + } + } +} + +#[cfg(test)] +mod test { + use mercury::internal::object::ObjectTrait; + + use crate::{ + command::{add::AddArgs, load_object}, + utils::test, + }; + + use super::*; + + #[tokio::test] + async fn test_create_tree() { + let index = Index::from_file("../tests/data/index/index-760").unwrap(); + println!("{:?}", index.tracked_entries(0).len()); + test::setup_with_new_libra().await; + let storage = ClientStorage::init(path::objects()); + let tree = create_tree(&index, &storage, "".into()).await; + + assert!(storage.get(&tree.id).is_ok()); + for item in tree.tree_items.iter() { + if item.mode == TreeItemMode::Tree { + assert!(storage.get(&item.id).is_ok()); + // println!("tree: {}", item.name); + if item.name == "DeveloperExperience" { + let sub_tree = storage.get(&item.id).unwrap(); + let tree = Tree::from_bytes(sub_tree.to_vec(), item.id).unwrap(); + assert!(tree.tree_items.len() == 4); // 4 sub tree according to the test data + } + } + } + } + + #[tokio::test] + #[should_panic] + async fn test_excute_commit_with_empty_index_fail() { + test::setup_with_new_libra().await; + let args = CommitArgs { + message: "init".to_string(), + allow_empty: false, + }; + execute(args).await; + } + + #[tokio::test] + async fn test_execute_commit() { + test::setup_with_new_libra().await; + // create first empty commit + { + let args = CommitArgs { + message: "init".to_string(), + allow_empty: true, + }; + execute(args).await; + + // check head branch exists + let head = Head::current().await; + let branch_name = match head { + Head::Branch(name) => name, + _ => panic!("head not in branch"), + }; + let branch = Branch::find_branch(&branch_name, None).await.unwrap(); + let commit: Commit = load_object(&branch.commit).unwrap(); + + assert!(commit.message == "init"); + let branch = Branch::find_branch(&branch_name, None).await.unwrap(); + assert!(branch.commit == commit.id); + } + + // create a new commit + { + // create `a.txt` `bb/b.txt` `bb/c.txt` + test::ensure_file("a.txt", Some("a")); + test::ensure_file("bb/b.txt", Some("b")); + test::ensure_file("bb/c.txt", Some("c")); + let args = AddArgs { + all: true, + update: false, + verbose: false, + pathspec: vec![], + }; + crate::command::add::execute(args).await; + } + + { + let args = CommitArgs { + message: "add some files".to_string(), + allow_empty: false, + }; + execute(args).await; + + let commit_id = Head::current_commit().await.unwrap(); + let commit: Commit = load_object(&commit_id).unwrap(); + assert!(commit.message == "add some files", "{}", commit.message); + + let pre_commit_id = commit.parent_commit_ids[0]; + let pre_commit: Commit = load_object(&pre_commit_id).unwrap(); + assert!(pre_commit.message == "init"); + + let tree_id = commit.tree_id; + let tree: Tree = load_object(&tree_id).unwrap(); + assert!(tree.tree_items.len() == 2); // 2 sub tree according to the test data + } + } +} diff --git a/git-rs/fetch.rs b/git-rs/fetch.rs new file mode 100644 index 0000000000000000000000000000000000000000..9ddfd5b2090707df2379646264f890879da34bae --- /dev/null +++ b/git-rs/fetch.rs @@ -0,0 +1,225 @@ +use std::{collections::HashSet, fs, io::Write}; + +use ceres::protocol::ServiceType::UploadPack; +use clap::Parser; +use futures::StreamExt; +use mercury::internal::object::commit::Commit; +use mercury::{errors::GitError, hash::SHA1}; +use url::Url; + +use crate::command::{ask_basic_auth, load_object}; +use crate::{ + command::index_pack::{self, IndexPackArgs}, + internal::{ + branch::Branch, + config::{Config, RemoteConfig}, + head::Head, + protocol::{https_client::HttpsClient, ProtocolClient}, + }, + utils::{self, path_ext::PathExt}, +}; + +#[derive(Parser, Debug)] +pub struct FetchArgs { + #[clap(long, short, group = "sub")] + repository: Option, + + #[clap(long, short, group = "sub")] + all: bool, +} + +pub async fn execute(args: FetchArgs) { + tracing::debug!("`fetch` args: {:?}", args); + tracing::warn!("didn't test yet"); + if args.all { + let remotes = Config::all_remote_configs().await; + let tasks = remotes.into_iter().map(|remote| async move { + fetch_repository(&remote).await; + }); + futures::future::join_all(tasks).await; + } else { + let remote = match args.repository { + Some(remote) => remote, + None => "origin".to_string(), // todo: get default remote + }; + let remote_config = Config::remote_config(&remote).await; + match remote_config { + Some(remote_config) => fetch_repository(&remote_config).await, + None => { + tracing::error!("remote config '{}' not found", remote); + eprintln!("fatal: '{}' does not appear to be a git repository", remote); + } + } + } +} + +pub async fn fetch_repository(remote_config: &RemoteConfig) { + println!("fetching from {}", remote_config.name); + + // fetch remote + let url = match Url::parse(&remote_config.url) { + Ok(url) => url, + Err(e) => { + eprintln!("fatal: invalid URL '{}': {}", remote_config.url, e); + return; + } + }; + let http_client = HttpsClient::from_url(&url); + + let mut refs = http_client.discovery_reference(UploadPack, None).await; + let mut auth = None; + while let Err(e) = refs { + if let GitError::UnAuthorized(_) = e { + auth = Some(ask_basic_auth()); + refs = http_client + .discovery_reference(UploadPack, auth.clone()) + .await; + } else { + eprintln!("fatal: {}", e); + return; + } + } + let refs = refs.unwrap(); + if refs.is_empty() { + tracing::warn!("fetch empty, no refs found"); + return; + } + + let want = refs + .iter() + .filter(|r| r._ref.starts_with("refs/heads")) + .map(|r| r._hash.clone()) + .collect(); + let have = current_have().await; + + let mut result_stream = http_client + .fetch_objects(&have, &want, auth.to_owned()) + .await + .unwrap(); + + let mut buffer = vec![]; + while let Some(item) = result_stream.next().await { + let item = item.unwrap(); + buffer.extend(item); + } + + // pase pkt line + if let Some(pack_pos) = buffer.windows(4).position(|w| w == b"PACK") { + tracing::info!("pack data found at: {}", pack_pos); + let readable_output = std::str::from_utf8(&buffer[..pack_pos]).unwrap(); + tracing::debug!("stdout readable: \n{}", readable_output); + tracing::info!("pack length: {}", buffer.len() - pack_pos); + assert!(buffer[pack_pos..pack_pos + 4].eq(b"PACK")); + + buffer = buffer[pack_pos..].to_vec(); + } else { + tracing::error!( + "no pack data found, stdout is: \n{}", + std::str::from_utf8(&buffer).unwrap() + ); + panic!("no pack data found"); + } + + /* save pack file */ + let pack_file = { + let hash = SHA1::new(&buffer[..buffer.len() - 20].to_vec()); + + let checksum = SHA1::from_bytes(&buffer[buffer.len() - 20..]); + assert_eq!(hash, checksum); + let checksum = checksum.to_plain_str(); + println!("checksum: {}", checksum); + + let pack_file = utils::path::objects() + .join("pack") + .join(format!("pack-{}.pack", checksum)); + let mut file = fs::File::create(pack_file.clone()).unwrap(); + file.write_all(&buffer).expect("write failed"); + + pack_file.to_string_or_panic() + }; + + /* build .idx file from PACK */ + index_pack::execute(IndexPackArgs { + pack_file, + index_file: None, + index_version: None, + }); + + /* update reference */ + for reference in refs.iter().filter(|r| r._ref.starts_with("refs/heads")) { + let branch_name = reference._ref.replace("refs/heads/", ""); + let remote = Some(remote_config.name.as_str()); + Branch::update_branch(&branch_name, &reference._hash, remote).await; + } + let remote_head = refs.iter().find(|r| r._ref == "HEAD"); + match remote_head { + Some(remote_head) => { + let remote_head_name = refs + .iter() + .find(|r| r._ref.starts_with("refs/heads") && r._hash == remote_head._hash); + + match remote_head_name { + Some(remote_head_name) => { + let remote_head_name = remote_head_name._ref.replace("refs/heads/", ""); + Head::update(Head::Branch(remote_head_name), Some(&remote_config.name)).await; + } + None => { + panic!("remote HEAD not found") + } + } + } + None => { + tracing::warn!("fetch empty, remote HEAD not found"); + } + } +} + +async fn current_have() -> Vec { + #[derive(PartialEq, Eq, PartialOrd, Ord)] + struct QueueItem { + priority: usize, + commit: SHA1, + } + let mut c_pending = std::collections::BinaryHeap::new(); + let mut inserted = HashSet::new(); + let check_and_insert = + |commit: &Commit, + inserted: &mut HashSet, + c_pending: &mut std::collections::BinaryHeap| { + if inserted.contains(&commit.id.to_plain_str()) { + return; + } + inserted.insert(commit.id.to_plain_str()); + c_pending.push(QueueItem { + priority: commit.committer.timestamp, + commit: commit.id, + }); + }; + let mut remotes = Config::all_remote_configs() + .await + .iter() + .map(|r| Some(r.name.to_owned())) + .collect::>(); + remotes.push(None); + + for remote in remotes { + let branchs = Branch::list_branches(remote.as_deref()).await; + for branch in branchs { + let commit: Commit = load_object(&branch.commit).unwrap(); + check_and_insert(&commit, &mut inserted, &mut c_pending); + } + } + let mut have = Vec::new(); + while have.len() < 32 && !c_pending.is_empty() { + let item = c_pending.pop().unwrap(); + have.push(item.commit.to_plain_str()); + + let commit: Commit = load_object(&item.commit).unwrap(); + for parent in commit.parent_commit_ids { + let parent: Commit = load_object(&parent).unwrap(); + check_and_insert(&parent, &mut inserted, &mut c_pending); + } + } + + have +} diff --git a/git-rs/index_pack.rs b/git-rs/index_pack.rs new file mode 100644 index 0000000000000000000000000000000000000000..4cc6022efcce3c0b4f9422841f85173f92659c14 --- /dev/null +++ b/git-rs/index_pack.rs @@ -0,0 +1,123 @@ +use std::collections::BTreeMap; +use std::io::Write; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use byteorder::{BigEndian, WriteBytesExt}; +use clap::Parser; +use sha1::{Digest, Sha1}; + +use mercury::internal::pack::Pack; +use mercury::errors::GitError; + +#[derive(Parser, Debug)] +pub struct IndexPackArgs { + /// Pack file path + pub pack_file: String, + /// output index file path. + /// Without this option the name of pack index file is constructed from + /// the name of packed archive file by replacing `.pack` with `.idx` + #[clap(short = 'o', required = false)] + pub index_file: Option, // Option is must, or clap will require it + + /// This is intended to be used by the test suite only. + /// It allows to force the version for the generated pack index + #[clap(long, required = false)] + pub index_version: Option, +} + +pub fn execute(args: IndexPackArgs) { + let pack_file = args.pack_file; + let index_file = args.index_file.unwrap_or_else(|| { + if !pack_file.ends_with(".pack") { + eprintln!("fatal: pack-file does not end with '.pack'"); + return String::new(); + } + pack_file.replace(".pack", ".idx") + }); + if index_file.is_empty() { + return; + } + if index_file == pack_file { + eprintln!("fatal: pack-file and index-file are the same file"); + return; + } + + if let Some(version) = args.index_version { + match version { + 1 => build_index_v1(&pack_file, &index_file).unwrap(), + 2 => println!("support later"), + _ => eprintln!("fatal: unsupported index version"), + } + } else { + // default version = 1 + build_index_v1(&pack_file, &index_file).unwrap(); + } +} + +/// Build index file for pack file, version 1 +/// [pack-format](https://git-scm.com/docs/pack-format) +pub fn build_index_v1(pack_file: &str, index_file: &str) -> Result<(), GitError> { + let pack_path = PathBuf::from(pack_file); + let tmp_path = pack_path.parent().unwrap(); + let pack_file = std::fs::File::open(pack_file)?; + let mut pack_reader = std::io::BufReader::new(pack_file); + let obj_map = Arc::new(Mutex::new(BTreeMap::new())); // sorted by hash + let obj_map_c = obj_map.clone(); + let mut pack = Pack::new(Some(8), Some(1024 * 1024 * 1024), Some(tmp_path.to_path_buf()), true); + pack.decode(&mut pack_reader, move |entry, offset| { + obj_map_c.lock().unwrap().insert(entry.hash, offset); + })?; + + let mut index_hash = Sha1::new(); + let mut index_file = std::fs::File::create(index_file)?; + // fan-out table + // The header consists of 256 4-byte network byte order integers. + // N-th entry of this table records the number of objects in the corresponding pack, + // the first byte of whose object name is less than or equal to N. + // This is called the first-level fan-out table. + let mut i: u8 = 0; + let mut cnt: u32 = 0; + let mut fanout = Vec::with_capacity(256 * 4); + let obj_map = Arc::try_unwrap(obj_map).unwrap().into_inner().unwrap(); + for (hash, _) in obj_map.iter() { // sorted + let first_byte = hash.0[0]; + while first_byte > i { // `while` rather than `if` to fill the gap, e.g. 0, 1, 2, 2, 2, 6 + fanout.write_u32::(cnt)?; + i += 1; + } + cnt += 1; + } + // fill the rest + loop { + fanout.write_u32::(cnt)?; + if i == 255 { + break; + } + i += 1; + } + index_hash.update(&fanout); + index_file.write_all(&fanout)?; + + // 4-byte network byte order integer, recording where the + // object is stored in the pack-file as the offset from the beginning. + // one object name of the appropriate size (20 bytes). + for (hash, offset) in obj_map { + let mut buf = Vec::with_capacity(24); + buf.write_u32::(offset as u32)?; + buf.write_all(&hash.0)?; + + index_hash.update(&buf); + index_file.write_all(&buf)?; + } + + index_hash.update(pack.signature.0); + // A copy of the pack checksum at the end of the corresponding pack-file. + index_file.write_all(&pack.signature.0)?; + let index_hash:[u8; 20] = index_hash.finalize().into(); + // Index checksum of all of the above. + index_file.write_all(&index_hash)?; + + tracing::debug!("Index file is written to {:?}", index_file); + Ok(()) +} \ No newline at end of file diff --git a/git-rs/init.rs b/git-rs/init.rs new file mode 100644 index 0000000000000000000000000000000000000000..8824b19cf7902651d1ea25096c79cc92ce1c5096 --- /dev/null +++ b/git-rs/init.rs @@ -0,0 +1,123 @@ +use crate::internal::db; +use crate::internal::model::{config, reference}; +use crate::utils::util::{DATABASE, ROOT_DIR}; +use sea_orm::{ActiveModelTrait, DbConn, DbErr, Set, TransactionTrait}; +use std::{env, fs, io}; + +pub async fn execute() { + init().await.unwrap(); +} + +/// Initialize a new Libra repository +#[allow(dead_code)] +pub async fn init() -> io::Result<()> { + let cur_dir = env::current_dir()?; + let root_dir = cur_dir.join(ROOT_DIR); + if root_dir.exists() { + println!("Already initialized - [{}]", root_dir.display()); + return Ok(()); + } + + // create .libra & sub-dirs + let dirs = ["objects/pack", "objects/info", "info"]; + for dir in dirs { + fs::create_dir_all(root_dir.join(dir))?; + } + // create info/exclude + // `include_str!` includes the file content while compiling + fs::write( + root_dir.join("info/exclude"), + include_str!("../../template/exclude"), + )?; + // create .libra/description + fs::write( + root_dir.join("description"), + include_str!("../../template/description"), + )?; + + // create database: .libra/libra.db + let database = root_dir.join(DATABASE); + let conn = db::create_database(database.to_str().unwrap()).await?; + + // create config table + init_config(&conn).await.unwrap(); + + // create HEAD + reference::ActiveModel { + name: Set(Some("master".to_owned())), + kind: Set(reference::ConfigKind::Head), + ..Default::default() // all others are `NotSet` + } + .insert(&conn) + .await + .unwrap(); + + // set .libra as hidden + set_dir_hidden(root_dir.to_str().unwrap())?; + println!( + "Initializing empty Libra repository in {}", + root_dir.display() + ); + Ok(()) +} + +async fn init_config(conn: &DbConn) -> Result<(), DbErr> { + let txn = conn.begin().await?; + + #[cfg(not(target_os = "windows"))] + let entries = [ + ("repositoryformatversion", "0"), + ("filemode", "true"), + ("bare", "false"), + ("logallrefupdates", "true"), + ]; + + #[cfg(target_os = "windows")] + let entries = [ + ("repositoryformatversion", "0"), + ("filemode", "false"), // no filemode on windows + ("bare", "false"), + ("logallrefupdates", "true"), + ("symlinks", "false"), // no symlinks on windows + ("ignorecase", "true"), // ignorecase on windows + ]; + + for (key, value) in entries { + // tip: Set(None) == NotSet == default == NULL + let entry = config::ActiveModel { + configuration: Set("core".to_owned()), + key: Set(key.to_owned()), + value: Set(value.to_owned()), + ..Default::default() // id & name NotSet + }; + entry.insert(&txn).await?; + } + txn.commit().await?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn set_dir_hidden(dir: &str) -> io::Result<()> { + use std::process::Command; + Command::new("attrib").arg("+H").arg(dir).spawn()?.wait()?; // 等待命令执行完成 + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn set_dir_hidden(_dir: &str) -> io::Result<()> { + // on unix-like systems, dotfiles are hidden by default + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::test; + + #[tokio::test] + async fn test_init() { + test::setup_without_libra(); + init().await.unwrap(); + // TODO check the result + } +} diff --git a/git-rs/log.rs b/git-rs/log.rs new file mode 100644 index 0000000000000000000000000000000000000000..884963975439960d2ac59d692ce7ad64cd7f4dc7 --- /dev/null +++ b/git-rs/log.rs @@ -0,0 +1,215 @@ +use std::cmp::min; +use std::collections::HashSet; + +use crate::command::load_object; +use crate::internal::branch::Branch; +use crate::internal::head::Head; +use clap::Parser; +use colored::Colorize; +#[cfg(unix)] +use std::io::Write; +#[cfg(unix)] +use std::process::{Command, Stdio}; + +use std::collections::VecDeque; +use std::str::FromStr; +use mercury::hash::SHA1; +use mercury::internal::object::commit::Commit; + +use super::parse_commit_msg; +#[derive(Parser, Debug)] +pub struct LogArgs { + /// Limit the number of output + #[clap(short, long)] + pub number: Option, +} + +/// Get all reachable commits from the given commit hash +/// **didn't consider the order of the commits** +pub async fn get_reachable_commits(commit_hash: String) -> Vec { + let mut queue = VecDeque::new(); + let mut commit_set: HashSet = HashSet::new(); // to avoid duplicate commits because of circular reference + let mut reachable_commits: Vec = Vec::new(); + queue.push_back(commit_hash); + + while !queue.is_empty() { + let commit_id = queue.pop_front().unwrap(); + let commit_id_hash = SHA1::from_str(&commit_id).unwrap(); + let commit = load_object::(&commit_id_hash) + .expect("fatal: storage broken, object not found"); + if commit_set.contains(&commit_id) { + continue; + } + commit_set.insert(commit_id); + + let parent_commit_ids = commit.parent_commit_ids.clone(); + for parent_commit_id in parent_commit_ids { + queue.push_back(parent_commit_id.to_plain_str()); + } + reachable_commits.push(commit); + } + reachable_commits +} + +pub async fn execute(args: LogArgs) { + #[cfg(unix)] + let mut process = Command::new("less") // create a pipe to less + .arg("-R") // raw control characters + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .spawn() + .expect("failed to execute process"); + + let head = Head::current().await; + // check if the current branch has any commits + if let Head::Branch(branch_name) = head.to_owned() { + let branch = Branch::find_branch(&branch_name, None).await; + if branch.is_none() { + panic!( + "fatal: your current branch '{}' does not have any commits yet ", + branch_name + ); + } + } + + let commit_hash = Head::current_commit().await.unwrap().to_plain_str(); + + let mut reachable_commits = get_reachable_commits(commit_hash.clone()).await; + // default sort with signature time + reachable_commits.sort_by(|a, b| b.committer.timestamp.cmp(&a.committer.timestamp)); + + let max_output_number = min(args.number.unwrap_or(usize::MAX), reachable_commits.len()); + let mut output_number = 0; + for commit in reachable_commits { + if output_number >= max_output_number { + break; + } + output_number += 1; + let mut message = { + let mut message = format!( + "{} {}", + "commit".yellow(), + &commit.id.to_plain_str().yellow() + ); + + // TODO other branch's head should shown branch name + if output_number == 1 { + message = format!("{} {}{}", message, "(".yellow(), "HEAD".blue()); + if let Head::Branch(name) = head.to_owned() { + // message += &"-> ".blue(); + // message += &head.name.as_ref().unwrap().green(); + message = format!("{}{}{}", message, " -> ".blue(), name.green()); + } + message = format!("{}{}", message, ")".yellow()); + } + message + }; + message.push_str(&format!("\nAuthor: {}", commit.author)); + let (msg, _) = parse_commit_msg(&commit.message); + message.push_str(&format!("\n{}\n", msg)); + + #[cfg(unix)] + { + if let Some(ref mut stdin) = process.stdin { + writeln!(stdin, "{}", message).unwrap(); + } else { + eprintln!("Failed to capture stdin"); + } + } + #[cfg(not(unix))] + { + println!("{}", message); + } + } + #[cfg(unix)] + { + let _ = process.wait().expect("failed to wait on child"); + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::{command::save_object, utils::test}; + use mercury::{hash::SHA1, internal::object::commit::Commit}; + + #[tokio::test] + async fn test_get_reachable_commits() { + test::setup_with_new_libra().await; + let commit_id = create_test_commit_tree().await; + + let reachable_commits = get_reachable_commits(commit_id).await; + assert_eq!(reachable_commits.len(), 6); + } + + #[tokio::test] + async fn test_execute_log() { + test::setup_with_new_libra().await; + let _ = create_test_commit_tree().await; + + let args = LogArgs { number: Some(6) }; + execute(args).await; + } + + /// create a test commit tree structure as graph and create branch (master) head to commit 6 + /// return a commit hash of commit 6 + /// 3 6 + /// / \ / + /// 1 -- 2 5 + // \ / \ + /// 4 7 + async fn create_test_commit_tree() -> String { + let mut commit_1 = Commit::from_tree_id(SHA1::new(&vec![1; 20]), vec![], "Commit_1"); + commit_1.committer.timestamp = 1; + // save_object(&commit_1); + save_object(&commit_1, &commit_1.id).unwrap(); + + let mut commit_2 = + Commit::from_tree_id(SHA1::new(&vec![2; 20]), vec![commit_1.id], "Commit_2"); + commit_2.committer.timestamp = 2; + save_object(&commit_2, &commit_2.id).unwrap(); + + let mut commit_3 = + Commit::from_tree_id(SHA1::new(&vec![3; 20]), vec![commit_2.id], "Commit_3"); + commit_3.committer.timestamp = 3; + save_object(&commit_3, &commit_3.id).unwrap(); + + let mut commit_4 = + Commit::from_tree_id(SHA1::new(&vec![4; 20]), vec![commit_2.id], "Commit_4"); + commit_4.committer.timestamp = 4; + save_object(&commit_4, &commit_4.id).unwrap(); + + let mut commit_5 = Commit::from_tree_id( + SHA1::new(&vec![5; 20]), + vec![commit_2.id, commit_4.id], + "Commit_5", + ); + commit_5.committer.timestamp = 5; + save_object(&commit_5, &commit_5.id).unwrap(); + + let mut commit_6 = Commit::from_tree_id( + SHA1::new(&vec![6; 20]), + vec![commit_3.id, commit_5.id], + "Commit_6", + ); + commit_6.committer.timestamp = 6; + save_object(&commit_6, &commit_6.id).unwrap(); + + let mut commit_7 = + Commit::from_tree_id(SHA1::new(&vec![7; 20]), vec![commit_5.id], "Commit_7"); + commit_7.committer.timestamp = 7; + save_object(&commit_7, &commit_7.id).unwrap(); + + // set current branch head to commit 6 + let head = Head::current().await; + let branch_name = match head { + Head::Branch(name) => name, + _ => panic!("should be branch"), + }; + + Branch::update_branch(&branch_name, &commit_6.id.to_plain_str(), None).await; + + commit_6.id.to_plain_str() + } +}