From da9e13ba2a0c093d3b498d785e477a5dc2dc3967 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:07 +0700 Subject: [PATCH 01/23] feat(codec): support tag signature encoding --- src/codec/binary.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/codec/binary.rs b/src/codec/binary.rs index 5ecd11e..37ad940 100644 --- a/src/codec/binary.rs +++ b/src/codec/binary.rs @@ -94,7 +94,7 @@ impl Encoder for BinaryEncoder { } fn encode_tag(&self, tag: &Tag, buf: &mut Vec) -> Result<(), VctrlError> { - buf.push(1u8); + buf.push(2u8); buf.extend_from_slice(tag.target.as_bytes()); write_user(&tag.tagger, buf)?; let ts = tag.timestamp.timestamp(); @@ -106,6 +106,14 @@ impl Encoder for BinaryEncoder { .map_err(|_| VctrlError::Other("tag message too long".into()))?; buf.extend_from_slice(&msg_len.to_be_bytes()); buf.extend_from_slice(msg); + if let Some(sig) = &tag.signature { + let sig_len = u32::try_from(sig.len()) + .map_err(|_| VctrlError::Other("signature too long".into()))?; + buf.extend_from_slice(&sig_len.to_be_bytes()); + buf.extend_from_slice(sig); + } else { + buf.extend_from_slice(&0u32.to_be_bytes()); + } Ok(()) } } @@ -298,7 +306,7 @@ impl Decoder for BinaryDecoder { let version = cursor .read_u8() .map_err(|e| VctrlError::Other(e.to_string()))?; - if version != 1 { + if version != 1 && version != 2 { return Err(VctrlError::Other("unsupported tag version".into())); } let mut target_hash = [0u8; 64]; @@ -328,11 +336,33 @@ impl Decoder for BinaryDecoder { .read_exact(&mut msg_bytes) .map_err(|e| VctrlError::Other(e.to_string()))?; let message = String::from_utf8(msg_bytes).map_err(|e| VctrlError::Other(e.to_string()))?; + + let signature = if version >= 2 { + let sig_len = cursor + .read_u32::() + .map_err(|e| VctrlError::Other(e.to_string()))?; + if sig_len > MAX_SIG_LEN { + return Err(VctrlError::Corrupted("signature too long".into())); + } + if sig_len == 0 { + None + } else { + let mut sig = vec![0u8; sig_len as usize]; + cursor + .read_exact(&mut sig) + .map_err(|e| VctrlError::Other(e.to_string()))?; + Some(sig) + } + } else { + None + }; + Ok(Tag { target, tagger, timestamp, message, + signature, }) } } From 1f01d5e093ac3255eefbb0031aa2acd330fdeda6 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 02/23] feat(branch): add list branches command --- src/command/branch.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/command/branch.rs b/src/command/branch.rs index 7794e47..a8ee65e 100644 --- a/src/command/branch.rs +++ b/src/command/branch.rs @@ -91,3 +91,27 @@ impl Command for SetHead { } } } + +pub struct ListBranches; + +impl Command for ListBranches { + type Output = Vec<(String, Hash, bool)>; + + fn execute( + &self, + _store: &mut dyn ObjectStore, + refs: &mut dyn RefStore, + ) -> Result, VctrlError> { + let active_branch = refs.head_ref_name()?; + let branch_refs = refs.list_refs("refs/heads/")?; + let mut result = Vec::new(); + for ref_name in branch_refs { + if let Some(hash) = refs.get_ref(&ref_name)? { + let short_name = ref_name.trim_start_matches("refs/heads/").to_string(); + let is_active = Some(ref_name.clone()) == active_branch; + result.push((short_name, hash, is_active)); + } + } + Ok(result) + } +} From 2fc3e9852157979872fae6c6e0b4fa0fe3dd12f6 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 03/23] feat(command): register new command modules --- src/command/mod.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/command/mod.rs b/src/command/mod.rs index 0cf060f..af55c90 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -13,8 +13,20 @@ pub mod merge_branch; pub use merge_branch::*; pub mod blame; pub mod diff; +pub mod log_graph; pub mod stash; - +pub use log_graph::*; +pub mod verify_tag; +pub use verify_tag::*; +pub mod describe; +pub use describe::*; +pub mod octopus_merge; +pub use octopus_merge::*; +pub mod rebase; +pub use rebase::*; +pub mod fsck; +pub use fsck::*; +pub mod show; pub use blame::*; pub use branch::*; pub use checkout::*; @@ -24,6 +36,7 @@ pub use diff::*; pub use log::*; pub use merge::*; pub use revert::*; +pub use show::*; pub use stash::*; pub use tag_cmd::*; pub use verify_commit::*; From fec82bdf0849cc660312fdff63e25baabdcfd689 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 04/23] feat(tag): add signing support for tags --- src/command/tag_cmd.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/command/tag_cmd.rs b/src/command/tag_cmd.rs index ccbc43e..eff1d45 100644 --- a/src/command/tag_cmd.rs +++ b/src/command/tag_cmd.rs @@ -1,5 +1,6 @@ use crate::codec::Encoder; use crate::command::Command; +use crate::crypto::Signer; use crate::domain::hash::Hash; use crate::domain::object::Object; use crate::domain::tag::Tag; @@ -32,16 +33,26 @@ pub struct CreateAnnotatedTag { pub message: String, pub encoder: Box, pub hasher: Box, + pub signer: Option>, } - impl Command for CreateAnnotatedTag { type Output = Hash; + fn execute( &self, store: &mut dyn ObjectStore, refs: &mut dyn RefStore, ) -> Result { - let tag = Tag::new(self.target, self.tagger.clone(), self.message.clone()); + let mut tag = Tag::new(self.target, self.tagger.clone(), self.message.clone()); + + if let Some(signer) = &self.signer { + let mut buf = Vec::new(); + self.encoder.encode_tag(&tag, &mut buf)?; + let pre_sig_hash = self.hasher.hash_tag_encoded(&buf); + let sig = signer.sign(pre_sig_hash.as_bytes())?; + tag.signature = Some(sig); + } + let mut buf = Vec::new(); self.encoder.encode_tag(&tag, &mut buf)?; let hash = self.hasher.hash_tag_encoded(&buf); From 75f8ffbe9e277e730d25b5606bdf7adc81389f14 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 05/23] feat(domain): add signature field to tag --- src/domain/tag.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/domain/tag.rs b/src/domain/tag.rs index f503a01..490db43 100644 --- a/src/domain/tag.rs +++ b/src/domain/tag.rs @@ -9,6 +9,7 @@ pub struct Tag { pub tagger: UserID, pub timestamp: DateTime, pub message: String, + pub signature: Option>, } impl Tag { @@ -18,6 +19,7 @@ impl Tag { tagger, timestamp: Utc::now(), message, + signature: None, } } } From 7803b8c428561e7249743126bccb8dc85ef103c1 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 06/23] feat(error): add Unsupported error variant --- src/error.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/error.rs b/src/error.rs index e49f17f..6895042 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,6 +24,8 @@ pub enum VctrlError { Other(String), #[error("data corrupted: {0}")] Corrupted(String), + #[error("operation not supported: {0}")] + Unsupported(String), } impl From for VctrlError { fn from(e: serde_json::Error) -> Self { From 3c64e576f267d932a0f493d9245f7e60c6408396 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 07/23] feat(gc): implement garbage collection removal --- src/gc.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/gc.rs b/src/gc.rs index cbfedb0..40c89cc 100644 --- a/src/gc.rs +++ b/src/gc.rs @@ -47,10 +47,18 @@ pub fn mark_reachable( Ok(reachable) } -pub fn count_reachable( - store: &mut dyn ObjectStore, - refs: &dyn RefStore, -) -> Result { +pub fn gc(store: &mut dyn ObjectStore, refs: &dyn RefStore) -> Result { let reachable = mark_reachable(store, refs)?; - Ok(reachable.len()) + let all = store.all_hashes()?; + let mut removed = 0; + for hash in all { + if !reachable.contains(&hash) { + match store.remove(&hash) { + Ok(()) => removed += 1, + Err(VctrlError::Unsupported(_)) => {} + Err(e) => return Err(e), + } + } + } + Ok(removed) } From f79c1613a51411d87ed6739f512a9cc430a8b6ae Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 08/23] feat(lib): re-export crypto and reflog modules --- src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 4eb8a20..0ec5b0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod codec; pub mod command; +pub mod crypto; pub mod diff; pub mod domain; pub mod error; @@ -11,9 +12,10 @@ pub mod patch; pub mod revwalk; pub mod storage; pub mod transport; - pub use codec::*; pub use command::*; +pub use crypto::*; +pub mod reflog; pub use diff::*; pub use domain::*; pub use error::*; @@ -21,4 +23,5 @@ pub use hashing::*; pub use index::*; pub use merge::*; pub use patch::*; +pub use reflog::*; pub use storage::*; From ff4ec546bdb5b265c281e2a55f8bc0831aac9e17 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:12 +0700 Subject: [PATCH 09/23] feat(merge): add merge strategy trait --- src/merge/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/merge/mod.rs b/src/merge/mod.rs index 6920874..e9b6ec0 100644 --- a/src/merge/mod.rs +++ b/src/merge/mod.rs @@ -4,12 +4,13 @@ pub use resolver::*; pub use three_way::*; pub mod base; pub use base::*; - +pub mod strategy; use crate::codec::Encoder; use crate::domain::hash::Hash; use crate::error::VctrlError; use crate::hashing::Hasher; use crate::storage::traits::ObjectStore; +pub use strategy::*; #[allow(clippy::too_many_arguments)] pub trait ThreeWayMerge { From b20cb349688d21aabfbe51d757204a56079d6bc0 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 10/23] feat(storage): add all_hashes and remove methods --- src/storage/file_store.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/storage/file_store.rs b/src/storage/file_store.rs index f9cb97d..7752925 100644 --- a/src/storage/file_store.rs +++ b/src/storage/file_store.rs @@ -243,6 +243,16 @@ impl ObjectStore for FileStore { fn exists(&self, hash: &Hash) -> Result { Ok(self.objects.contains_key(hash)) } + fn all_hashes(&self) -> Result, VctrlError> { + Ok(self.objects.keys().copied().collect()) + } + fn remove(&mut self, hash: &Hash) -> Result<(), VctrlError> { + if self.objects.remove(hash).is_some() { + Ok(()) + } else { + Err(VctrlError::NotFound(format!("object '{}' not found", hash))) + } + } } impl RefStore for FileStore { From a21c6b4a406c4efa7640c7a62832be645a7c67db Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 11/23] feat(storage): add reflog and object removal --- src/storage/memory.rs | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/storage/memory.rs b/src/storage/memory.rs index 44d7fed..ed84cb7 100644 --- a/src/storage/memory.rs +++ b/src/storage/memory.rs @@ -1,12 +1,14 @@ use crate::domain::hash::Hash; use crate::domain::object::Object; use crate::error::VctrlError; +use crate::reflog::ReflogEntry; use crate::storage::traits::{ObjectStore, RefStore}; use std::collections::HashMap; pub struct MemoryStore { objects: HashMap, } + impl MemoryStore { pub fn new() -> Self { Self { @@ -30,17 +32,27 @@ impl ObjectStore for MemoryStore { fn exists(&self, hash: &Hash) -> Result { Ok(self.objects.contains_key(hash)) } + fn all_hashes(&self) -> Result, VctrlError> { + Ok(self.objects.keys().copied().collect()) + } + fn remove(&mut self, hash: &Hash) -> Result<(), VctrlError> { + self.objects.remove(hash); + Ok(()) + } } pub struct MemoryRefStore { refs: HashMap, head: Option, + reflog: Vec, } + impl MemoryRefStore { pub fn new() -> Self { Self { refs: HashMap::new(), head: None, + reflog: Vec::new(), } } } @@ -52,7 +64,15 @@ impl Default for MemoryRefStore { impl RefStore for MemoryRefStore { fn set_ref(&mut self, name: &str, hash: &Hash) -> Result<(), VctrlError> { + let old = self.refs.get(name).copied(); self.refs.insert(name.to_string(), *hash); + self.reflog.push(ReflogEntry { + ref_name: name.to_string(), + old_hash: old, + new_hash: *hash, + timestamp: chrono::Utc::now(), + message: format!("set_ref {}", name), + }); Ok(()) } fn get_ref(&self, name: &str) -> Result, VctrlError> { @@ -94,3 +114,30 @@ impl RefStore for MemoryRefStore { .collect()) } } + +impl crate::reflog::ReflogStore for MemoryRefStore { + fn log_ref_update( + &mut self, + ref_name: &str, + old_hash: Option, + new_hash: Hash, + message: &str, + ) -> Result<(), VctrlError> { + self.reflog.push(ReflogEntry { + ref_name: ref_name.to_string(), + old_hash, + new_hash, + timestamp: chrono::Utc::now(), + message: message.to_string(), + }); + Ok(()) + } + fn reflog(&self, ref_name: &str) -> Result, VctrlError> { + Ok(self + .reflog + .iter() + .filter(|e| e.ref_name == ref_name) + .cloned() + .collect()) + } +} From af520c84ea8e291734e5acb295cbbe8f40a89491 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 12/23] feat(storage): extend ObjectStore with all_hashes and remove --- src/storage/traits.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/storage/traits.rs b/src/storage/traits.rs index 16e5642..587bf2e 100644 --- a/src/storage/traits.rs +++ b/src/storage/traits.rs @@ -9,6 +9,8 @@ pub trait ObjectStore { fn put(&mut self, hash: &Hash, obj: &Object) -> Result<(), VctrlError>; fn get(&self, hash: &Hash) -> Result, VctrlError>; fn exists(&self, hash: &Hash) -> Result; + fn all_hashes(&self) -> Result, VctrlError>; + fn remove(&mut self, hash: &Hash) -> Result<(), VctrlError>; } pub trait RefStore { From a5b473706f44630d92b02214b09649d11b29ec8b Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 13/23] test(tag): update test for signer option --- tests/tag_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tag_tests.rs b/tests/tag_tests.rs index b71cd95..5832f69 100644 --- a/tests/tag_tests.rs +++ b/tests/tag_tests.rs @@ -33,6 +33,7 @@ fn test_annotated_tag() { message: "Release v2.0".into(), encoder: Box::new(encoder()), hasher: Box::new(hasher()), + signer: None, }; let tag_hash = cmd.execute(&mut store, &mut refs).unwrap(); assert!(store.exists(&tag_hash).unwrap()); From 793d17b08426c0ad3309ec1d1ee5d2dc15810dcd Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 14/23] feat(describe): add describe command --- src/command/describe.rs | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/command/describe.rs diff --git a/src/command/describe.rs b/src/command/describe.rs new file mode 100644 index 0000000..053589a --- /dev/null +++ b/src/command/describe.rs @@ -0,0 +1,86 @@ +use crate::command::Command; +use crate::domain::hash::Hash; +use crate::domain::object::Object; +use crate::error::VctrlError; +use crate::storage::traits::{ObjectStore, RefStore}; +use std::collections::{HashSet, VecDeque}; + +pub struct Describe { + pub commit_hash: Hash, + pub max_commits_to_search: usize, +} + +impl Command for Describe { + type Output = Option; + + fn execute( + &self, + store: &mut dyn ObjectStore, + refs: &mut dyn RefStore, + ) -> Result, VctrlError> { + let tag_refs = refs.list_refs("refs/tags/")?; + let mut tag_map: Vec<(Hash, String)> = Vec::new(); + for ref_name in &tag_refs { + if let Some(hash) = refs.get_ref(ref_name)? { + let commit_hash = match store.get(&hash)? { + Some(Object::Tag(t)) => t.target, + Some(Object::Commit(_)) => hash, + _ => continue, + }; + let tag_name = ref_name.trim_start_matches("refs/tags/").to_string(); + tag_map.push((commit_hash, tag_name)); + } + } + + if tag_map.is_empty() { + return Ok(None); + } + + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back((self.commit_hash, 0usize)); + visited.insert(self.commit_hash); + + let mut found: Option<(String, usize)> = None; + + while let Some((hash, dist)) = queue.pop_front() { + for (commit_hash, tag_name) in &tag_map { + if *commit_hash == hash && (found.is_none() || dist < found.as_ref().unwrap().1) { + found = Some((tag_name.clone(), dist)); + } + } + if found.is_some() { + break; + } + + if dist >= self.max_commits_to_search { + continue; + } + + if let Some(Object::Commit(commit)) = store.get(&hash)? { + for parent in &commit.parents { + if visited.insert(*parent) { + queue.push_back((*parent, dist + 1)); + } + } + } + } + + if let Some((tag_name, dist)) = found { + let mut desc = tag_name; + if dist > 0 { + desc.push_str(&format!("-{}", dist)); + } + let short_hash = self.commit_hash.to_hex(); + let short = if short_hash.len() >= 8 { + &short_hash[..8] + } else { + &short_hash + }; + desc.push_str(&format!("-g{}", short)); + Ok(Some(desc)) + } else { + Ok(None) + } + } +} From 75d535d8a7ebfff16e27cde758ac11d3c78fb44e Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 15/23] feat(fsck): add fsck command --- src/command/fsck.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/command/fsck.rs diff --git a/src/command/fsck.rs b/src/command/fsck.rs new file mode 100644 index 0000000..861d8da --- /dev/null +++ b/src/command/fsck.rs @@ -0,0 +1,32 @@ +use crate::codec::Encoder; +use crate::command::Command; +use crate::error::VctrlError; +use crate::hashing::Hasher; +use crate::storage::traits::{ObjectStore, ObjectStoreExt, RefStore}; + +pub struct Fsck { + pub encoder: Box, + pub hasher: Box, +} + +impl Command for Fsck { + type Output = Vec; + + fn execute( + &self, + store: &mut dyn ObjectStore, + _refs: &mut dyn RefStore, + ) -> Result, VctrlError> { + let hashes = match store.all_hashes() { + Ok(h) => h, + Err(e) => return Ok(vec![e]), + }; + let mut errors = Vec::new(); + for hash in hashes { + if let Err(e) = store.get_verified(&hash, self.encoder.as_ref(), self.hasher.as_ref()) { + errors.push(e); + } + } + Ok(errors) + } +} From 6631014a74207bc248d55eb0f1b4ede2c7a28931 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 16/23] feat(log): add log graph command --- src/command/log_graph.rs | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/command/log_graph.rs diff --git a/src/command/log_graph.rs b/src/command/log_graph.rs new file mode 100644 index 0000000..d9fe5b6 --- /dev/null +++ b/src/command/log_graph.rs @@ -0,0 +1,69 @@ +use crate::command::Command; +use crate::domain::commit::Commit; +use crate::domain::hash::Hash; +use crate::domain::user::UserID; +use crate::error::VctrlError; +use crate::revwalk::RevWalk; +use crate::storage::traits::{ObjectStore, RefStore}; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct GraphCommit { + pub hash: Hash, + pub message: String, + pub author: UserID, + pub timestamp: DateTime, + pub parent_indices: Vec, +} + +pub struct LogGraph { + pub head: Hash, + pub encoder: Box, + pub hasher: Box, +} + +impl Command for LogGraph { + type Output = Vec; + + fn execute( + &self, + store: &mut dyn ObjectStore, + _refs: &mut dyn RefStore, + ) -> Result, VctrlError> { + let walk = RevWalk::new(store, &[self.head])?; + let commits: Vec = walk.collect::, _>>()?; + + let mut hashes = Vec::with_capacity(commits.len()); + for c in &commits { + let mut buf = Vec::new(); + self.encoder.encode_commit(c, &mut buf)?; + let hash = self.hasher.hash_commit_encoded(&buf); + hashes.push(hash); + } + + let hash_to_idx: HashMap = + hashes.iter().enumerate().map(|(i, h)| (*h, i)).collect(); + + let graph_commits = commits + .into_iter() + .enumerate() + .map(|(i, c)| { + let parent_indices = c + .parents + .iter() + .filter_map(|p| hash_to_idx.get(p).copied()) + .collect(); + GraphCommit { + hash: hashes[i], + message: c.message, + author: c.author, + timestamp: c.timestamp, + parent_indices, + } + }) + .collect(); + + Ok(graph_commits) + } +} From a6ddcbb81f1c029f4fb7a6d5cbae8c6dfaa7a237 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 17/23] feat(merge): add octopus merge command --- src/command/octopus_merge.rs | 100 +++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/command/octopus_merge.rs diff --git a/src/command/octopus_merge.rs b/src/command/octopus_merge.rs new file mode 100644 index 0000000..aab9c03 --- /dev/null +++ b/src/command/octopus_merge.rs @@ -0,0 +1,100 @@ +use crate::codec::Encoder; +use crate::command::Command; +use crate::domain::hash::Hash; +use crate::domain::object::Object; +use crate::domain::user::UserID; +use crate::error::VctrlError; +use crate::hashing::Hasher; +use crate::merge::{ConflictResolver, ThreeWayMerge, find_merge_base}; +use crate::storage::traits::{ObjectStore, ObjectStoreExt, RefStore}; + +pub struct OctopusMerge { + pub branch_names: Vec, + pub author: UserID, + pub committer: UserID, + pub merger: Box, + pub resolver: Box, + pub encoder: Box, + pub hasher: Box, +} + +impl Command for OctopusMerge { + type Output = Hash; + + fn execute( + &self, + store: &mut dyn ObjectStore, + refs: &mut dyn RefStore, + ) -> Result { + if self.branch_names.len() < 2 { + return Err(VctrlError::Other( + "octopus merge requires at least 2 branches".into(), + )); + } + + let head_hash = refs + .head()? + .ok_or_else(|| VctrlError::Other("no HEAD".into()))?; + let mut theirs_hashes = Vec::new(); + for name in &self.branch_names { + theirs_hashes.push( + refs.get_ref(name)? + .ok_or_else(|| VctrlError::NotFound(format!("branch '{}' not found", name)))?, + ); + } + + let head_commit = store.get_commit(&head_hash)?; + let mut current_tree = store.get_tree(&head_commit.tree)?; + let mut parents = vec![head_hash]; + + for theirs_hash in &theirs_hashes { + let base = find_merge_base(store, head_hash, *theirs_hash)? + .ok_or_else(|| VctrlError::Other("no common ancestor".into()))?; + + let mut buf = Vec::new(); + self.encoder.encode_tree(¤t_tree, &mut buf)?; + let our_tree_hash = self.hasher.hash_tree_encoded(&buf); + if !store.exists(&our_tree_hash)? { + store.put(&our_tree_hash, &Object::Tree(current_tree.clone()))?; + } + + let merged_tree_hash = self.merger.merge( + store, + &base, + &our_tree_hash, + theirs_hash, + self.resolver.as_ref(), + self.encoder.as_ref(), + self.hasher.as_ref(), + )?; + current_tree = store.get_tree(&merged_tree_hash)?; + parents.push(*theirs_hash); + } + + let mut buf = Vec::new(); + self.encoder.encode_tree(¤t_tree, &mut buf)?; + let final_tree_hash = self.hasher.hash_tree_encoded(&buf); + if !store.exists(&final_tree_hash)? { + store.put(&final_tree_hash, &Object::Tree(current_tree))?; + } + + let commit = crate::domain::commit::Commit::new( + final_tree_hash, + parents, + self.author.clone(), + self.committer.clone(), + format!("octopus merge of {}", self.branch_names.join(", ")), + None, + ); + let mut buf = Vec::new(); + self.encoder.encode_commit(&commit, &mut buf)?; + let commit_hash = self.hasher.hash_commit_encoded(&buf); + store.put(&commit_hash, &Object::Commit(Box::new(commit)))?; + + if let Some(head_ref) = refs.head_ref_name()? { + refs.set_ref(&head_ref, &commit_hash)?; + } + + Ok(commit_hash) + } +} From 79c558e047790cf1d74ab04d07921614488900f2 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 18/23] feat(rebase): add rebase command --- src/command/rebase.rs | 97 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/command/rebase.rs diff --git a/src/command/rebase.rs b/src/command/rebase.rs new file mode 100644 index 0000000..c07e633 --- /dev/null +++ b/src/command/rebase.rs @@ -0,0 +1,97 @@ +use crate::codec::Encoder; +use crate::command::Command; +use crate::domain::commit::Commit; +use crate::domain::hash::Hash; +use crate::domain::object::Object; +use crate::domain::user::UserID; +use crate::error::VctrlError; +use crate::hashing::Hasher; +use crate::merge::{ConflictResolver, ThreeWayMerge}; +use crate::storage::traits::{ObjectStore, ObjectStoreExt, RefStore}; +use std::collections::HashSet; + +pub struct Rebase { + pub upstream: Hash, + pub onto: Hash, + pub author: UserID, + pub committer: UserID, + pub merger: Box, + pub resolver: Box, + pub encoder: Box, + pub hasher: Box, +} + +impl Command for Rebase { + type Output = Hash; + + fn execute( + &self, + store: &mut dyn ObjectStore, + refs: &mut dyn RefStore, + ) -> Result { + let head_hash = refs + .head()? + .ok_or_else(|| VctrlError::Other("no HEAD".into()))?; + + let mut to_rebase = Vec::new(); + let mut visited = HashSet::new(); + let mut current = Some(head_hash); + while let Some(h) = current { + if h == self.upstream { + break; + } + if !visited.insert(h) { + return Err(VctrlError::Other("cycle detected".into())); + } + let commit = store.get_commit(&h)?; + current = commit.parents.first().copied(); + to_rebase.push(commit); + } + to_rebase.reverse(); + + let mut current_head = self.onto; + for src_commit in &to_rebase { + let base_tree_hash = if let Some(parent_hash) = src_commit.parents.first() { + let parent_commit = store.get_commit(parent_hash)?; + parent_commit.tree + } else { + let empty_tree = + crate::domain::tree::Tree::new(vec![]).map_err(VctrlError::Tree)?; + let mut buf = Vec::new(); + self.encoder.encode_tree(&empty_tree, &mut buf)?; + let hash = self.hasher.hash_tree_encoded(&buf); + store.put(&hash, &Object::Tree(empty_tree))?; + hash + }; + + let merged_tree_hash = self.merger.merge( + store, + &base_tree_hash, + ¤t_head, + &src_commit.tree, + self.resolver.as_ref(), + self.encoder.as_ref(), + self.hasher.as_ref(), + )?; + + let new_commit = Commit::new( + merged_tree_hash, + vec![current_head], + self.author.clone(), + self.committer.clone(), + src_commit.message.clone(), + None, + ); + let mut buf = Vec::new(); + self.encoder.encode_commit(&new_commit, &mut buf)?; + let new_hash = self.hasher.hash_commit_encoded(&buf); + store.put(&new_hash, &Object::Commit(Box::new(new_commit)))?; + current_head = new_hash; + } + + if let Some(head_ref) = refs.head_ref_name()? { + refs.set_ref(&head_ref, ¤t_head)?; + } + Ok(current_head) + } +} From 8259728bcd068f124378908776d8d23c41426f83 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 19/23] feat(show): add show command --- src/command/show.rs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/command/show.rs diff --git a/src/command/show.rs b/src/command/show.rs new file mode 100644 index 0000000..2f7bde3 --- /dev/null +++ b/src/command/show.rs @@ -0,0 +1,39 @@ +use crate::command::Command; +use crate::diff::{DiffEntry, TreeDiff, TreeDiffer}; +use crate::domain::commit::Commit; +use crate::domain::hash::Hash; +use crate::error::VctrlError; +use crate::storage::traits::{ObjectStore, ObjectStoreExt, RefStore}; + +#[derive(Debug, Clone)] +pub struct ShowOutput { + pub commit: Commit, + pub diff: Option>, +} + +pub struct Show { + pub commit_hash: Hash, +} + +impl Command for Show { + type Output = ShowOutput; + + fn execute( + &self, + store: &mut dyn ObjectStore, + _refs: &mut dyn RefStore, + ) -> Result { + let commit = store.get_commit(&self.commit_hash)?; + let diff = if let Some(parent_hash) = commit.parents.first().copied() { + let parent_commit = store.get_commit(&parent_hash)?; + let parent_tree = store.get_tree(&parent_commit.tree)?; + let commit_tree = store.get_tree(&commit.tree)?; + let differ = TreeDiffer; + Some(differ.diff(&parent_tree, &commit_tree)?) + } else { + None + }; + + Ok(ShowOutput { commit, diff }) + } +} From decc4d9cff67ae8847ee0eacab09377033738cc1 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 20/23] feat(tag): add verify tag command --- src/command/verify_tag.rs | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/command/verify_tag.rs diff --git a/src/command/verify_tag.rs b/src/command/verify_tag.rs new file mode 100644 index 0000000..a11537c --- /dev/null +++ b/src/command/verify_tag.rs @@ -0,0 +1,49 @@ +use crate::codec::Encoder; +use crate::command::Command; +use crate::crypto::Verifier; +use crate::domain::hash::Hash; +use crate::domain::object::Object; +use crate::error::VctrlError; +use crate::hashing::Hasher; +use crate::storage::traits::{ObjectStore, RefStore}; + +pub struct VerifyTag { + pub tag_hash: Hash, + pub verifier: Box, + pub encoder: Box, + pub hasher: Box, +} + +impl Command for VerifyTag { + type Output = bool; + + fn execute( + &self, + store: &mut dyn ObjectStore, + _refs: &mut dyn RefStore, + ) -> Result { + let tag = match store.get(&self.tag_hash)? { + Some(Object::Tag(t)) => *t, + _ => return Err(VctrlError::NotFound("tag not found".into())), + }; + + let sig_bytes = match &tag.signature { + Some(s) => s.clone(), + None => return Ok(false), + }; + + let pre_sig_tag = crate::domain::tag::Tag { + target: tag.target, + tagger: tag.tagger.clone(), + timestamp: tag.timestamp, + message: tag.message.clone(), + signature: None, + }; + + let mut buf = Vec::new(); + self.encoder.encode_tag(&pre_sig_tag, &mut buf)?; + let pre_sig_hash = self.hasher.hash_tag_encoded(&buf); + + self.verifier.verify(pre_sig_hash.as_bytes(), &sig_bytes) + } +} From 9551a6008dc7422636bb7b2c0fe2b1afd4869663 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 21/23] feat(crypto): add signer and verifier traits --- src/crypto.rs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/crypto.rs diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..b2614da --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,9 @@ +use crate::error::VctrlError; + +pub trait Signer: Send + Sync { + fn sign(&self, data: &[u8]) -> Result, VctrlError>; +} + +pub trait Verifier: Send + Sync { + fn verify(&self, data: &[u8], signature: &[u8]) -> Result; +} From d1050d99981d936e1ca03ac4221fc9354db8c9f5 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 22/23] feat(merge): add MergeStrategy trait --- src/merge/strategy.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/merge/strategy.rs diff --git a/src/merge/strategy.rs b/src/merge/strategy.rs new file mode 100644 index 0000000..5196a1f --- /dev/null +++ b/src/merge/strategy.rs @@ -0,0 +1,18 @@ +use crate::domain::hash::Hash; +use crate::error::VctrlError; +use crate::storage::traits::ObjectStore; + +pub enum MergeResult { + Success(Hash), + Conflict(String), +} + +pub trait MergeStrategy { + fn merge( + &self, + store: &mut dyn ObjectStore, + base: &Hash, + ours: &Hash, + theirs: &Hash, + ) -> Result; +} From 3840aeb45bb8b2bfadec3640c1dc9fdde2eefa30 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:08:13 +0700 Subject: [PATCH 23/23] feat(reflog): add reflog store trait --- src/reflog.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/reflog.rs diff --git a/src/reflog.rs b/src/reflog.rs new file mode 100644 index 0000000..ac5a102 --- /dev/null +++ b/src/reflog.rs @@ -0,0 +1,22 @@ +use crate::domain::hash::Hash; +use crate::error::VctrlError; + +#[derive(Debug, Clone)] +pub struct ReflogEntry { + pub ref_name: String, + pub old_hash: Option, + pub new_hash: Hash, + pub timestamp: chrono::DateTime, + pub message: String, +} + +pub trait ReflogStore { + fn log_ref_update( + &mut self, + ref_name: &str, + old_hash: Option, + new_hash: Hash, + message: &str, + ) -> Result<(), VctrlError>; + fn reflog(&self, ref_name: &str) -> Result, VctrlError>; +}