Skip to main content

git_remote_htree/git/
storage.rs

1//! Hashtree-backed git object and ref storage with configurable persistence
2//!
3//! Stores git objects and refs in a hashtree merkle tree:
4//!   root/
5//!     .git/
6//!       HEAD -> "ref: refs/heads/main"
7//!       refs/heads/main -> <commit-sha1>
8//!       info/refs -> dumb-HTTP ref advertisement
9//!       objects/XX/YYYY... -> zlib-compressed loose object (standard git layout)
10//!       objects/info/packs -> standard Git pack list
11//!
12//! The root hash (SHA-256) is the content-addressed identifier for the entire repo state.
13
14use flate2::read::ZlibDecoder;
15use flate2::write::ZlibEncoder;
16use flate2::Compression;
17use hashtree_config::{Config, StorageBackend};
18use hashtree_core::store::{Store, StoreError, StoreStats};
19use hashtree_core::types::Hash;
20use hashtree_core::{Cid, DirEntry, HashTree, HashTreeConfig, LinkType};
21use hashtree_fs::FsBlobStore;
22#[cfg(feature = "lmdb")]
23use hashtree_lmdb::LmdbBlobStore;
24use sha1::{Digest, Sha1};
25use std::collections::{BTreeMap, HashMap, HashSet};
26use std::io::{Read, Write};
27use std::path::Path;
28use std::sync::Arc;
29use tokio::runtime::{Handle, Runtime};
30use tracing::{debug, info, warn};
31
32use super::object::{parse_tree, GitObject, ObjectId, ObjectType};
33use super::progress::{RepoTreeBuildPhase, RepoTreeBuildProgress};
34use super::refs::{validate_ref_name, Ref};
35use super::{Error, Result};
36
37/// Box type for async recursion
38type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
39const GIT_TREE_CHUNK_SIZE: usize = 64 * 1024;
40
41struct BaseObjectSources<'a, S: Store> {
42    objects: &'a HashMap<String, Vec<u8>>,
43    tree: &'a HashTree<S>,
44    objects_cid: &'a Cid,
45    progress: Option<&'a RepoTreeBuildProgress>,
46}
47
48#[derive(Default)]
49struct RefDirectory {
50    files: BTreeMap<String, String>,
51    dirs: BTreeMap<String, RefDirectory>,
52}
53
54impl RefDirectory {
55    fn insert(&mut self, parts: &[&str], value: String) {
56        let Some((name, rest)) = parts.split_first() else {
57            return;
58        };
59
60        if rest.is_empty() {
61            self.files.insert((*name).to_string(), value);
62        } else {
63            self.dirs
64                .entry((*name).to_string())
65                .or_default()
66                .insert(rest, value);
67        }
68    }
69}
70
71/// Runtime executor - either owns a runtime or reuses an existing one
72enum RuntimeExecutor {
73    Owned(Runtime),
74    Handle(Handle),
75}
76
77impl RuntimeExecutor {
78    fn block_on<F: std::future::Future>(&self, f: F) -> F::Output {
79        match self {
80            RuntimeExecutor::Owned(rt) => rt.block_on(f),
81            RuntimeExecutor::Handle(handle) => tokio::task::block_in_place(|| handle.block_on(f)),
82        }
83    }
84}
85
86/// Local blob store - wraps either FsBlobStore or LmdbBlobStore
87pub enum LocalStore {
88    Fs(FsBlobStore),
89    #[cfg(feature = "lmdb")]
90    Lmdb(LmdbBlobStore),
91}
92
93impl LocalStore {
94    pub(crate) fn new_for_backend<P: AsRef<Path>>(
95        path: P,
96        backend: StorageBackend,
97        max_bytes: u64,
98    ) -> std::result::Result<Self, StoreError> {
99        let path = path.as_ref();
100        #[cfg(feature = "lmdb")]
101        {
102            Self::new_for_backend_with_openers(
103                path,
104                backend,
105                max_bytes,
106                Self::open_fs_store,
107                Self::open_lmdb_store,
108            )
109        }
110
111        #[cfg(not(feature = "lmdb"))]
112        match backend {
113            StorageBackend::Fs => Self::open_fs_store(path, max_bytes),
114            #[cfg(not(feature = "lmdb"))]
115            StorageBackend::Lmdb => {
116                warn!(
117                    "LMDB backend requested but lmdb feature not enabled, using filesystem storage"
118                );
119                Self::open_fs_store(path, max_bytes)
120            }
121        }
122    }
123
124    fn open_fs_store(path: &Path, max_bytes: u64) -> std::result::Result<Self, StoreError> {
125        if max_bytes > 0 {
126            Ok(LocalStore::Fs(FsBlobStore::with_max_bytes(
127                path, max_bytes,
128            )?))
129        } else {
130            Ok(LocalStore::Fs(FsBlobStore::new(path)?))
131        }
132    }
133
134    #[cfg(feature = "lmdb")]
135    fn open_lmdb_store(path: &Path, max_bytes: u64) -> std::result::Result<Self, StoreError> {
136        if max_bytes > 0 {
137            Ok(LocalStore::Lmdb(LmdbBlobStore::with_max_bytes(
138                path, max_bytes,
139            )?))
140        } else {
141            Ok(LocalStore::Lmdb(LmdbBlobStore::new(path)?))
142        }
143    }
144
145    #[cfg(feature = "lmdb")]
146    fn new_for_backend_with_openers<FS, LMDB>(
147        path: &Path,
148        backend: StorageBackend,
149        max_bytes: u64,
150        fs_open: FS,
151        lmdb_open: LMDB,
152    ) -> std::result::Result<Self, StoreError>
153    where
154        FS: Fn(&Path, u64) -> std::result::Result<Self, StoreError>,
155        LMDB: Fn(&Path, u64) -> std::result::Result<Self, StoreError>,
156    {
157        match backend {
158            StorageBackend::Fs => fs_open(path, max_bytes),
159            StorageBackend::Lmdb => match lmdb_open(path, max_bytes) {
160                Ok(store) => Ok(store),
161                Err(err) if should_fallback_from_lmdb_error(&err) => {
162                    warn!(
163                        path = %path.display(),
164                        "LMDB backend is unsupported in this environment, falling back to filesystem storage"
165                    );
166                    fs_open(path, max_bytes)
167                }
168                Err(err) => Err(err),
169            },
170        }
171    }
172
173    /// Create a new local store based on config
174    pub fn new<P: AsRef<Path>>(path: P) -> std::result::Result<Self, StoreError> {
175        Self::new_with_max_bytes(path, 0)
176    }
177
178    /// Create a new local store based on config with an optional byte limit.
179    pub fn new_with_max_bytes<P: AsRef<Path>>(
180        path: P,
181        max_bytes: u64,
182    ) -> std::result::Result<Self, StoreError> {
183        let config = Config::load_or_default();
184        Self::new_for_backend(path, config.storage.backend, max_bytes)
185    }
186
187    /// List all hashes in the store
188    pub fn list(&self) -> std::result::Result<Vec<Hash>, StoreError> {
189        match self {
190            LocalStore::Fs(store) => store.list(),
191            #[cfg(feature = "lmdb")]
192            LocalStore::Lmdb(store) => store.list(),
193        }
194    }
195
196    /// Sync get operation
197    pub fn get_sync(&self, hash: &Hash) -> std::result::Result<Option<Vec<u8>>, StoreError> {
198        match self {
199            LocalStore::Fs(store) => store.get_sync(hash),
200            #[cfg(feature = "lmdb")]
201            LocalStore::Lmdb(store) => store.get_sync(hash),
202        }
203    }
204}
205
206#[cfg(feature = "lmdb")]
207pub(crate) fn should_fallback_from_lmdb_error(error: &StoreError) -> bool {
208    matches!(
209        error,
210        StoreError::Io(io_error) if io_error.raw_os_error() == Some(libc::ENOSYS)
211    )
212}
213
214#[async_trait::async_trait]
215impl Store for LocalStore {
216    async fn put(&self, hash: Hash, data: Vec<u8>) -> std::result::Result<bool, StoreError> {
217        match self {
218            LocalStore::Fs(store) => store.put(hash, data).await,
219            #[cfg(feature = "lmdb")]
220            LocalStore::Lmdb(store) => store.put(hash, data).await,
221        }
222    }
223
224    async fn get(&self, hash: &Hash) -> std::result::Result<Option<Vec<u8>>, StoreError> {
225        match self {
226            LocalStore::Fs(store) => store.get(hash).await,
227            #[cfg(feature = "lmdb")]
228            LocalStore::Lmdb(store) => store.get(hash).await,
229        }
230    }
231
232    async fn get_range(
233        &self,
234        hash: &Hash,
235        start: u64,
236        end_inclusive: u64,
237    ) -> std::result::Result<Option<Vec<u8>>, StoreError> {
238        match self {
239            LocalStore::Fs(store) => store.get_range(hash, start, end_inclusive).await,
240            #[cfg(feature = "lmdb")]
241            LocalStore::Lmdb(store) => store.get_range(hash, start, end_inclusive).await,
242        }
243    }
244
245    async fn blob_size(&self, hash: &Hash) -> std::result::Result<Option<u64>, StoreError> {
246        match self {
247            LocalStore::Fs(store) => store.blob_size(hash).await,
248            #[cfg(feature = "lmdb")]
249            LocalStore::Lmdb(store) => store.blob_size(hash).await,
250        }
251    }
252
253    async fn has(&self, hash: &Hash) -> std::result::Result<bool, StoreError> {
254        match self {
255            LocalStore::Fs(store) => store.has(hash).await,
256            #[cfg(feature = "lmdb")]
257            LocalStore::Lmdb(store) => store.has(hash).await,
258        }
259    }
260
261    async fn delete(&self, hash: &Hash) -> std::result::Result<bool, StoreError> {
262        match self {
263            LocalStore::Fs(store) => store.delete(hash).await,
264            #[cfg(feature = "lmdb")]
265            LocalStore::Lmdb(store) => store.delete(hash).await,
266        }
267    }
268
269    fn set_max_bytes(&self, max: u64) {
270        match self {
271            LocalStore::Fs(store) => store.set_max_bytes(max),
272            #[cfg(feature = "lmdb")]
273            LocalStore::Lmdb(store) => store.set_max_bytes(max),
274        }
275    }
276
277    fn max_bytes(&self) -> Option<u64> {
278        match self {
279            LocalStore::Fs(store) => store.max_bytes(),
280            #[cfg(feature = "lmdb")]
281            LocalStore::Lmdb(store) => store.max_bytes(),
282        }
283    }
284
285    async fn stats(&self) -> StoreStats {
286        match self {
287            LocalStore::Fs(store) => match store.stats() {
288                Ok(stats) => StoreStats {
289                    count: stats.count as u64,
290                    bytes: stats.total_bytes,
291                    pinned_count: stats.pinned_count as u64,
292                    pinned_bytes: stats.pinned_bytes,
293                },
294                Err(_) => StoreStats::default(),
295            },
296            #[cfg(feature = "lmdb")]
297            LocalStore::Lmdb(store) => match store.stats() {
298                Ok(stats) => StoreStats {
299                    count: stats.count as u64,
300                    bytes: stats.total_bytes,
301                    pinned_count: 0,
302                    pinned_bytes: 0,
303                },
304                Err(_) => StoreStats::default(),
305            },
306        }
307    }
308
309    async fn evict_if_needed(&self) -> std::result::Result<u64, StoreError> {
310        match self {
311            LocalStore::Fs(store) => store.evict_if_needed().await,
312            #[cfg(feature = "lmdb")]
313            LocalStore::Lmdb(store) => store.evict_if_needed().await,
314        }
315    }
316}
317
318/// Git storage backed by HashTree with configurable persistence
319pub struct GitStorage {
320    store: Arc<LocalStore>,
321    tree: HashTree<LocalStore>,
322    runtime: RuntimeExecutor,
323    /// In-memory state for the current session
324    objects: std::sync::RwLock<HashMap<String, Vec<u8>>>,
325    refs: std::sync::RwLock<HashMap<String, String>>,
326    pack_files: std::sync::RwLock<BTreeMap<String, Vec<u8>>>,
327    packed_object_ids: std::sync::RwLock<HashSet<String>>,
328    /// Cached root CID (hash + encryption key)
329    root_cid: std::sync::RwLock<Option<Cid>>,
330}
331
332impl GitStorage {
333    /// Open or create a git storage at the given path
334    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
335        let config = Config::load_or_default();
336        let max_size_bytes = config
337            .storage
338            .max_size_gb
339            .saturating_mul(1024 * 1024 * 1024);
340        Self::open_with_max_bytes(path, max_size_bytes)
341    }
342
343    /// Open or create a git storage at the given path with an explicit byte limit.
344    pub fn open_with_max_bytes(path: impl AsRef<Path>, max_size_bytes: u64) -> Result<Self> {
345        let config = Config::load_or_default();
346        Self::open_with_backend_and_max_bytes(path, config.storage.backend, max_size_bytes)
347    }
348
349    pub fn open_with_backend_and_max_bytes(
350        path: impl AsRef<Path>,
351        backend: StorageBackend,
352        max_size_bytes: u64,
353    ) -> Result<Self> {
354        let runtime = match Handle::try_current() {
355            Ok(handle) => RuntimeExecutor::Handle(handle),
356            Err(_) => {
357                let rt = Runtime::new()
358                    .map_err(|e| Error::StorageError(format!("tokio runtime: {}", e)))?;
359                RuntimeExecutor::Owned(rt)
360            }
361        };
362
363        let store_path = path.as_ref().join("blobs");
364        let store = Arc::new(
365            LocalStore::new_for_backend(&store_path, backend, max_size_bytes)
366                .map_err(|e| Error::StorageError(format!("local store: {}", e)))?,
367        );
368
369        // Use encrypted mode (default) - blossom servers require encrypted data
370        let tree =
371            HashTree::new(HashTreeConfig::new(store.clone()).with_chunk_size(GIT_TREE_CHUNK_SIZE));
372
373        Ok(Self {
374            store,
375            tree,
376            runtime,
377            objects: std::sync::RwLock::new(HashMap::new()),
378            refs: std::sync::RwLock::new(HashMap::new()),
379            pack_files: std::sync::RwLock::new(BTreeMap::new()),
380            packed_object_ids: std::sync::RwLock::new(HashSet::new()),
381            root_cid: std::sync::RwLock::new(None),
382        })
383    }
384
385    /// Evict old local blobs if storage is over the configured limit.
386    pub fn evict_if_needed(&self) -> Result<u64> {
387        self.runtime
388            .block_on(self.store.evict_if_needed())
389            .map_err(|e| Error::StorageError(format!("evict: {}", e)))
390    }
391
392    /// Write an object, returning its ID
393    fn write_object(&self, obj: &GitObject) -> Result<ObjectId> {
394        let oid = obj.id();
395        let key = oid.to_hex();
396
397        let loose = obj.to_loose_format();
398        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
399        encoder.write_all(&loose)?;
400        let compressed = encoder.finish()?;
401
402        let mut objects = self
403            .objects
404            .write()
405            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
406        objects.insert(key, compressed);
407
408        // Invalidate cached root
409        if let Ok(mut root) = self.root_cid.write() {
410            *root = None;
411        }
412
413        Ok(oid)
414    }
415
416    /// Write raw object data (type + content already parsed)
417    pub fn write_raw_object(&self, obj_type: ObjectType, content: &[u8]) -> Result<ObjectId> {
418        let obj = GitObject::new(obj_type, content.to_vec());
419        self.write_object(&obj)
420    }
421
422    /// Read an object by ID from in-memory cache
423    #[allow(dead_code)]
424    fn read_object(&self, oid: &ObjectId) -> Result<GitObject> {
425        let key = oid.to_hex();
426        let objects = self
427            .objects
428            .read()
429            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
430        let compressed = objects
431            .get(&key)
432            .ok_or_else(|| Error::ObjectNotFound(key.clone()))?;
433
434        let mut decoder = ZlibDecoder::new(compressed.as_slice());
435        let mut data = Vec::new();
436        decoder.read_to_end(&mut data)?;
437
438        GitObject::from_loose_format(&data)
439    }
440
441    /// Write a ref
442    pub fn write_ref(&self, name: &str, target: &Ref) -> Result<()> {
443        validate_ref_name(name)?;
444
445        let value = match target {
446            Ref::Direct(oid) => oid.to_hex(),
447            Ref::Symbolic(target) => format!("ref: {}", target),
448        };
449
450        let mut refs = self
451            .refs
452            .write()
453            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
454        refs.insert(name.to_string(), value);
455
456        // Invalidate cached root
457        if let Ok(mut root) = self.root_cid.write() {
458            *root = None;
459        }
460
461        Ok(())
462    }
463
464    /// Read a ref
465    #[allow(dead_code)]
466    pub fn read_ref(&self, name: &str) -> Result<Option<Ref>> {
467        let refs = self
468            .refs
469            .read()
470            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
471
472        match refs.get(name) {
473            Some(value) => {
474                if let Some(target) = value.strip_prefix("ref: ") {
475                    Ok(Some(Ref::Symbolic(target.to_string())))
476                } else {
477                    let oid = ObjectId::from_hex(value)
478                        .ok_or_else(|| Error::StorageError(format!("invalid ref: {}", value)))?;
479                    Ok(Some(Ref::Direct(oid)))
480                }
481            }
482            None => Ok(None),
483        }
484    }
485
486    /// List all refs
487    #[allow(dead_code)]
488    pub fn list_refs(&self) -> Result<HashMap<String, String>> {
489        let refs = self
490            .refs
491            .read()
492            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
493        Ok(refs.clone())
494    }
495
496    /// Delete a ref
497    pub fn delete_ref(&self, name: &str) -> Result<bool> {
498        let mut refs = self
499            .refs
500            .write()
501            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
502        let existed = refs.remove(name).is_some();
503
504        // Invalidate cached root
505        if let Ok(mut root) = self.root_cid.write() {
506            *root = None;
507        }
508
509        Ok(existed)
510    }
511
512    /// Import a raw git object (already in loose format, zlib compressed)
513    /// Used when fetching existing objects from remote before push
514    pub fn import_compressed_object(&self, oid: &str, compressed_data: Vec<u8>) -> Result<()> {
515        let expected_oid = ObjectId::from_hex(oid)
516            .ok_or_else(|| Error::InvalidObjectFormat(format!("invalid object id: {}", oid)))?;
517        let mut decoder = ZlibDecoder::new(compressed_data.as_slice());
518        let mut loose = Vec::new();
519        decoder.read_to_end(&mut loose)?;
520        let object = GitObject::from_loose_format(&loose)?;
521        let actual_oid = object.id();
522        if actual_oid != expected_oid {
523            return Err(Error::InvalidObjectFormat(format!(
524                "object id mismatch: expected {}, got {}",
525                expected_oid, actual_oid
526            )));
527        }
528
529        let mut objects = self
530            .objects
531            .write()
532            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
533        objects.insert(expected_oid.to_hex(), compressed_data);
534
535        // Invalidate cached root
536        if let Ok(mut root) = self.root_cid.write() {
537            *root = None;
538        }
539
540        Ok(())
541    }
542
543    /// Import a ref directly (used when loading existing refs from remote)
544    pub fn import_ref(&self, name: &str, value: &str) -> Result<()> {
545        validate_ref_name(name)?;
546
547        let mut refs = self
548            .refs
549            .write()
550            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
551        refs.insert(name.to_string(), value.to_string());
552
553        // Invalidate cached root
554        if let Ok(mut root) = self.root_cid.write() {
555            *root = None;
556        }
557
558        Ok(())
559    }
560
561    pub fn set_pack_files(&self, files: BTreeMap<String, Vec<u8>>) -> Result<()> {
562        self.set_pack_checkpoint_files(files, HashSet::new())
563    }
564
565    pub fn set_pack_checkpoint_files(
566        &self,
567        files: BTreeMap<String, Vec<u8>>,
568        covered_objects: HashSet<String>,
569    ) -> Result<()> {
570        let mut pack_files = self
571            .pack_files
572            .write()
573            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
574        *pack_files = files;
575        let mut packed_object_ids = self
576            .packed_object_ids
577            .write()
578            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
579        *packed_object_ids = covered_objects;
580
581        if let Ok(mut root) = self.root_cid.write() {
582            *root = None;
583        }
584
585        Ok(())
586    }
587
588    pub fn add_pack_covered_objects(&self, covered_objects: HashSet<String>) -> Result<()> {
589        let mut packed_object_ids = self
590            .packed_object_ids
591            .write()
592            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
593        packed_object_ids.extend(covered_objects);
594
595        if let Ok(mut root) = self.root_cid.write() {
596            *root = None;
597        }
598
599        Ok(())
600    }
601
602    /// Check if a ref exists
603    #[cfg(test)]
604    pub fn has_ref(&self, name: &str) -> Result<bool> {
605        let refs = self
606            .refs
607            .read()
608            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
609        Ok(refs.contains_key(name))
610    }
611
612    /// Get count of objects in storage
613    #[cfg(test)]
614    pub fn object_count(&self) -> Result<usize> {
615        let objects = self
616            .objects
617            .read()
618            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
619        Ok(objects.len())
620    }
621
622    /// Get the cached root CID (returns None if tree hasn't been built)
623    #[allow(dead_code)]
624    pub fn get_root_cid(&self) -> Result<Option<Cid>> {
625        let root = self
626            .root_cid
627            .read()
628            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
629        Ok(root.clone())
630    }
631
632    /// Get the default branch name
633    #[allow(dead_code)]
634    pub fn default_branch(&self) -> Result<Option<String>> {
635        let refs = self
636            .refs
637            .read()
638            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
639
640        if let Some(head) = refs.get("HEAD") {
641            if let Some(target) = head.strip_prefix("ref: ") {
642                return Ok(Some(target.to_string()));
643            }
644        }
645        Ok(None)
646    }
647
648    /// Get the tree SHA from a commit object
649    fn get_commit_tree(
650        &self,
651        commit_oid: &str,
652        objects: &HashMap<String, Vec<u8>>,
653    ) -> Option<String> {
654        let compressed = objects.get(commit_oid)?;
655
656        // Decompress the object
657        let mut decoder = ZlibDecoder::new(&compressed[..]);
658        let mut decompressed = Vec::new();
659        decoder.read_to_end(&mut decompressed).ok()?;
660
661        // Parse git object format: "type size\0content"
662        let null_pos = decompressed.iter().position(|&b| b == 0)?;
663        let content = &decompressed[null_pos + 1..];
664
665        // Parse commit content - first line is "tree <sha>"
666        let content_str = std::str::from_utf8(content).ok()?;
667        let first_line = content_str.lines().next()?;
668        first_line
669            .strip_prefix("tree ")
670            .map(|tree_hash| tree_hash.to_string())
671    }
672
673    /// Get git object content (decompressed, without header)
674    fn get_object_content(
675        &self,
676        oid: &str,
677        objects: &HashMap<String, Vec<u8>>,
678    ) -> Option<(ObjectType, Vec<u8>)> {
679        let compressed = objects.get(oid)?;
680        Self::parse_compressed_object(compressed)
681    }
682
683    fn parse_compressed_object(compressed: &[u8]) -> Option<(ObjectType, Vec<u8>)> {
684        let mut decoder = ZlibDecoder::new(compressed);
685        let mut decompressed = Vec::new();
686        decoder.read_to_end(&mut decompressed).ok()?;
687
688        // Parse git object format: "type size\0content"
689        let null_pos = decompressed.iter().position(|&b| b == 0)?;
690        let header = std::str::from_utf8(&decompressed[..null_pos]).ok()?;
691        let obj_type = if header.starts_with("blob") {
692            ObjectType::Blob
693        } else if header.starts_with("tree") {
694            ObjectType::Tree
695        } else if header.starts_with("commit") {
696            ObjectType::Commit
697        } else if header.starts_with("tag") {
698            ObjectType::Tag
699        } else {
700            return None;
701        };
702        let content = decompressed[null_pos + 1..].to_vec();
703        Some((obj_type, content))
704    }
705
706    async fn load_base_compressed_object<S: Store>(
707        &self,
708        oid: &str,
709        base_tree: &HashTree<S>,
710        base_objects_cid: &Cid,
711    ) -> Result<Option<Vec<u8>>> {
712        if oid.len() != 40 {
713            return Err(Error::ObjectNotFound(oid.to_string()));
714        }
715
716        let path = format!("{}/{}", &oid[..2], &oid[2..]);
717        let Some(object_cid) = base_tree
718            .resolve_path(base_objects_cid, &path)
719            .await
720            .map_err(|e| Error::StorageError(format!("resolve {} in base objects: {}", oid, e)))?
721        else {
722            return Ok(None);
723        };
724
725        base_tree
726            .get(&object_cid, None)
727            .await
728            .map_err(|e| Error::StorageError(format!("read {} from base objects: {}", oid, e)))
729    }
730
731    async fn get_object_content_from_base<S: Store>(
732        &self,
733        oid: &str,
734        base_tree: &HashTree<S>,
735        base_objects_cid: &Cid,
736    ) -> Result<Option<(ObjectType, Vec<u8>)>> {
737        let Some(compressed) = self
738            .load_base_compressed_object(oid, base_tree, base_objects_cid)
739            .await?
740        else {
741            return Ok(None);
742        };
743
744        Ok(Self::parse_compressed_object(&compressed))
745    }
746
747    async fn get_tree_entries_from_sources<S: Store>(
748        &self,
749        tree_oid: &str,
750        objects: &HashMap<String, Vec<u8>>,
751        base_tree: Option<&HashTree<S>>,
752        base_objects_cid: Option<&Cid>,
753    ) -> Result<Vec<super::object::TreeEntry>> {
754        let object = match self.get_object_content(tree_oid, objects) {
755            Some(object) => Some(object),
756            None => {
757                if let (Some(base_tree), Some(base_objects_cid)) = (base_tree, base_objects_cid) {
758                    self.get_object_content_from_base(tree_oid, base_tree, base_objects_cid)
759                        .await?
760                } else {
761                    None
762                }
763            }
764        }
765        .ok_or_else(|| Error::ObjectNotFound(tree_oid.to_string()))?;
766
767        if object.0 != ObjectType::Tree {
768            return Err(Error::InvalidObjectType(format!(
769                "expected tree, got {:?}",
770                object.0
771            )));
772        }
773
774        parse_tree(&object.1)
775    }
776
777    fn tree_entry_to_dir_entry(entry: &hashtree_core::TreeEntry) -> DirEntry {
778        DirEntry::from_cid(
779            &entry.name,
780            &Cid {
781                hash: entry.hash,
782                key: entry.key,
783            },
784        )
785        .with_size(entry.size)
786        .with_link_type(entry.link_type)
787    }
788
789    async fn import_object_from_base<S: Store>(
790        &self,
791        oid: &str,
792        objects: &mut HashMap<String, Vec<u8>>,
793        base_tree: &HashTree<S>,
794        base_objects_cid: &Cid,
795    ) -> Result<bool> {
796        if objects.contains_key(oid) {
797            return Ok(true);
798        }
799        let Some(compressed) = self
800            .load_base_compressed_object(oid, base_tree, base_objects_cid)
801            .await?
802        else {
803            return Ok(false);
804        };
805
806        objects.insert(oid.to_string(), compressed);
807        Ok(true)
808    }
809
810    fn seed_missing_object_from_base_boxed<'a, S: Store + 'a>(
811        &'a self,
812        oid: &'a str,
813        objects: &'a mut HashMap<String, Vec<u8>>,
814        base_tree: &'a HashTree<S>,
815        base_objects_cid: &'a Cid,
816    ) -> BoxFuture<'a, Result<bool>> {
817        Box::pin(self.import_object_from_base(oid, objects, base_tree, base_objects_cid))
818    }
819
820    fn peel_tag_target(&self, oid: &str, objects: &HashMap<String, Vec<u8>>) -> Option<String> {
821        let (obj_type, content) = self.get_object_content(oid, objects)?;
822        if obj_type != ObjectType::Tag {
823            return Some(oid.to_string());
824        }
825
826        let target = std::str::from_utf8(&content)
827            .ok()?
828            .lines()
829            .find_map(|line| line.strip_prefix("object "))
830            .map(str::trim)?
831            .to_string();
832
833        match self.get_object_content(&target, objects)?.0 {
834            ObjectType::Tag => self.peel_tag_target(&target, objects),
835            _ => Some(target),
836        }
837    }
838
839    fn build_info_refs_content(
840        &self,
841        refs: &HashMap<String, String>,
842        objects: &HashMap<String, Vec<u8>>,
843    ) -> String {
844        let mut lines = Vec::new();
845
846        for (name, value) in refs {
847            if name == "HEAD" {
848                continue;
849            }
850
851            let oid = value.trim().to_string();
852            lines.push((name.clone(), oid.clone()));
853
854            if name.starts_with("refs/tags/") {
855                if let Some(peeled) = self.peel_tag_target(&oid, objects) {
856                    if peeled != oid {
857                        lines.push((format!("{}^{{}}", name), peeled));
858                    }
859                }
860            }
861        }
862
863        lines.sort_by(|a, b| a.0.cmp(&b.0));
864
865        let mut content = String::new();
866        for (name, oid) in lines {
867            content.push_str(&oid);
868            content.push('\t');
869            content.push_str(&name);
870            content.push('\n');
871        }
872        content
873    }
874
875    async fn build_info_dir(
876        &self,
877        refs: &HashMap<String, String>,
878        objects: &HashMap<String, Vec<u8>>,
879    ) -> Result<Cid> {
880        let info_refs = self.build_info_refs_content(refs, objects);
881        let (info_refs_cid, info_refs_size) = self
882            .tree
883            .put(info_refs.as_bytes())
884            .await
885            .map_err(|e| Error::StorageError(format!("put info/refs: {}", e)))?;
886
887        self.tree
888            .put_directory(vec![
889                DirEntry::from_cid("refs", &info_refs_cid).with_size(info_refs_size)
890            ])
891            .await
892            .map_err(|e| Error::StorageError(format!("put info dir: {}", e)))
893    }
894
895    /// Build the hashtree and return the root CID (hash + encryption key)
896    pub fn build_tree(&self) -> Result<Cid> {
897        self.build_tree_with_base_objects::<LocalStore>(None, None, None)
898    }
899
900    pub fn build_tree_with_progress(&self, progress: &RepoTreeBuildProgress) -> Result<Cid> {
901        self.build_tree_with_base_objects_internal::<LocalStore>(None, None, None, Some(progress))
902    }
903
904    fn is_safe_pack_name(name: &str) -> bool {
905        name.len() == "pack-".len() + 40 + ".pack".len()
906            && name.starts_with("pack-")
907            && name.ends_with(".pack")
908            && name["pack-".len()..name.len() - ".pack".len()]
909                .bytes()
910                .all(|byte| byte.is_ascii_hexdigit())
911    }
912
913    pub fn tree_root_has_git_pack_checkpoint<S: Store>(
914        &self,
915        tree: &HashTree<S>,
916        root_cid: &Cid,
917    ) -> Result<bool> {
918        let Some(info_packs_cid) = self
919            .runtime
920            .block_on(tree.resolve_path(root_cid, ".git/objects/info/packs"))
921            .map_err(|e| Error::StorageError(format!("resolve .git/objects/info/packs: {}", e)))?
922        else {
923            return Ok(false);
924        };
925
926        let Some(info_packs_bytes) = self
927            .runtime
928            .block_on(tree.get(&info_packs_cid, None))
929            .map_err(|e| Error::StorageError(format!("read .git/objects/info/packs: {}", e)))?
930        else {
931            return Ok(false);
932        };
933
934        let info_packs = String::from_utf8_lossy(&info_packs_bytes);
935        for line in info_packs.lines().map(str::trim) {
936            let Some(pack_name) = line.strip_prefix("P ") else {
937                continue;
938            };
939            if !Self::is_safe_pack_name(pack_name) {
940                continue;
941            }
942
943            let idx_name = format!("{}.idx", pack_name.trim_end_matches(".pack"));
944            let pack_path = format!(".git/objects/pack/{pack_name}");
945            let idx_path = format!(".git/objects/pack/{idx_name}");
946            let pack_exists = self
947                .runtime
948                .block_on(tree.resolve_path(root_cid, &pack_path))
949                .map_err(|e| Error::StorageError(format!("resolve {}: {}", pack_path, e)))?
950                .is_some();
951            let idx_exists = self
952                .runtime
953                .block_on(tree.resolve_path(root_cid, &idx_path))
954                .map_err(|e| Error::StorageError(format!("resolve {}: {}", idx_path, e)))?
955                .is_some();
956            if pack_exists && idx_exists {
957                return Ok(true);
958            }
959        }
960
961        Ok(false)
962    }
963
964    fn root_has_git_pack_checkpoint(&self, root_cid: &Cid) -> Result<bool> {
965        self.tree_root_has_git_pack_checkpoint(&self.tree, root_cid)
966    }
967
968    pub fn validate_root_contains_direct_refs(&self, root_cid: &Cid) -> Result<()> {
969        let direct_refs: Vec<String> = {
970            let refs = self
971                .refs
972                .read()
973                .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
974            refs.values()
975                .filter(|value| {
976                    !value.starts_with("ref: ")
977                        && value.len() == 40
978                        && value.bytes().all(|byte| byte.is_ascii_hexdigit())
979                })
980                .cloned()
981                .collect()
982        };
983
984        if direct_refs.is_empty() {
985            return Ok(());
986        }
987
988        let objects_dir = self
989            .runtime
990            .block_on(self.tree.resolve_path(root_cid, ".git/objects"))
991            .map_err(|e| Error::StorageError(format!("resolve .git/objects: {}", e)))?;
992        if objects_dir.is_none() {
993            return Err(Error::StorageError(
994                "built root is missing .git/objects".to_string(),
995            ));
996        }
997
998        let pack_checkpoint_available = self.root_has_git_pack_checkpoint(root_cid)?;
999        for oid in direct_refs {
1000            let object_path = format!(".git/objects/{}/{}", &oid[..2], &oid[2..]);
1001            let object_cid = self
1002                .runtime
1003                .block_on(self.tree.resolve_path(root_cid, &object_path))
1004                .map_err(|e| Error::StorageError(format!("resolve {}: {}", object_path, e)))?;
1005            if object_cid.is_none() && !pack_checkpoint_available {
1006                return Err(Error::ObjectNotFound(oid));
1007            }
1008        }
1009
1010        Ok(())
1011    }
1012
1013    pub fn build_tree_with_base_objects<S: Store>(
1014        &self,
1015        base_tree: Option<&HashTree<S>>,
1016        base_root: Option<&Cid>,
1017        base_tree_sha: Option<&str>,
1018    ) -> Result<Cid> {
1019        self.build_tree_with_base_objects_internal(base_tree, base_root, base_tree_sha, None)
1020    }
1021
1022    pub fn build_tree_with_base_objects_with_progress<S: Store>(
1023        &self,
1024        base_tree: Option<&HashTree<S>>,
1025        base_root: Option<&Cid>,
1026        base_tree_sha: Option<&str>,
1027        progress: &RepoTreeBuildProgress,
1028    ) -> Result<Cid> {
1029        self.build_tree_with_base_objects_internal(
1030            base_tree,
1031            base_root,
1032            base_tree_sha,
1033            Some(progress),
1034        )
1035    }
1036
1037    fn build_tree_with_base_objects_internal<S: Store>(
1038        &self,
1039        base_tree: Option<&HashTree<S>>,
1040        base_root: Option<&Cid>,
1041        base_tree_sha: Option<&str>,
1042        progress: Option<&RepoTreeBuildProgress>,
1043    ) -> Result<Cid> {
1044        // Check if we have a cached root
1045        if let Ok(root) = self.root_cid.read() {
1046            if let Some(ref cid) = *root {
1047                if let Some(progress) = progress {
1048                    progress.mark_done();
1049                }
1050                return Ok(cid.clone());
1051            }
1052        }
1053
1054        if let Err(err) = self.evict_if_needed() {
1055            debug!("pre-build eviction skipped: {}", err);
1056        }
1057
1058        let objects = self
1059            .objects
1060            .read()
1061            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
1062        let refs = self
1063            .refs
1064            .read()
1065            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
1066
1067        // Get default branch from HEAD or find first branch ref
1068        let (default_branch, commit_sha) = if let Some(head) = refs.get("HEAD") {
1069            let branch = head.strip_prefix("ref: ").map(String::from);
1070            let sha = branch.as_ref().and_then(|b| refs.get(b)).cloned();
1071            (branch, sha)
1072        } else {
1073            // No HEAD ref - find first refs/heads/* ref directly
1074            let mut branch_info: Option<(String, String)> = None;
1075            for (ref_name, sha) in refs.iter() {
1076                if ref_name.starts_with("refs/heads/") {
1077                    branch_info = Some((ref_name.clone(), sha.clone()));
1078                    break;
1079                }
1080            }
1081            match branch_info {
1082                Some((branch, sha)) => (Some(branch), Some(sha)),
1083                None => (None, None),
1084            }
1085        };
1086
1087        // Get tree SHA from commit
1088        let tree_sha = commit_sha
1089            .as_ref()
1090            .and_then(|sha| self.get_commit_tree(sha, &objects));
1091
1092        // Clone objects for async block
1093        let mut objects_clone = objects.clone();
1094
1095        let base_objects_cid = if let (Some(base_tree), Some(base_root)) = (base_tree, base_root) {
1096            self.runtime
1097                .block_on(base_tree.resolve_path(base_root, ".git/objects"))
1098                .map_err(|e| Error::StorageError(format!("resolve base .git/objects: {}", e)))?
1099        } else {
1100            None
1101        };
1102
1103        let base_tree_sha = base_tree_sha.map(str::to_string);
1104
1105        let base_root_entries = if let (Some(base_tree), Some(base_root)) = (base_tree, base_root) {
1106            Some(
1107                self.runtime
1108                    .block_on(base_tree.list_directory(base_root))
1109                    .map_err(|e| Error::StorageError(format!("list base root: {}", e)))?,
1110            )
1111        } else {
1112            None
1113        };
1114
1115        let root_cid = loop {
1116            let build_result = self.runtime.block_on(async {
1117                // Build objects directory
1118                let objects_cid = self
1119                    .build_objects_dir_with_base(
1120                        &objects_clone,
1121                        base_tree,
1122                        base_objects_cid.as_ref(),
1123                        progress,
1124                    )
1125                    .await?;
1126
1127                // Build refs directory
1128                if let Some(progress) = progress {
1129                    progress.start_phase(RepoTreeBuildPhase::Refs, Some(refs.len()));
1130                }
1131                let refs_cid = self.build_refs_dir(&refs, progress).await?;
1132
1133                // Build dumb-HTTP info directory
1134                if let Some(progress) = progress {
1135                    progress.start_phase(RepoTreeBuildPhase::Info, Some(1));
1136                }
1137                let info_cid = self.build_info_dir(&refs, &objects_clone).await?;
1138                if let Some(progress) = progress {
1139                    progress.increment_processed();
1140                }
1141
1142                // Build HEAD file - use default_branch if no explicit HEAD
1143                // Git expects HEAD to end with newline, so add it if missing
1144                if let Some(progress) = progress {
1145                    progress.start_phase(RepoTreeBuildPhase::Head, Some(1));
1146                }
1147                let head_content = refs.get("HEAD")
1148                    .map(|h| if h.ends_with('\n') { h.clone() } else { format!("{}\n", h) })
1149                    .or_else(|| default_branch.as_ref().map(|b| format!("ref: {}\n", b)))
1150                    .unwrap_or_else(|| "ref: refs/heads/main\n".to_string());
1151                debug!("HEAD content: {:?}", head_content);
1152                let (head_cid, head_size) = self.tree.put(head_content.as_bytes()).await
1153                    .map_err(|e| Error::StorageError(format!("put HEAD: {}", e)))?;
1154                debug!("HEAD hash: {}", hex::encode(head_cid.hash));
1155                if let Some(progress) = progress {
1156                    progress.increment_processed();
1157                }
1158
1159                // Build .git directory - use from_cid to preserve encryption keys
1160                let mut git_entries = vec![
1161                    DirEntry::from_cid("HEAD", &head_cid).with_size(head_size),
1162                    DirEntry::from_cid("info", &info_cid).with_link_type(LinkType::Dir),
1163                    DirEntry::from_cid("objects", &objects_cid).with_link_type(LinkType::Dir),
1164                    DirEntry::from_cid("refs", &refs_cid).with_link_type(LinkType::Dir),
1165                ];
1166
1167                // Add config if we have a default branch
1168                if let Some(ref branch) = default_branch {
1169                    let config = format!(
1170                        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n[init]\n\tdefaultBranch = {}\n",
1171                        branch.trim_start_matches("refs/heads/")
1172                    );
1173                    let (config_cid, config_size) = self.tree.put(config.as_bytes()).await
1174                        .map_err(|e| Error::StorageError(format!("put config: {}", e)))?;
1175                    git_entries.push(DirEntry::from_cid("config", &config_cid).with_size(config_size));
1176                }
1177
1178                // Build and add index file if we have a tree SHA
1179                if let Some(ref tree_oid) = tree_sha {
1180                    if let Some(progress) = progress {
1181                        progress.start_phase(RepoTreeBuildPhase::Index, None);
1182                    }
1183                    let index_result = if let (Some(base_tree), Some(base_objects_cid), Some(base_tree_sha)) =
1184                        (base_tree, base_objects_cid.as_ref(), base_tree_sha.as_deref())
1185                    {
1186                        let sources = BaseObjectSources {
1187                            objects: &objects_clone,
1188                            tree: base_tree,
1189                            objects_cid: base_objects_cid,
1190                            progress,
1191                        };
1192                        self.build_index_file_with_base(
1193                            tree_oid,
1194                            &sources,
1195                            base_tree_sha,
1196                            base_root_entries.as_ref(),
1197                        )
1198                        .await
1199                    } else {
1200                        self.build_index_file(tree_oid, &objects_clone, progress)
1201                    };
1202                    match index_result {
1203                        Ok(index_data) => {
1204                            let (index_cid, index_size) = self.tree.put(&index_data).await
1205                                .map_err(|e| Error::StorageError(format!("put index: {}", e)))?;
1206                            git_entries.push(DirEntry::from_cid("index", &index_cid).with_size(index_size));
1207                            info!("Added git index file ({} bytes)", index_data.len());
1208                        }
1209                        Err(e) => {
1210                            debug!("Failed to build git index file: {} - continuing without index", e);
1211                        }
1212                    }
1213                }
1214
1215                if let Some(progress) = progress {
1216                    progress.start_phase(RepoTreeBuildPhase::GitDir, Some(1));
1217                }
1218                let git_cid = self.tree.put_directory(git_entries).await
1219                    .map_err(|e| Error::StorageError(format!("put .git: {}", e)))?;
1220                if let Some(progress) = progress {
1221                    progress.increment_processed();
1222                }
1223
1224                // Build root entries starting with .git
1225                // Use from_cid to preserve the encryption key
1226                let mut root_entries = vec![DirEntry::from_cid(".git", &git_cid).with_link_type(LinkType::Dir)];
1227
1228                // Add working tree files if we have a tree SHA
1229                if let Some(ref tree_oid) = tree_sha {
1230                    if let Some(progress) = progress {
1231                        progress.start_phase(RepoTreeBuildPhase::WorkingTree, None);
1232                    }
1233                    let working_tree_entries = if let (Some(base_tree), Some(base_objects_cid), Some(base_tree_sha)) =
1234                        (base_tree, base_objects_cid.as_ref(), base_tree_sha.as_deref())
1235                    {
1236                        let sources = BaseObjectSources {
1237                            objects: &objects_clone,
1238                            tree: base_tree,
1239                            objects_cid: base_objects_cid,
1240                            progress,
1241                        };
1242                        self.build_working_tree_entries_with_base(
1243                            tree_oid,
1244                            &sources,
1245                            base_tree_sha,
1246                            base_root_entries.as_ref(),
1247                        )
1248                        .await?
1249                    } else {
1250                        self.build_working_tree_entries(tree_oid, &objects_clone, progress).await?
1251                    };
1252                    root_entries.extend(working_tree_entries);
1253                    info!("Added {} working tree entries to root", root_entries.len() - 1);
1254                }
1255
1256                // Sort entries for deterministic ordering
1257                root_entries.sort_by(|a, b| a.name.cmp(&b.name));
1258
1259                if let Some(progress) = progress {
1260                    progress.start_phase(RepoTreeBuildPhase::Root, Some(1));
1261                }
1262                let root_cid = self.tree.put_directory(root_entries).await
1263                    .map_err(|e| Error::StorageError(format!("put root: {}", e)))?;
1264                if let Some(progress) = progress {
1265                    progress.increment_processed();
1266                }
1267
1268                info!("Built hashtree root: {} (encrypted: {}) (.git dir: {})",
1269                    hex::encode(root_cid.hash),
1270                    root_cid.key.is_some(),
1271                    hex::encode(git_cid.hash));
1272
1273                Ok::<Cid, Error>(root_cid)
1274            });
1275
1276            match build_result {
1277                Ok(root_cid) => break root_cid,
1278                Err(Error::ObjectNotFound(oid))
1279                    if base_tree.is_some() && base_objects_cid.is_some() =>
1280                {
1281                    let imported =
1282                        self.runtime
1283                            .block_on(self.seed_missing_object_from_base_boxed(
1284                                &oid,
1285                                &mut objects_clone,
1286                                base_tree.expect("checked is_some"),
1287                                base_objects_cid.as_ref().expect("checked is_some"),
1288                            ))?;
1289                    if imported {
1290                        continue;
1291                    }
1292                    return Err(Error::ObjectNotFound(oid));
1293                }
1294                Err(err) => return Err(err),
1295            }
1296        };
1297
1298        // Cache the root CID
1299        if let Ok(mut root) = self.root_cid.write() {
1300            *root = Some(root_cid.clone());
1301        }
1302        if let Some(progress) = progress {
1303            progress.mark_done();
1304        }
1305
1306        Ok(root_cid)
1307    }
1308
1309    /// Build working tree entries from a git tree object
1310    async fn build_working_tree_entries(
1311        &self,
1312        tree_oid: &str,
1313        objects: &HashMap<String, Vec<u8>>,
1314        progress: Option<&RepoTreeBuildProgress>,
1315    ) -> Result<Vec<DirEntry>> {
1316        let mut entries = Vec::new();
1317
1318        // Get tree content
1319        let (obj_type, content) = self
1320            .get_object_content(tree_oid, objects)
1321            .ok_or_else(|| Error::ObjectNotFound(tree_oid.to_string()))?;
1322
1323        if obj_type != ObjectType::Tree {
1324            return Err(Error::InvalidObjectType(format!(
1325                "expected tree, got {:?}",
1326                obj_type
1327            )));
1328        }
1329
1330        // Parse tree entries
1331        let tree_entries = parse_tree(&content)?;
1332
1333        for entry in tree_entries {
1334            let oid_hex = entry.oid.to_hex();
1335
1336            if entry.is_tree() {
1337                // Recursively build subdirectory
1338                let sub_entries = self
1339                    .build_working_tree_entries_boxed(&oid_hex, objects, progress)
1340                    .await?;
1341
1342                // Create subdirectory in hashtree
1343                let dir_cid =
1344                    self.tree.put_directory(sub_entries).await.map_err(|e| {
1345                        Error::StorageError(format!("put dir {}: {}", entry.name, e))
1346                    })?;
1347
1348                // Use from_cid to preserve encryption key
1349                entries
1350                    .push(DirEntry::from_cid(&entry.name, &dir_cid).with_link_type(LinkType::Dir));
1351                if let Some(progress) = progress {
1352                    progress.record_working_dir(false);
1353                }
1354            } else {
1355                // Get blob content
1356                if let Some((ObjectType::Blob, blob_content)) =
1357                    self.get_object_content(&oid_hex, objects)
1358                {
1359                    // Use put() instead of put_blob() to chunk large files
1360                    let (cid, size) = self.tree.put(&blob_content).await.map_err(|e| {
1361                        Error::StorageError(format!("put blob {}: {}", entry.name, e))
1362                    })?;
1363
1364                    // Use from_cid to preserve encryption key
1365                    entries.push(DirEntry::from_cid(&entry.name, &cid).with_size(size));
1366                    if let Some(progress) = progress {
1367                        progress.record_working_file(false);
1368                    }
1369                }
1370            }
1371        }
1372
1373        // Sort for deterministic ordering
1374        entries.sort_by(|a, b| a.name.cmp(&b.name));
1375
1376        Ok(entries)
1377    }
1378
1379    /// Boxed version for async recursion
1380    fn build_working_tree_entries_boxed<'a>(
1381        &'a self,
1382        tree_oid: &'a str,
1383        objects: &'a HashMap<String, Vec<u8>>,
1384        progress: Option<&'a RepoTreeBuildProgress>,
1385    ) -> BoxFuture<'a, Result<Vec<DirEntry>>> {
1386        Box::pin(self.build_working_tree_entries(tree_oid, objects, progress))
1387    }
1388
1389    fn build_working_tree_entries_with_base_recursive_boxed<'a, S: Store + 'a>(
1390        &'a self,
1391        tree_oid: &'a str,
1392        sources: &'a BaseObjectSources<'a, S>,
1393        old_tree_oid: Option<&'a str>,
1394        old_dir_entries: Option<&'a Vec<hashtree_core::TreeEntry>>,
1395    ) -> BoxFuture<'a, Result<Vec<DirEntry>>> {
1396        Box::pin(async move {
1397            let tree_entries = self
1398                .get_tree_entries_from_sources(
1399                    tree_oid,
1400                    sources.objects,
1401                    Some(sources.tree),
1402                    Some(sources.objects_cid),
1403                )
1404                .await?;
1405            let old_tree_entries = if let Some(old_tree_oid) = old_tree_oid {
1406                match self
1407                    .get_tree_entries_from_sources(
1408                        old_tree_oid,
1409                        sources.objects,
1410                        Some(sources.tree),
1411                        Some(sources.objects_cid),
1412                    )
1413                    .await
1414                {
1415                    Ok(entries) => Some(entries),
1416                    Err(Error::ObjectNotFound(_)) if old_dir_entries.is_some() => None,
1417                    Err(err) => return Err(err),
1418                }
1419            } else {
1420                None
1421            };
1422            let old_tree_available = old_tree_entries.is_some();
1423
1424            let mut old_tree_entry_map = old_tree_entries
1425                .unwrap_or_default()
1426                .into_iter()
1427                .map(|entry| (entry.name.clone(), entry))
1428                .collect::<HashMap<_, _>>();
1429            let mut old_dir_entry_map = old_dir_entries
1430                .cloned()
1431                .unwrap_or_default()
1432                .into_iter()
1433                .map(|entry| (entry.name.clone(), entry))
1434                .collect::<HashMap<_, _>>();
1435
1436            let mut entries = Vec::new();
1437            for entry in tree_entries {
1438                let oid_hex = entry.oid.to_hex();
1439                let old_tree_entry = old_tree_entry_map.remove(&entry.name);
1440                let old_dir_entry = old_dir_entry_map.remove(&entry.name);
1441
1442                if entry.is_tree() {
1443                    if let (Some(old_tree_entry), Some(old_dir_entry)) =
1444                        (old_tree_entry.as_ref(), old_dir_entry.as_ref())
1445                    {
1446                        if old_tree_entry.is_tree()
1447                            && old_tree_entry.oid == entry.oid
1448                            && old_dir_entry.link_type == LinkType::Dir
1449                        {
1450                            entries.push(Self::tree_entry_to_dir_entry(old_dir_entry));
1451                            if let Some(progress) = sources.progress {
1452                                progress.record_working_dir(true);
1453                            }
1454                            continue;
1455                        }
1456                    }
1457
1458                    let old_subtree_oid = old_tree_entry
1459                        .as_ref()
1460                        .filter(|old| old.is_tree())
1461                        .map(|old| old.oid.to_hex());
1462                    let old_subdir_entries = if let Some(old_dir_entry) = old_dir_entry.as_ref() {
1463                        if old_dir_entry.link_type == LinkType::Dir {
1464                            Some(
1465                                sources
1466                                    .tree
1467                                    .list_directory(&Cid {
1468                                        hash: old_dir_entry.hash,
1469                                        key: old_dir_entry.key,
1470                                    })
1471                                    .await
1472                                    .map_err(|e| {
1473                                        Error::StorageError(format!(
1474                                            "list base working dir {}: {}",
1475                                            entry.name, e
1476                                        ))
1477                                    })?,
1478                            )
1479                        } else {
1480                            None
1481                        }
1482                    } else {
1483                        None
1484                    };
1485
1486                    let sub_entries = self
1487                        .build_working_tree_entries_with_base_recursive_boxed(
1488                            &oid_hex,
1489                            sources,
1490                            old_subtree_oid.as_deref(),
1491                            old_subdir_entries.as_ref(),
1492                        )
1493                        .await?;
1494                    let dir_cid = self.tree.put_directory(sub_entries).await.map_err(|e| {
1495                        Error::StorageError(format!("put dir {}: {}", entry.name, e))
1496                    })?;
1497                    entries.push(
1498                        DirEntry::from_cid(&entry.name, &dir_cid).with_link_type(LinkType::Dir),
1499                    );
1500                    if let Some(progress) = sources.progress {
1501                        progress.record_working_dir(false);
1502                    }
1503                    continue;
1504                }
1505
1506                if let (Some(old_tree_entry), Some(old_dir_entry)) =
1507                    (old_tree_entry.as_ref(), old_dir_entry.as_ref())
1508                {
1509                    if !old_tree_entry.is_tree()
1510                        && old_tree_entry.oid == entry.oid
1511                        && old_dir_entry.link_type != LinkType::Dir
1512                    {
1513                        entries.push(Self::tree_entry_to_dir_entry(old_dir_entry));
1514                        if let Some(progress) = sources.progress {
1515                            progress.record_working_file(true);
1516                        }
1517                        continue;
1518                    }
1519                }
1520
1521                let blob_content = match self.get_object_content(&oid_hex, sources.objects) {
1522                    Some((ObjectType::Blob, blob_content)) => blob_content,
1523                    Some((obj_type, _)) => {
1524                        return Err(Error::InvalidObjectType(format!(
1525                            "expected blob, got {:?}",
1526                            obj_type
1527                        )));
1528                    }
1529                    None if !old_tree_available => {
1530                        if let Some(old_dir_entry) = old_dir_entry.as_ref() {
1531                            if old_dir_entry.link_type != LinkType::Dir {
1532                                entries.push(Self::tree_entry_to_dir_entry(old_dir_entry));
1533                                if let Some(progress) = sources.progress {
1534                                    progress.record_working_file(true);
1535                                }
1536                                continue;
1537                            }
1538                        }
1539                        return Err(Error::ObjectNotFound(oid_hex));
1540                    }
1541                    None => match self
1542                        .get_object_content_from_base(&oid_hex, sources.tree, sources.objects_cid)
1543                        .await?
1544                    {
1545                        Some((ObjectType::Blob, blob_content)) => blob_content,
1546                        Some((obj_type, _)) => {
1547                            return Err(Error::InvalidObjectType(format!(
1548                                "expected blob, got {:?}",
1549                                obj_type
1550                            )));
1551                        }
1552                        None => return Err(Error::ObjectNotFound(oid_hex)),
1553                    },
1554                };
1555
1556                let (cid, size) =
1557                    self.tree.put(&blob_content).await.map_err(|e| {
1558                        Error::StorageError(format!("put blob {}: {}", entry.name, e))
1559                    })?;
1560                entries.push(DirEntry::from_cid(&entry.name, &cid).with_size(size));
1561                if let Some(progress) = sources.progress {
1562                    progress.record_working_file(false);
1563                }
1564            }
1565
1566            entries.sort_by(|a, b| a.name.cmp(&b.name));
1567            Ok(entries)
1568        })
1569    }
1570
1571    async fn build_working_tree_entries_with_base<S: Store>(
1572        &self,
1573        tree_oid: &str,
1574        sources: &BaseObjectSources<'_, S>,
1575        base_tree_oid: &str,
1576        base_root_entries: Option<&Vec<hashtree_core::TreeEntry>>,
1577    ) -> Result<Vec<DirEntry>> {
1578        let root_entries = base_root_entries.map(|entries| {
1579            entries
1580                .iter()
1581                .filter(|entry| entry.name != ".git")
1582                .cloned()
1583                .collect::<Vec<_>>()
1584        });
1585
1586        self.build_working_tree_entries_with_base_recursive_boxed(
1587            tree_oid,
1588            sources,
1589            Some(base_tree_oid),
1590            root_entries.as_ref(),
1591        )
1592        .await
1593    }
1594
1595    async fn build_objects_prefix_dir(
1596        &self,
1597        prefix: &str,
1598        old_entries: Option<Vec<hashtree_core::TreeEntry>>,
1599        new_objects: &[(String, Vec<u8>)],
1600        progress: Option<&RepoTreeBuildProgress>,
1601    ) -> Result<Cid> {
1602        let mut sub_entries: BTreeMap<String, DirEntry> = old_entries
1603            .unwrap_or_default()
1604            .into_iter()
1605            .map(|entry| (entry.name.clone(), Self::tree_entry_to_dir_entry(&entry)))
1606            .collect();
1607
1608        for (suffix, data) in new_objects {
1609            let (cid, size) = self.tree.put(data).await.map_err(|e| {
1610                Error::StorageError(format!("put object {}{}: {}", prefix, suffix, e))
1611            })?;
1612            sub_entries.insert(
1613                suffix.clone(),
1614                DirEntry::from_cid(suffix, &cid).with_size(size),
1615            );
1616            if let Some(progress) = progress {
1617                progress.record_object_blob();
1618            }
1619        }
1620
1621        let cid = self
1622            .tree
1623            .put_directory(sub_entries.into_values().collect())
1624            .await
1625            .map_err(|e| Error::StorageError(format!("put objects/{}: {}", prefix, e)))?;
1626        if let Some(progress) = progress {
1627            progress.record_object_prefix();
1628        }
1629        Ok(cid)
1630    }
1631
1632    /// Build the objects directory using HashTree, reusing unchanged prefix directories
1633    /// from an older root when available.
1634    async fn build_objects_dir_with_base<S: Store>(
1635        &self,
1636        objects: &HashMap<String, Vec<u8>>,
1637        base_tree: Option<&HashTree<S>>,
1638        base_objects_cid: Option<&Cid>,
1639        progress: Option<&RepoTreeBuildProgress>,
1640    ) -> Result<Cid> {
1641        if let Some(progress) = progress {
1642            progress.start_phase(RepoTreeBuildPhase::Objects, Some(objects.len()));
1643        }
1644
1645        let pack_files = self
1646            .pack_files
1647            .read()
1648            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?
1649            .clone();
1650        let packed_object_ids = self
1651            .packed_object_ids
1652            .read()
1653            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?
1654            .clone();
1655        let packs_replace_loose_objects =
1656            !packed_object_ids.is_empty() && (!pack_files.is_empty() || base_objects_cid.is_some());
1657        let mut buckets: BTreeMap<String, Vec<(String, Vec<u8>)>> = BTreeMap::new();
1658        for (oid, data) in objects {
1659            if packs_replace_loose_objects && packed_object_ids.contains(oid) {
1660                continue;
1661            }
1662            let prefix = &oid[..2];
1663            let suffix = &oid[2..];
1664            buckets
1665                .entry(prefix.to_string())
1666                .or_default()
1667                .push((suffix.to_string(), data.clone()));
1668        }
1669
1670        let mut top_entries: BTreeMap<String, DirEntry> = BTreeMap::new();
1671        let mut merged_prefixes = std::collections::HashSet::new();
1672        let mut inherited_pack_entries: BTreeMap<String, DirEntry> = BTreeMap::new();
1673        let empty_objects: Vec<(String, Vec<u8>)> = Vec::new();
1674
1675        if let (Some(base_tree), Some(base_objects_cid)) = (base_tree, base_objects_cid) {
1676            for entry in base_tree
1677                .list_directory(base_objects_cid)
1678                .await
1679                .map_err(|e| Error::StorageError(format!("list base objects dir: {}", e)))?
1680            {
1681                if entry.name == "info" || entry.name == "pack" {
1682                    if pack_files.is_empty() {
1683                        top_entries
1684                            .insert(entry.name.clone(), Self::tree_entry_to_dir_entry(&entry));
1685                    } else if entry.name == "pack" && entry.link_type == LinkType::Dir {
1686                        let pack_cid = Cid {
1687                            hash: entry.hash,
1688                            key: entry.key,
1689                        };
1690                        for pack_entry in
1691                            base_tree.list_directory(&pack_cid).await.map_err(|e| {
1692                                Error::StorageError(format!("list base objects/pack dir: {}", e))
1693                            })?
1694                        {
1695                            inherited_pack_entries.insert(
1696                                pack_entry.name.clone(),
1697                                Self::tree_entry_to_dir_entry(&pack_entry),
1698                            );
1699                        }
1700                    }
1701                    continue;
1702                }
1703
1704                let Some(delta_objects) = buckets.get(&entry.name) else {
1705                    if !packs_replace_loose_objects {
1706                        top_entries
1707                            .insert(entry.name.clone(), Self::tree_entry_to_dir_entry(&entry));
1708                        continue;
1709                    }
1710
1711                    if entry.link_type != LinkType::Dir || entry.name.len() != 2 {
1712                        top_entries
1713                            .insert(entry.name.clone(), Self::tree_entry_to_dir_entry(&entry));
1714                        continue;
1715                    }
1716
1717                    let prefix_cid = Cid {
1718                        hash: entry.hash,
1719                        key: entry.key,
1720                    };
1721                    let old_prefix_entries = base_tree
1722                        .list_directory(&prefix_cid)
1723                        .await
1724                        .map_err(|e| {
1725                            Error::StorageError(format!(
1726                                "list base objects/{} dir: {}",
1727                                entry.name, e
1728                            ))
1729                        })?
1730                        .into_iter()
1731                        .filter(|old_entry| {
1732                            !packed_object_ids
1733                                .contains(&format!("{}{}", entry.name, old_entry.name))
1734                        })
1735                        .collect::<Vec<_>>();
1736                    if old_prefix_entries.is_empty() {
1737                        continue;
1738                    }
1739
1740                    let merged_cid = self
1741                        .build_objects_prefix_dir(
1742                            &entry.name,
1743                            Some(old_prefix_entries),
1744                            &empty_objects,
1745                            progress,
1746                        )
1747                        .await?;
1748                    top_entries.insert(
1749                        entry.name.clone(),
1750                        DirEntry::from_cid(&entry.name, &merged_cid).with_link_type(LinkType::Dir),
1751                    );
1752                    merged_prefixes.insert(entry.name);
1753                    continue;
1754                };
1755
1756                let prefix_cid = Cid {
1757                    hash: entry.hash,
1758                    key: entry.key,
1759                };
1760                let old_prefix_entries = if entry.link_type == LinkType::Dir {
1761                    let mut entries = base_tree.list_directory(&prefix_cid).await.map_err(|e| {
1762                        Error::StorageError(format!("list base objects/{} dir: {}", entry.name, e))
1763                    })?;
1764                    if packs_replace_loose_objects {
1765                        entries.retain(|old_entry| {
1766                            !packed_object_ids
1767                                .contains(&format!("{}{}", entry.name, old_entry.name))
1768                        });
1769                    }
1770                    Some(entries)
1771                } else {
1772                    None
1773                };
1774                let merged_cid = self
1775                    .build_objects_prefix_dir(
1776                        &entry.name,
1777                        old_prefix_entries,
1778                        delta_objects,
1779                        progress,
1780                    )
1781                    .await?;
1782                top_entries.insert(
1783                    entry.name.clone(),
1784                    DirEntry::from_cid(&entry.name, &merged_cid).with_link_type(LinkType::Dir),
1785                );
1786                merged_prefixes.insert(entry.name);
1787            }
1788        }
1789
1790        for (prefix, objs) in &buckets {
1791            if merged_prefixes.contains(prefix) {
1792                continue;
1793            }
1794            let sub_cid = self
1795                .build_objects_prefix_dir(prefix, None, objs, progress)
1796                .await?;
1797            top_entries.insert(
1798                prefix.clone(),
1799                DirEntry::from_cid(prefix, &sub_cid).with_link_type(LinkType::Dir),
1800            );
1801        }
1802
1803        if !pack_files.is_empty() {
1804            let mut pack_entries_by_name = inherited_pack_entries;
1805            for (name, data) in pack_files {
1806                let (cid, size) = self.tree.put(&data).await.map_err(|e| {
1807                    Error::StorageError(format!("put objects/pack/{}: {}", name, e))
1808                })?;
1809                pack_entries_by_name.insert(
1810                    name.clone(),
1811                    DirEntry::from_cid(&name, &cid).with_size(size),
1812                );
1813            }
1814            let mut pack_names_for_info_packs = pack_entries_by_name
1815                .keys()
1816                .filter(|name| name.ends_with(".pack"))
1817                .cloned()
1818                .collect::<Vec<_>>();
1819            pack_names_for_info_packs.sort();
1820
1821            let pack_dir_cid = self
1822                .tree
1823                .put_directory(pack_entries_by_name.into_values().collect())
1824                .await
1825                .map_err(|e| Error::StorageError(format!("put objects/pack: {}", e)))?;
1826            top_entries.insert(
1827                "pack".to_string(),
1828                DirEntry::from_cid("pack", &pack_dir_cid).with_link_type(LinkType::Dir),
1829            );
1830
1831            let packs_content = pack_names_for_info_packs
1832                .iter()
1833                .map(|name| format!("P {}\n", name))
1834                .collect::<String>();
1835            let (packs_cid, packs_size) = self
1836                .tree
1837                .put(packs_content.as_bytes())
1838                .await
1839                .map_err(|e| Error::StorageError(format!("put objects/info/packs: {}", e)))?;
1840            let info_cid = self
1841                .tree
1842                .put_directory(vec![
1843                    DirEntry::from_cid("packs", &packs_cid).with_size(packs_size)
1844                ])
1845                .await
1846                .map_err(|e| Error::StorageError(format!("put objects/info: {}", e)))?;
1847            top_entries.insert(
1848                "info".to_string(),
1849                DirEntry::from_cid("info", &info_cid).with_link_type(LinkType::Dir),
1850            );
1851        }
1852
1853        if !top_entries.contains_key("info") {
1854            let (packs_cid, packs_size) = self
1855                .tree
1856                .put(b"")
1857                .await
1858                .map_err(|e| Error::StorageError(format!("put objects/info/packs: {}", e)))?;
1859            let info_cid = self
1860                .tree
1861                .put_directory(vec![
1862                    DirEntry::from_cid("packs", &packs_cid).with_size(packs_size)
1863                ])
1864                .await
1865                .map_err(|e| Error::StorageError(format!("put objects/info: {}", e)))?;
1866            top_entries.insert(
1867                "info".to_string(),
1868                DirEntry::from_cid("info", &info_cid).with_link_type(LinkType::Dir),
1869            );
1870        }
1871
1872        let entry_count = top_entries.len();
1873        let cid = self
1874            .tree
1875            .put_directory(top_entries.into_values().collect())
1876            .await
1877            .map_err(|e| Error::StorageError(format!("put objects dir: {}", e)))?;
1878
1879        debug!(
1880            "Built objects dir with {} entries: {}",
1881            entry_count,
1882            hex::encode(cid.hash)
1883        );
1884        Ok(cid)
1885    }
1886
1887    /// Build the refs directory using HashTree
1888    async fn build_refs_dir(
1889        &self,
1890        refs: &HashMap<String, String>,
1891        progress: Option<&RepoTreeBuildProgress>,
1892    ) -> Result<Cid> {
1893        let mut root = RefDirectory::default();
1894
1895        for (ref_name, value) in refs {
1896            let parts: Vec<&str> = ref_name.split('/').collect();
1897            if parts.len() >= 3 && parts[0] == "refs" {
1898                root.insert(&parts[1..], value.clone());
1899            }
1900        }
1901
1902        let mut ref_entries = self
1903            .build_ref_entries_recursive(&root, "refs", progress)
1904            .await?;
1905
1906        if ref_entries.is_empty() {
1907            // Return empty directory Cid
1908            let empty_cid = self
1909                .tree
1910                .put_directory(vec![])
1911                .await
1912                .map_err(|e| Error::StorageError(format!("put empty refs: {}", e)))?;
1913            return Ok(empty_cid);
1914        }
1915
1916        ref_entries.sort_by(|a, b| a.name.cmp(&b.name));
1917
1918        let refs_cid = self
1919            .tree
1920            .put_directory(ref_entries)
1921            .await
1922            .map_err(|e| Error::StorageError(format!("put refs dir: {}", e)))?;
1923        debug!("refs dir -> {}", hex::encode(refs_cid.hash));
1924        Ok(refs_cid)
1925    }
1926
1927    fn build_ref_entries_recursive<'a>(
1928        &'a self,
1929        dir: &'a RefDirectory,
1930        prefix: &'a str,
1931        progress: Option<&'a RepoTreeBuildProgress>,
1932    ) -> BoxFuture<'a, Result<Vec<DirEntry>>> {
1933        Box::pin(async move {
1934            let mut entries = Vec::new();
1935
1936            for (name, value) in &dir.files {
1937                let (cid, size) = self
1938                    .tree
1939                    .put(value.as_bytes())
1940                    .await
1941                    .map_err(|e| Error::StorageError(format!("put ref: {}", e)))?;
1942                debug!("{}/{} -> blob {}", prefix, name, hex::encode(cid.hash));
1943                entries.push(DirEntry::from_cid(name, &cid).with_size(size));
1944                if let Some(progress) = progress {
1945                    progress.increment_processed();
1946                }
1947            }
1948
1949            for (name, child) in &dir.dirs {
1950                let child_prefix = format!("{prefix}/{name}");
1951                let child_entries = self
1952                    .build_ref_entries_recursive(child, &child_prefix, progress)
1953                    .await?;
1954                let child_cid =
1955                    self.tree.put_directory(child_entries).await.map_err(|e| {
1956                        Error::StorageError(format!("put {child_prefix} dir: {}", e))
1957                    })?;
1958                debug!("{} dir -> {}", child_prefix, hex::encode(child_cid.hash));
1959                entries.push(DirEntry::from_cid(name, &child_cid).with_link_type(LinkType::Dir));
1960            }
1961
1962            entries.sort_by(|a, b| a.name.cmp(&b.name));
1963            Ok(entries)
1964        })
1965    }
1966
1967    /// Build git index file from tree entries
1968    /// Returns the raw binary content of the index file
1969    fn build_index_file(
1970        &self,
1971        tree_oid: &str,
1972        objects: &HashMap<String, Vec<u8>>,
1973        progress: Option<&RepoTreeBuildProgress>,
1974    ) -> Result<Vec<u8>> {
1975        // Collect all file entries from the tree (recursively)
1976        let mut entries: Vec<(String, [u8; 20], u32, u32)> = Vec::new(); // (path, sha1, mode, size)
1977        self.collect_tree_entries_for_index(tree_oid, objects, "", &mut entries, progress)?;
1978
1979        self.build_index_bytes(entries)
1980    }
1981
1982    async fn build_index_file_with_base<S: Store>(
1983        &self,
1984        tree_oid: &str,
1985        sources: &BaseObjectSources<'_, S>,
1986        base_tree_oid: &str,
1987        base_root_entries: Option<&Vec<hashtree_core::TreeEntry>>,
1988    ) -> Result<Vec<u8>> {
1989        let mut entries: Vec<(String, [u8; 20], u32, u32)> = Vec::new();
1990        let root_entries = base_root_entries.map(|entries| {
1991            entries
1992                .iter()
1993                .filter(|entry| entry.name != ".git")
1994                .cloned()
1995                .collect::<Vec<_>>()
1996        });
1997
1998        self.collect_tree_entries_for_index_with_base_boxed(
1999            tree_oid,
2000            sources,
2001            Some(base_tree_oid),
2002            root_entries.as_ref(),
2003            "",
2004            &mut entries,
2005        )
2006        .await?;
2007
2008        self.build_index_bytes(entries)
2009    }
2010
2011    fn build_index_bytes(&self, mut entries: Vec<(String, [u8; 20], u32, u32)>) -> Result<Vec<u8>> {
2012        // Sort entries by path (git index requirement)
2013        entries.sort_by(|a, b| a.0.cmp(&b.0));
2014
2015        let entry_count = entries.len() as u32;
2016        debug!("Building git index with {} entries", entry_count);
2017
2018        // Build index content
2019        let mut index_data = Vec::new();
2020
2021        // Header: DIRC + version 2 + entry count
2022        index_data.extend_from_slice(b"DIRC");
2023        index_data.extend_from_slice(&2u32.to_be_bytes()); // version 2
2024        index_data.extend_from_slice(&entry_count.to_be_bytes());
2025
2026        // Current time for ctime/mtime (doesn't matter much for our use case)
2027        let now_sec = std::time::SystemTime::now()
2028            .duration_since(std::time::UNIX_EPOCH)
2029            .unwrap_or_default()
2030            .as_secs() as u32;
2031
2032        for (path, sha1, mode, size) in &entries {
2033            let entry_start = index_data.len();
2034
2035            // ctime sec, nsec
2036            index_data.extend_from_slice(&now_sec.to_be_bytes());
2037            index_data.extend_from_slice(&0u32.to_be_bytes());
2038            // mtime sec, nsec
2039            index_data.extend_from_slice(&now_sec.to_be_bytes());
2040            index_data.extend_from_slice(&0u32.to_be_bytes());
2041            // dev, ino (use 0)
2042            index_data.extend_from_slice(&0u32.to_be_bytes());
2043            index_data.extend_from_slice(&0u32.to_be_bytes());
2044            // mode
2045            index_data.extend_from_slice(&mode.to_be_bytes());
2046            // uid, gid (use 0)
2047            index_data.extend_from_slice(&0u32.to_be_bytes());
2048            index_data.extend_from_slice(&0u32.to_be_bytes());
2049            // file size
2050            index_data.extend_from_slice(&size.to_be_bytes());
2051            // SHA-1
2052            index_data.extend_from_slice(sha1);
2053            // flags: path length (max 0xFFF) in low 12 bits
2054            let path_len = std::cmp::min(path.len(), 0xFFF) as u16;
2055            index_data.extend_from_slice(&path_len.to_be_bytes());
2056            // path (NUL-terminated)
2057            index_data.extend_from_slice(path.as_bytes());
2058            index_data.push(0); // NUL terminator
2059
2060            // Pad to 8-byte boundary relative to entry start
2061            let entry_len = index_data.len() - entry_start;
2062            let padding = (8 - (entry_len % 8)) % 8;
2063            index_data.extend(std::iter::repeat_n(0, padding));
2064        }
2065
2066        // Calculate SHA-1 checksum of everything and append
2067        let mut hasher = Sha1::new();
2068        hasher.update(&index_data);
2069        let checksum = hasher.finalize();
2070        index_data.extend_from_slice(&checksum);
2071
2072        debug!(
2073            "Built git index: {} bytes, {} entries",
2074            index_data.len(),
2075            entry_count
2076        );
2077        Ok(index_data)
2078    }
2079
2080    /// Collect file entries from a git tree for building the index
2081    fn collect_tree_entries_for_index(
2082        &self,
2083        tree_oid: &str,
2084        objects: &HashMap<String, Vec<u8>>,
2085        prefix: &str,
2086        entries: &mut Vec<(String, [u8; 20], u32, u32)>,
2087        progress: Option<&RepoTreeBuildProgress>,
2088    ) -> Result<()> {
2089        let (obj_type, content) = self
2090            .get_object_content(tree_oid, objects)
2091            .ok_or_else(|| Error::ObjectNotFound(tree_oid.to_string()))?;
2092
2093        if obj_type != ObjectType::Tree {
2094            return Err(Error::InvalidObjectType(format!(
2095                "expected tree, got {:?}",
2096                obj_type
2097            )));
2098        }
2099
2100        let tree_entries = parse_tree(&content)?;
2101
2102        for entry in tree_entries {
2103            let path = if prefix.is_empty() {
2104                entry.name.clone()
2105            } else {
2106                format!("{}/{}", prefix, entry.name)
2107            };
2108
2109            let oid_hex = entry.oid.to_hex();
2110
2111            if entry.is_tree() {
2112                // Recursively process subdirectory
2113                self.collect_tree_entries_for_index(&oid_hex, objects, &path, entries, progress)?;
2114            } else {
2115                // Get blob content for size and SHA-1
2116                if let Some((ObjectType::Blob, blob_content)) =
2117                    self.get_object_content(&oid_hex, objects)
2118                {
2119                    // Convert hex SHA to bytes
2120                    let mut sha1_bytes = [0u8; 20];
2121                    if let Ok(bytes) = hex::decode(&oid_hex) {
2122                        if bytes.len() == 20 {
2123                            sha1_bytes.copy_from_slice(&bytes);
2124                        }
2125                    }
2126
2127                    // Mode: use entry.mode or default to regular file
2128                    let mode = entry.mode;
2129                    let size = blob_content.len() as u32;
2130
2131                    entries.push((path, sha1_bytes, mode, size));
2132                    if let Some(progress) = progress {
2133                        progress.record_index_entry();
2134                    }
2135                }
2136            }
2137        }
2138
2139        Ok(())
2140    }
2141
2142    fn collect_tree_entries_for_index_with_base_boxed<'a, S: Store + 'a>(
2143        &'a self,
2144        tree_oid: &'a str,
2145        sources: &'a BaseObjectSources<'a, S>,
2146        old_tree_oid: Option<&'a str>,
2147        old_dir_entries: Option<&'a Vec<hashtree_core::TreeEntry>>,
2148        prefix: &'a str,
2149        entries: &'a mut Vec<(String, [u8; 20], u32, u32)>,
2150    ) -> BoxFuture<'a, Result<()>> {
2151        Box::pin(async move {
2152            let tree_entries = self
2153                .get_tree_entries_from_sources(
2154                    tree_oid,
2155                    sources.objects,
2156                    Some(sources.tree),
2157                    Some(sources.objects_cid),
2158                )
2159                .await?;
2160            let old_tree_entries = if let Some(old_tree_oid) = old_tree_oid {
2161                Some(
2162                    self.get_tree_entries_from_sources(
2163                        old_tree_oid,
2164                        sources.objects,
2165                        Some(sources.tree),
2166                        Some(sources.objects_cid),
2167                    )
2168                    .await?,
2169                )
2170            } else {
2171                None
2172            };
2173            let mut old_tree_entry_map = old_tree_entries
2174                .unwrap_or_default()
2175                .into_iter()
2176                .map(|entry| (entry.name.clone(), entry))
2177                .collect::<HashMap<_, _>>();
2178            let mut old_dir_entry_map = old_dir_entries
2179                .cloned()
2180                .unwrap_or_default()
2181                .into_iter()
2182                .map(|entry| (entry.name.clone(), entry))
2183                .collect::<HashMap<_, _>>();
2184
2185            for entry in tree_entries {
2186                let path = if prefix.is_empty() {
2187                    entry.name.clone()
2188                } else {
2189                    format!("{}/{}", prefix, entry.name)
2190                };
2191                let oid_hex = entry.oid.to_hex();
2192                let old_tree_entry = old_tree_entry_map.remove(&entry.name);
2193                let old_dir_entry = old_dir_entry_map.remove(&entry.name);
2194
2195                if entry.is_tree() {
2196                    let old_subtree_oid = old_tree_entry
2197                        .as_ref()
2198                        .filter(|old| old.is_tree())
2199                        .map(|old| old.oid.to_hex());
2200                    let old_subdir_entries = if let Some(old_dir_entry) = old_dir_entry.as_ref() {
2201                        if old_dir_entry.link_type == LinkType::Dir {
2202                            Some(
2203                                sources
2204                                    .tree
2205                                    .list_directory(&Cid {
2206                                        hash: old_dir_entry.hash,
2207                                        key: old_dir_entry.key,
2208                                    })
2209                                    .await
2210                                    .map_err(|e| {
2211                                        Error::StorageError(format!(
2212                                            "list base working dir {}: {}",
2213                                            path, e
2214                                        ))
2215                                    })?,
2216                            )
2217                        } else {
2218                            None
2219                        }
2220                    } else {
2221                        None
2222                    };
2223
2224                    self.collect_tree_entries_for_index_with_base_boxed(
2225                        &oid_hex,
2226                        sources,
2227                        old_subtree_oid.as_deref(),
2228                        old_subdir_entries.as_ref(),
2229                        &path,
2230                        entries,
2231                    )
2232                    .await?;
2233                    continue;
2234                }
2235
2236                let mut sha1_bytes = [0u8; 20];
2237                if let Ok(bytes) = hex::decode(&oid_hex) {
2238                    if bytes.len() == 20 {
2239                        sha1_bytes.copy_from_slice(&bytes);
2240                    }
2241                }
2242
2243                let size = if let (Some(old_tree_entry), Some(old_dir_entry)) =
2244                    (old_tree_entry.as_ref(), old_dir_entry.as_ref())
2245                {
2246                    if !old_tree_entry.is_tree()
2247                        && old_tree_entry.oid == entry.oid
2248                        && old_dir_entry.link_type != LinkType::Dir
2249                    {
2250                        old_dir_entry.size as u32
2251                    } else {
2252                        self.blob_size_from_sources(
2253                            &oid_hex,
2254                            sources.objects,
2255                            Some(sources.tree),
2256                            Some(sources.objects_cid),
2257                        )
2258                        .await? as u32
2259                    }
2260                } else {
2261                    self.blob_size_from_sources(
2262                        &oid_hex,
2263                        sources.objects,
2264                        Some(sources.tree),
2265                        Some(sources.objects_cid),
2266                    )
2267                    .await? as u32
2268                };
2269
2270                entries.push((path, sha1_bytes, entry.mode, size));
2271                if let Some(progress) = sources.progress {
2272                    progress.record_index_entry();
2273                }
2274            }
2275
2276            Ok(())
2277        })
2278    }
2279
2280    async fn blob_size_from_sources<S: Store>(
2281        &self,
2282        oid: &str,
2283        objects: &HashMap<String, Vec<u8>>,
2284        base_tree: Option<&HashTree<S>>,
2285        base_objects_cid: Option<&Cid>,
2286    ) -> Result<usize> {
2287        let object = match self.get_object_content(oid, objects) {
2288            Some(object) => Some(object),
2289            None => {
2290                if let (Some(base_tree), Some(base_objects_cid)) = (base_tree, base_objects_cid) {
2291                    self.get_object_content_from_base(oid, base_tree, base_objects_cid)
2292                        .await?
2293                } else {
2294                    None
2295                }
2296            }
2297        }
2298        .ok_or_else(|| Error::ObjectNotFound(oid.to_string()))?;
2299
2300        if object.0 != ObjectType::Blob {
2301            return Err(Error::InvalidObjectType(format!(
2302                "expected blob, got {:?}",
2303                object.0
2304            )));
2305        }
2306
2307        Ok(object.1.len())
2308    }
2309
2310    /// Get the underlying store
2311    pub fn store(&self) -> &Arc<LocalStore> {
2312        &self.store
2313    }
2314
2315    /// Get the HashTree for direct access
2316    #[allow(dead_code)]
2317    pub fn hashtree(&self) -> &HashTree<LocalStore> {
2318        &self.tree
2319    }
2320
2321    /// Push all blobs to file servers
2322    #[allow(dead_code)]
2323    pub fn push_to_file_servers(
2324        &self,
2325        blossom: &hashtree_blossom::BlossomClient,
2326    ) -> Result<(usize, usize)> {
2327        let hashes = self
2328            .store
2329            .list()
2330            .map_err(|e| Error::StorageError(format!("list hashes: {}", e)))?;
2331
2332        info!("Pushing {} blobs to file servers", hashes.len());
2333
2334        let mut uploaded = 0;
2335        let mut existed = 0;
2336
2337        self.runtime.block_on(async {
2338            for hash in &hashes {
2339                let hex_hash = hex::encode(hash);
2340                let data = match self.store.get_sync(hash) {
2341                    Ok(Some(d)) => d,
2342                    _ => continue,
2343                };
2344
2345                match blossom.upload_if_missing(&data).await {
2346                    Ok((_, true)) => {
2347                        debug!("Uploaded {}", &hex_hash[..12]);
2348                        uploaded += 1;
2349                    }
2350                    Ok((_, false)) => {
2351                        existed += 1;
2352                    }
2353                    Err(e) => {
2354                        debug!("Failed to upload {}: {}", &hex_hash[..12], e);
2355                    }
2356                }
2357            }
2358        });
2359
2360        info!(
2361            "Upload complete: {} new, {} already existed",
2362            uploaded, existed
2363        );
2364        Ok((uploaded, existed))
2365    }
2366
2367    /// Clear all state (for testing or re-initialization)
2368    #[allow(dead_code)]
2369    pub fn clear(&self) -> Result<()> {
2370        let mut objects = self
2371            .objects
2372            .write()
2373            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
2374        let mut refs = self
2375            .refs
2376            .write()
2377            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
2378        let mut root = self
2379            .root_cid
2380            .write()
2381            .map_err(|e| Error::StorageError(format!("lock: {}", e)))?;
2382
2383        objects.clear();
2384        refs.clear();
2385        *root = None;
2386        Ok(())
2387    }
2388}
2389
2390#[cfg(test)]
2391mod tests {
2392    use super::*;
2393    use hashtree_core::store::Store;
2394    use hashtree_core::{sha256, LinkType};
2395    use std::io::{Read, Write};
2396    use std::net::{TcpListener, TcpStream};
2397    use std::path::Path;
2398    use std::process::{Child, Command, Stdio};
2399    #[cfg(feature = "lmdb")]
2400    use std::sync::atomic::{AtomicUsize, Ordering};
2401    use std::time::{Duration, Instant};
2402    use tempfile::TempDir;
2403
2404    fn create_test_storage() -> (GitStorage, TempDir) {
2405        let temp_dir = TempDir::new().unwrap();
2406        let storage =
2407            GitStorage::open_with_backend_and_max_bytes(temp_dir.path(), StorageBackend::Fs, 0)
2408                .unwrap();
2409        (storage, temp_dir)
2410    }
2411
2412    #[test]
2413    fn git_storage_uses_transport_friendly_chunks() {
2414        let (storage, _temp_dir) = create_test_storage();
2415        assert_eq!(storage.tree.chunk_size(), GIT_TREE_CHUNK_SIZE);
2416    }
2417
2418    fn create_test_storage_with_limit(max_size_bytes: u64) -> (GitStorage, TempDir) {
2419        let temp_dir = TempDir::new().unwrap();
2420        let storage = GitStorage::open_with_backend_and_max_bytes(
2421            temp_dir.path(),
2422            StorageBackend::Fs,
2423            max_size_bytes,
2424        )
2425        .unwrap();
2426        (storage, temp_dir)
2427    }
2428
2429    fn local_total_bytes(storage: &GitStorage) -> u64 {
2430        match storage.store().as_ref() {
2431            LocalStore::Fs(store) => store.stats().unwrap().total_bytes,
2432            #[cfg(feature = "lmdb")]
2433            LocalStore::Lmdb(store) => store.stats().unwrap().total_bytes,
2434        }
2435    }
2436
2437    fn write_test_commit(storage: &GitStorage) -> ObjectId {
2438        let blob_oid = storage
2439            .write_raw_object(ObjectType::Blob, b"hello from hashtree\n")
2440            .unwrap();
2441
2442        let mut tree_content = Vec::new();
2443        tree_content.extend_from_slice(b"100644 README.md\0");
2444        tree_content.extend_from_slice(&hex::decode(blob_oid.to_hex()).unwrap());
2445        let tree_oid = storage
2446            .write_raw_object(ObjectType::Tree, &tree_content)
2447            .unwrap();
2448
2449        let commit_content = format!(
2450            "tree {}\nauthor Test User <test@example.com> 0 +0000\ncommitter Test User <test@example.com> 0 +0000\n\nInitial commit\n",
2451            tree_oid.to_hex()
2452        );
2453        storage
2454            .write_raw_object(ObjectType::Commit, commit_content.as_bytes())
2455            .unwrap()
2456    }
2457
2458    fn write_root_tree(storage: &GitStorage, blobs: &[(&str, ObjectId)]) -> ObjectId {
2459        let mut sorted = blobs.to_vec();
2460        sorted.sort_by(|a, b| a.0.cmp(b.0));
2461
2462        let mut tree_content = Vec::new();
2463        for (name, oid) in sorted {
2464            tree_content.extend_from_slice(format!("100644 {name}\0").as_bytes());
2465            tree_content.extend_from_slice(&hex::decode(oid.to_hex()).unwrap());
2466        }
2467
2468        storage
2469            .write_raw_object(ObjectType::Tree, &tree_content)
2470            .unwrap()
2471    }
2472
2473    fn write_commit_for_tree(storage: &GitStorage, tree_oid: ObjectId, message: &str) -> ObjectId {
2474        let commit_content = format!(
2475            "tree {}\nauthor Test User <test@example.com> 0 +0000\ncommitter Test User <test@example.com> 0 +0000\n\n{}\n",
2476            tree_oid.to_hex(),
2477            message
2478        );
2479        storage
2480            .write_raw_object(ObjectType::Commit, commit_content.as_bytes())
2481            .unwrap()
2482    }
2483
2484    fn export_tree_to_fs<S: Store>(
2485        runtime: &RuntimeExecutor,
2486        tree: &HashTree<S>,
2487        cid: &Cid,
2488        dst: &Path,
2489    ) {
2490        std::fs::create_dir_all(dst).unwrap();
2491        let entries = runtime.block_on(tree.list_directory(cid)).unwrap();
2492        for entry in entries {
2493            let entry_cid = Cid {
2494                hash: entry.hash,
2495                key: entry.key,
2496            };
2497            let path = dst.join(&entry.name);
2498            match entry.link_type {
2499                LinkType::Dir | LinkType::Fanout => {
2500                    export_tree_to_fs(runtime, tree, &entry_cid, &path)
2501                }
2502                LinkType::Blob | LinkType::File => {
2503                    let data = runtime
2504                        .block_on(tree.get(&entry_cid, None))
2505                        .unwrap()
2506                        .unwrap();
2507                    if let Some(parent) = path.parent() {
2508                        std::fs::create_dir_all(parent).unwrap();
2509                    }
2510                    std::fs::write(path, data).unwrap();
2511                }
2512            }
2513        }
2514    }
2515
2516    fn spawn_http_server(root: &Path, port: u16) -> Child {
2517        Command::new("python3")
2518            .args([
2519                "-m",
2520                "http.server",
2521                &port.to_string(),
2522                "--bind",
2523                "127.0.0.1",
2524            ])
2525            .current_dir(root)
2526            .stdout(Stdio::null())
2527            .stderr(Stdio::null())
2528            .spawn()
2529            .expect("spawn python http server")
2530    }
2531
2532    fn wait_for_http_server(server: &mut Child, port: u16, path: &str) {
2533        let deadline = Instant::now() + Duration::from_secs(5);
2534
2535        loop {
2536            if let Some(status) = server.try_wait().expect("check http server status") {
2537                panic!("python http server exited before becoming ready: {status}");
2538            }
2539
2540            if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", port)) {
2541                stream
2542                    .set_read_timeout(Some(Duration::from_millis(200)))
2543                    .expect("set read timeout");
2544                stream
2545                    .set_write_timeout(Some(Duration::from_millis(200)))
2546                    .expect("set write timeout");
2547                let request =
2548                    format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
2549                if stream.write_all(request.as_bytes()).is_ok() {
2550                    let mut response = String::new();
2551                    if stream.read_to_string(&mut response).is_ok()
2552                        && response.starts_with("HTTP/1.0 200")
2553                    {
2554                        return;
2555                    }
2556                }
2557            }
2558
2559            if Instant::now() >= deadline {
2560                panic!("python http server did not become ready on port {port}");
2561            }
2562            std::thread::sleep(Duration::from_millis(50));
2563        }
2564    }
2565
2566    #[test]
2567    fn test_import_ref() {
2568        let (storage, _temp) = create_test_storage();
2569
2570        // Import a ref
2571        storage
2572            .import_ref("refs/heads/main", "abc123def456")
2573            .unwrap();
2574
2575        // Check it exists
2576        assert!(storage.has_ref("refs/heads/main").unwrap());
2577
2578        // Check value via list_refs
2579        let refs = storage.list_refs().unwrap();
2580        assert_eq!(
2581            refs.get("refs/heads/main"),
2582            Some(&"abc123def456".to_string())
2583        );
2584    }
2585
2586    #[test]
2587    fn import_ref_rejects_invalid_name() {
2588        let (storage, _temp) = create_test_storage();
2589
2590        assert!(storage
2591            .import_ref(
2592                "refs/heads/../main",
2593                "0123456789abcdef0123456789abcdef01234567",
2594            )
2595            .is_err());
2596        assert!(!storage.has_ref("refs/heads/../main").unwrap());
2597    }
2598
2599    #[cfg(feature = "lmdb")]
2600    #[test]
2601    fn test_local_store_falls_back_to_fs_when_lmdb_open_returns_enosys() {
2602        let temp_dir = TempDir::new().unwrap();
2603        let fs_calls = AtomicUsize::new(0);
2604        let lmdb_calls = AtomicUsize::new(0);
2605
2606        let store = LocalStore::new_for_backend_with_openers(
2607            temp_dir.path(),
2608            StorageBackend::Lmdb,
2609            0,
2610            |path, max_bytes| {
2611                fs_calls.fetch_add(1, Ordering::SeqCst);
2612                LocalStore::open_fs_store(path, max_bytes)
2613            },
2614            |_path, _max_bytes| {
2615                lmdb_calls.fetch_add(1, Ordering::SeqCst);
2616                Err(StoreError::Io(std::io::Error::from_raw_os_error(
2617                    libc::ENOSYS,
2618                )))
2619            },
2620        )
2621        .unwrap();
2622
2623        assert!(matches!(store, LocalStore::Fs(_)));
2624        assert_eq!(lmdb_calls.load(Ordering::SeqCst), 1);
2625        assert_eq!(fs_calls.load(Ordering::SeqCst), 1);
2626    }
2627
2628    #[cfg(feature = "lmdb")]
2629    #[test]
2630    fn test_local_store_does_not_fallback_on_unrelated_lmdb_errors() {
2631        let temp_dir = TempDir::new().unwrap();
2632        let fs_calls = AtomicUsize::new(0);
2633
2634        let result = LocalStore::new_for_backend_with_openers(
2635            temp_dir.path(),
2636            StorageBackend::Lmdb,
2637            0,
2638            |path, max_bytes| {
2639                fs_calls.fetch_add(1, Ordering::SeqCst);
2640                LocalStore::open_fs_store(path, max_bytes)
2641            },
2642            |_path, _max_bytes| {
2643                Err(StoreError::Io(std::io::Error::from_raw_os_error(
2644                    libc::EACCES,
2645                )))
2646            },
2647        );
2648
2649        assert!(
2650            matches!(result, Err(StoreError::Io(io_error)) if io_error.raw_os_error() == Some(libc::EACCES))
2651        );
2652        assert_eq!(fs_calls.load(Ordering::SeqCst), 0);
2653    }
2654
2655    #[test]
2656    fn test_import_multiple_refs_preserves_all() {
2657        let (storage, _temp) = create_test_storage();
2658
2659        // Import multiple refs (simulating loading from remote)
2660        storage.import_ref("refs/heads/main", "sha_main").unwrap();
2661        storage.import_ref("refs/heads/dev", "sha_dev").unwrap();
2662        storage
2663            .import_ref("refs/heads/feature", "sha_feature")
2664            .unwrap();
2665
2666        // All should exist
2667        assert!(storage.has_ref("refs/heads/main").unwrap());
2668        assert!(storage.has_ref("refs/heads/dev").unwrap());
2669        assert!(storage.has_ref("refs/heads/feature").unwrap());
2670
2671        // Now write a new ref (simulating push)
2672        storage
2673            .write_ref(
2674                "refs/heads/new-branch",
2675                &Ref::Direct(
2676                    ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
2677                ),
2678            )
2679            .unwrap();
2680
2681        // Original refs should still exist
2682        let refs = storage.list_refs().unwrap();
2683        assert_eq!(refs.len(), 4);
2684        assert!(refs.contains_key("refs/heads/main"));
2685        assert!(refs.contains_key("refs/heads/dev"));
2686        assert!(refs.contains_key("refs/heads/feature"));
2687        assert!(refs.contains_key("refs/heads/new-branch"));
2688    }
2689
2690    #[test]
2691    fn test_import_compressed_object() {
2692        let (storage, _temp) = create_test_storage();
2693
2694        let obj = GitObject::new(ObjectType::Blob, b"imported bytes".to_vec());
2695        let oid = obj.id().to_hex();
2696        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
2697        encoder.write_all(&obj.to_loose_format()).unwrap();
2698        let compressed = encoder.finish().unwrap();
2699
2700        storage.import_compressed_object(&oid, compressed).unwrap();
2701
2702        // Check object count
2703        assert_eq!(storage.object_count().unwrap(), 1);
2704    }
2705
2706    #[test]
2707    fn import_compressed_object_rejects_oid_mismatch() {
2708        let (storage, _temp) = create_test_storage();
2709        let obj = GitObject::new(ObjectType::Blob, b"trusted bytes".to_vec());
2710        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
2711        encoder.write_all(&obj.to_loose_format()).unwrap();
2712        let compressed = encoder.finish().unwrap();
2713
2714        assert!(storage
2715            .import_compressed_object("0123456789abcdef0123456789abcdef01234567", compressed,)
2716            .is_err());
2717        assert_eq!(storage.object_count().unwrap(), 0);
2718    }
2719
2720    #[test]
2721    fn test_write_ref_overwrites_imported() {
2722        let (storage, _temp) = create_test_storage();
2723
2724        // Import a ref
2725        storage.import_ref("refs/heads/main", "old_sha").unwrap();
2726
2727        // Write same ref with new value
2728        storage
2729            .write_ref(
2730                "refs/heads/main",
2731                &Ref::Direct(
2732                    ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
2733                ),
2734            )
2735            .unwrap();
2736
2737        // Should have new value
2738        let refs = storage.list_refs().unwrap();
2739        assert_eq!(
2740            refs.get("refs/heads/main"),
2741            Some(&"0123456789abcdef0123456789abcdef01234567".to_string())
2742        );
2743    }
2744
2745    #[test]
2746    fn test_delete_ref_preserves_others() {
2747        let (storage, _temp) = create_test_storage();
2748
2749        // Import multiple refs
2750        storage.import_ref("refs/heads/main", "sha_main").unwrap();
2751        storage.import_ref("refs/heads/dev", "sha_dev").unwrap();
2752
2753        // Delete one
2754        storage.delete_ref("refs/heads/dev").unwrap();
2755
2756        // Other should still exist
2757        assert!(storage.has_ref("refs/heads/main").unwrap());
2758        assert!(!storage.has_ref("refs/heads/dev").unwrap());
2759    }
2760
2761    #[test]
2762    fn test_clear_removes_all() {
2763        let (storage, _temp) = create_test_storage();
2764
2765        // Import refs and objects
2766        storage.import_ref("refs/heads/main", "sha_main").unwrap();
2767        storage
2768            .write_raw_object(ObjectType::Blob, b"clear me")
2769            .unwrap();
2770
2771        // Clear
2772        storage.clear().unwrap();
2773
2774        // All gone
2775        assert!(!storage.has_ref("refs/heads/main").unwrap());
2776        assert_eq!(storage.object_count().unwrap(), 0);
2777    }
2778
2779    #[test]
2780    fn test_evict_if_needed_respects_configured_limit() {
2781        let (storage, _temp) = create_test_storage_with_limit(1_024);
2782
2783        storage
2784            .write_raw_object(ObjectType::Blob, &vec![b'a'; 900])
2785            .unwrap();
2786        storage
2787            .write_raw_object(ObjectType::Blob, &vec![b'b'; 900])
2788            .unwrap();
2789        storage
2790            .write_ref(
2791                "refs/heads/main",
2792                &Ref::Direct(
2793                    ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
2794                ),
2795            )
2796            .unwrap();
2797
2798        storage.build_tree().unwrap();
2799
2800        let before = local_total_bytes(&storage);
2801        assert!(before > 1_024);
2802
2803        let freed = storage.evict_if_needed().unwrap();
2804        assert!(freed > 0);
2805
2806        let after = local_total_bytes(&storage);
2807        assert!(after <= 1_024);
2808    }
2809
2810    #[test]
2811    fn test_build_tree_evicts_stale_blobs_before_writing_new_tree() {
2812        let max_size_bytes = 16 * 1024;
2813        let (storage, _temp) = create_test_storage_with_limit(max_size_bytes);
2814
2815        let stale_blobs = vec![
2816            vec![b'x'; 7 * 1024],
2817            vec![b'y'; 7 * 1024],
2818            vec![b'z'; 7 * 1024],
2819        ];
2820        let stale_hashes: Vec<Hash> = stale_blobs.iter().map(|blob| sha256(blob)).collect();
2821
2822        for (hash, blob) in stale_hashes.iter().zip(stale_blobs) {
2823            storage
2824                .runtime
2825                .block_on(storage.store().put(*hash, blob))
2826                .unwrap();
2827        }
2828
2829        let before = local_total_bytes(&storage);
2830        assert!(before > max_size_bytes);
2831
2832        let commit_oid = write_test_commit(&storage);
2833        storage
2834            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
2835            .unwrap();
2836        storage
2837            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
2838            .unwrap();
2839
2840        storage.build_tree().unwrap();
2841
2842        let evicted_stale = stale_hashes
2843            .iter()
2844            .filter(|hash| !storage.runtime.block_on(storage.store().has(hash)).unwrap())
2845            .count();
2846
2847        assert!(
2848            evicted_stale > 0,
2849            "expected build_tree preflight eviction to remove stale blobs before writing"
2850        );
2851    }
2852
2853    #[test]
2854    fn test_build_tree_progress_tracks_repo_tree_work() {
2855        let (storage, _temp) = create_test_storage();
2856        let commit_oid = write_test_commit(&storage);
2857        storage
2858            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
2859            .unwrap();
2860        storage
2861            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
2862            .unwrap();
2863
2864        let progress = RepoTreeBuildProgress::new();
2865        storage.build_tree_with_progress(&progress).unwrap();
2866
2867        let snapshot = progress.snapshot();
2868        assert_eq!(snapshot.phase, RepoTreeBuildPhase::Done);
2869        assert_eq!(snapshot.object_blobs, 3);
2870        assert_eq!(snapshot.files, 1);
2871        assert_eq!(snapshot.dirs, 0);
2872        assert_eq!(snapshot.reused, 0);
2873        assert_eq!(
2874            snapshot.format_for_label("Building repo tree"),
2875            "  Building repo tree: done (3 object blobs, 1 files, 0 dirs, 0 reused)"
2876        );
2877    }
2878
2879    #[test]
2880    fn test_build_tree_adds_dumb_http_metadata() {
2881        let (storage, _temp) = create_test_storage();
2882        let commit_oid = write_test_commit(&storage);
2883        let tag_content = format!(
2884            "object {}\ntype commit\ntag v1.0.0\ntagger Test User <test@example.com> 0 +0000\n\nrelease\n",
2885            commit_oid.to_hex()
2886        );
2887        let tag_oid = storage
2888            .write_raw_object(ObjectType::Tag, tag_content.as_bytes())
2889            .unwrap();
2890
2891        storage
2892            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
2893            .unwrap();
2894        storage
2895            .write_ref("refs/tags/v1.0.0", &Ref::Direct(tag_oid))
2896            .unwrap();
2897        storage
2898            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
2899            .unwrap();
2900
2901        let root_cid = storage.build_tree().unwrap();
2902
2903        let info_refs_cid = storage
2904            .runtime
2905            .block_on(storage.tree.resolve_path(&root_cid, ".git/info/refs"))
2906            .unwrap()
2907            .expect("info/refs exists");
2908        let info_refs = storage
2909            .runtime
2910            .block_on(storage.tree.get(&info_refs_cid, None))
2911            .unwrap()
2912            .unwrap();
2913        let info_refs = String::from_utf8(info_refs).unwrap();
2914
2915        assert_eq!(
2916            info_refs,
2917            format!(
2918                "{commit}\trefs/heads/main\n{tag}\trefs/tags/v1.0.0\n{commit}\trefs/tags/v1.0.0^{{}}\n",
2919                commit = commit_oid.to_hex(),
2920                tag = tag_oid.to_hex()
2921            )
2922        );
2923
2924        let packs_cid = storage
2925            .runtime
2926            .block_on(
2927                storage
2928                    .tree
2929                    .resolve_path(&root_cid, ".git/objects/info/packs"),
2930            )
2931            .unwrap()
2932            .expect("objects/info/packs exists");
2933        let packs = storage
2934            .runtime
2935            .block_on(storage.tree.get(&packs_cid, None))
2936            .unwrap()
2937            .unwrap();
2938        assert!(packs.is_empty(), "objects/info/packs should be empty");
2939    }
2940
2941    #[test]
2942    fn test_build_tree_writes_git_objects_info_packs_for_pack_files() {
2943        let (storage, _temp) = create_test_storage();
2944        let commit_oid = write_test_commit(&storage);
2945        storage
2946            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
2947            .unwrap();
2948        storage
2949            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
2950            .unwrap();
2951
2952        let pack_hash = "0123456789abcdef0123456789abcdef01234567";
2953        let pack_name = format!("pack-{pack_hash}.pack");
2954        let idx_name = format!("pack-{pack_hash}.idx");
2955        let mut pack_files = BTreeMap::new();
2956        pack_files.insert(pack_name.clone(), b"pack bytes".to_vec());
2957        pack_files.insert(idx_name.clone(), b"idx bytes".to_vec());
2958        storage.set_pack_files(pack_files).unwrap();
2959
2960        let root_cid = storage.build_tree().unwrap();
2961
2962        let packs_cid = storage
2963            .runtime
2964            .block_on(
2965                storage
2966                    .tree
2967                    .resolve_path(&root_cid, ".git/objects/info/packs"),
2968            )
2969            .unwrap()
2970            .expect("objects/info/packs exists");
2971        let packs = storage
2972            .runtime
2973            .block_on(storage.tree.get(&packs_cid, None))
2974            .unwrap()
2975            .unwrap();
2976        assert_eq!(
2977            String::from_utf8(packs).unwrap(),
2978            format!("P {pack_name}\n")
2979        );
2980
2981        for path in [
2982            format!(".git/objects/pack/{pack_name}"),
2983            format!(".git/objects/pack/{idx_name}"),
2984        ] {
2985            let cid = storage
2986                .runtime
2987                .block_on(storage.tree.resolve_path(&root_cid, &path))
2988                .unwrap()
2989                .unwrap_or_else(|| panic!("{path} should exist"));
2990            let content = storage
2991                .runtime
2992                .block_on(storage.tree.get(&cid, None))
2993                .unwrap()
2994                .unwrap();
2995            assert!(!content.is_empty(), "{path} should have content");
2996        }
2997    }
2998
2999    #[test]
3000    fn test_pack_checkpoint_can_replace_loose_git_objects() {
3001        let (storage, _temp) = create_test_storage();
3002        let commit_oid = write_test_commit(&storage);
3003        storage
3004            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
3005            .unwrap();
3006        storage
3007            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
3008            .unwrap();
3009
3010        let pack_hash = "0123456789abcdef0123456789abcdef01234567";
3011        let pack_name = format!("pack-{pack_hash}.pack");
3012        let idx_name = format!("pack-{pack_hash}.idx");
3013        let mut pack_files = BTreeMap::new();
3014        pack_files.insert(pack_name.clone(), b"pack bytes".to_vec());
3015        pack_files.insert(idx_name, b"idx bytes".to_vec());
3016        storage
3017            .set_pack_checkpoint_files(pack_files, HashSet::from([commit_oid.to_hex()]))
3018            .unwrap();
3019
3020        let root_cid = storage.build_tree().unwrap();
3021        let loose_path = format!(
3022            ".git/objects/{}/{}",
3023            &commit_oid.to_hex()[..2],
3024            &commit_oid.to_hex()[2..]
3025        );
3026        let loose_cid = storage
3027            .runtime
3028            .block_on(storage.tree.resolve_path(&root_cid, &loose_path))
3029            .unwrap();
3030        assert!(
3031            loose_cid.is_none(),
3032            "pack checkpoint should omit duplicated loose objects"
3033        );
3034
3035        storage
3036            .validate_root_contains_direct_refs(&root_cid)
3037            .expect("pack checkpoint should satisfy ref validation");
3038
3039        let packs_cid = storage
3040            .runtime
3041            .block_on(
3042                storage
3043                    .tree
3044                    .resolve_path(&root_cid, ".git/objects/info/packs"),
3045            )
3046            .unwrap()
3047            .expect("objects/info/packs exists");
3048        let packs = storage
3049            .runtime
3050            .block_on(storage.tree.get(&packs_cid, None))
3051            .unwrap()
3052            .unwrap();
3053        assert_eq!(
3054            String::from_utf8(packs).unwrap(),
3055            format!("P {pack_name}\n")
3056        );
3057    }
3058
3059    #[test]
3060    fn test_inherited_pack_checkpoint_can_replace_base_loose_git_objects() {
3061        let (storage, _temp) = create_test_storage();
3062        let commit_oid = write_test_commit(&storage);
3063        storage
3064            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
3065            .unwrap();
3066        storage
3067            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
3068            .unwrap();
3069
3070        let pack_hash = "0123456789abcdef0123456789abcdef01234567";
3071        let pack_name = format!("pack-{pack_hash}.pack");
3072        let idx_name = format!("pack-{pack_hash}.idx");
3073        let mut pack_files = BTreeMap::new();
3074        pack_files.insert(pack_name.clone(), b"pack bytes".to_vec());
3075        pack_files.insert(idx_name, b"idx bytes".to_vec());
3076        storage
3077            .set_pack_checkpoint_files(pack_files, HashSet::from([commit_oid.to_hex()]))
3078            .unwrap();
3079        let base_root_cid = storage.build_tree().unwrap();
3080
3081        storage
3082            .set_pack_checkpoint_files(BTreeMap::new(), HashSet::from([commit_oid.to_hex()]))
3083            .unwrap();
3084
3085        let root_cid = storage
3086            .build_tree_with_base_objects(Some(&storage.tree), Some(&base_root_cid), None)
3087            .unwrap();
3088        let loose_path = format!(
3089            ".git/objects/{}/{}",
3090            &commit_oid.to_hex()[..2],
3091            &commit_oid.to_hex()[2..]
3092        );
3093        let loose_cid = storage
3094            .runtime
3095            .block_on(storage.tree.resolve_path(&root_cid, &loose_path))
3096            .unwrap();
3097        assert!(
3098            loose_cid.is_none(),
3099            "inherited pack-covered objects should not be rewritten loose"
3100        );
3101
3102        let packs_cid = storage
3103            .runtime
3104            .block_on(
3105                storage
3106                    .tree
3107                    .resolve_path(&root_cid, ".git/objects/info/packs"),
3108            )
3109            .unwrap()
3110            .expect("inherited objects/info/packs exists");
3111        let packs = storage
3112            .runtime
3113            .block_on(storage.tree.get(&packs_cid, None))
3114            .unwrap()
3115            .unwrap();
3116        assert_eq!(
3117            String::from_utf8(packs).unwrap(),
3118            format!("P {pack_name}\n")
3119        );
3120    }
3121
3122    #[test]
3123    fn test_pack_only_base_reuses_visible_working_files_without_loose_old_tree() {
3124        let (storage, _temp) = create_test_storage();
3125        let readme_oid = storage
3126            .write_raw_object(ObjectType::Blob, b"base readme\n")
3127            .unwrap();
3128        let base_tree_oid = write_root_tree(&storage, &[("README.md", readme_oid)]);
3129        let base_commit_oid = write_commit_for_tree(&storage, base_tree_oid, "base");
3130        storage
3131            .write_ref("refs/heads/main", &Ref::Direct(base_commit_oid))
3132            .unwrap();
3133        storage
3134            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
3135            .unwrap();
3136
3137        let pack_hash = "fedcba9876543210fedcba9876543210fedcba98";
3138        let pack_name = format!("pack-{pack_hash}.pack");
3139        let idx_name = format!("pack-{pack_hash}.idx");
3140        let mut pack_files = BTreeMap::new();
3141        pack_files.insert(pack_name, b"pack bytes".to_vec());
3142        pack_files.insert(idx_name, b"idx bytes".to_vec());
3143        storage
3144            .set_pack_checkpoint_files(
3145                pack_files,
3146                HashSet::from([
3147                    base_commit_oid.to_hex(),
3148                    base_tree_oid.to_hex(),
3149                    readme_oid.to_hex(),
3150                ]),
3151            )
3152            .unwrap();
3153        let base_root_cid = storage.build_tree().unwrap();
3154
3155        let new_blob_oid = storage
3156            .write_raw_object(ObjectType::Blob, b"new file\n")
3157            .unwrap();
3158        let current_tree_oid = write_root_tree(
3159            &storage,
3160            &[("README.md", readme_oid), ("new.txt", new_blob_oid)],
3161        );
3162        let current_commit_oid = write_commit_for_tree(&storage, current_tree_oid, "delta");
3163        storage
3164            .write_ref("refs/heads/main", &Ref::Direct(current_commit_oid))
3165            .unwrap();
3166        storage
3167            .set_pack_checkpoint_files(BTreeMap::new(), HashSet::new())
3168            .unwrap();
3169
3170        {
3171            let mut objects = storage.objects.write().unwrap();
3172            objects.remove(&base_commit_oid.to_hex());
3173            objects.remove(&base_tree_oid.to_hex());
3174            objects.remove(&readme_oid.to_hex());
3175        }
3176
3177        let root_cid = storage
3178            .build_tree_with_base_objects(
3179                Some(&storage.tree),
3180                Some(&base_root_cid),
3181                Some(&base_tree_oid.to_hex()),
3182            )
3183            .unwrap();
3184
3185        for (path, expected) in [("README.md", "base readme\n"), ("new.txt", "new file\n")] {
3186            let cid = storage
3187                .runtime
3188                .block_on(storage.tree.resolve_path(&root_cid, path))
3189                .unwrap()
3190                .unwrap_or_else(|| panic!("{path} should exist"));
3191            let content = storage
3192                .runtime
3193                .block_on(storage.tree.get(&cid, None))
3194                .unwrap()
3195                .unwrap();
3196            assert_eq!(String::from_utf8(content).unwrap(), expected, "{path}");
3197        }
3198    }
3199
3200    #[test]
3201    fn test_build_tree_materializes_loose_refs_at_git_paths() {
3202        let (storage, _temp) = create_test_storage();
3203        let commit_oid = write_test_commit(&storage);
3204
3205        storage
3206            .write_ref("refs/heads/master", &Ref::Direct(commit_oid))
3207            .unwrap();
3208        storage
3209            .write_ref("refs/heads/codex/meshrouter-prod", &Ref::Direct(commit_oid))
3210            .unwrap();
3211        storage
3212            .write_ref("refs/tags/v1.0.0", &Ref::Direct(commit_oid))
3213            .unwrap();
3214        storage
3215            .write_ref("HEAD", &Ref::Symbolic("refs/heads/master".to_string()))
3216            .unwrap();
3217
3218        let root_cid = storage.build_tree().unwrap();
3219
3220        for path in [
3221            ".git/refs/heads/master",
3222            ".git/refs/heads/codex/meshrouter-prod",
3223            ".git/refs/tags/v1.0.0",
3224        ] {
3225            let ref_cid = storage
3226                .runtime
3227                .block_on(storage.tree.resolve_path(&root_cid, path))
3228                .unwrap()
3229                .unwrap_or_else(|| panic!("{path} should exist"));
3230            let ref_value = storage
3231                .runtime
3232                .block_on(storage.tree.get(&ref_cid, None))
3233                .unwrap()
3234                .unwrap();
3235            assert_eq!(
3236                String::from_utf8(ref_value).unwrap(),
3237                commit_oid.to_hex(),
3238                "{path} should contain the ref target",
3239            );
3240        }
3241    }
3242
3243    #[test]
3244    fn test_materialized_tree_supports_static_http_clone_from_git_dir() {
3245        let (storage, _temp) = create_test_storage();
3246        let commit_oid = write_test_commit(&storage);
3247        storage
3248            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
3249            .unwrap();
3250        storage
3251            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
3252            .unwrap();
3253
3254        let root_cid = storage.build_tree().unwrap();
3255        let export_dir = TempDir::new().unwrap();
3256        let repo_dir = export_dir.path().join("repo");
3257        export_tree_to_fs(&storage.runtime, &storage.tree, &root_cid, &repo_dir);
3258
3259        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3260        let port = listener.local_addr().unwrap().port();
3261        drop(listener);
3262
3263        let mut server = spawn_http_server(export_dir.path(), port);
3264        wait_for_http_server(&mut server, port, "/repo/.git/HEAD");
3265
3266        let clone_dir = TempDir::new().unwrap();
3267        let clone_path = clone_dir.path().join("clone");
3268        let output = Command::new("git")
3269            .current_dir(clone_dir.path())
3270            .args([
3271                "clone",
3272                &format!("http://127.0.0.1:{port}/repo/.git", port = port),
3273                clone_path.to_str().unwrap(),
3274            ])
3275            .output()
3276            .unwrap();
3277
3278        let _ = server.kill();
3279        let _ = server.wait();
3280
3281        assert!(
3282            output.status.success(),
3283            "git clone failed: {}",
3284            String::from_utf8_lossy(&output.stderr)
3285        );
3286        assert_eq!(
3287            std::fs::read_to_string(clone_path.join("README.md")).unwrap(),
3288            "hello from hashtree\n"
3289        );
3290    }
3291
3292    #[test]
3293    fn test_validate_root_contains_direct_refs_rejects_missing_tip_object() {
3294        let (storage, _temp) = create_test_storage();
3295        let commit_oid = write_test_commit(&storage);
3296        storage
3297            .write_ref("refs/heads/main", &Ref::Direct(commit_oid))
3298            .unwrap();
3299        storage
3300            .write_ref("HEAD", &Ref::Symbolic("refs/heads/main".to_string()))
3301            .unwrap();
3302
3303        let empty_objects_dir = storage
3304            .runtime
3305            .block_on(storage.tree.put_directory(vec![]))
3306            .unwrap();
3307        let refs_dir = storage
3308            .runtime
3309            .block_on(storage.tree.put_directory(vec![]))
3310            .unwrap();
3311        let info_dir = storage
3312            .runtime
3313            .block_on(storage.tree.put_directory(vec![]))
3314            .unwrap();
3315        let git_dir = storage
3316            .runtime
3317            .block_on(storage.tree.put_directory(vec![
3318                DirEntry::from_cid("HEAD", &info_dir).with_size(0),
3319                DirEntry::from_cid("info", &info_dir).with_link_type(LinkType::Dir),
3320                DirEntry::from_cid("objects", &empty_objects_dir).with_link_type(LinkType::Dir),
3321                DirEntry::from_cid("refs", &refs_dir).with_link_type(LinkType::Dir),
3322            ]))
3323            .unwrap();
3324        let root_cid = storage
3325            .runtime
3326            .block_on(storage.tree.put_directory(vec![
3327                DirEntry::from_cid(".git", &git_dir).with_link_type(LinkType::Dir),
3328            ]))
3329            .unwrap();
3330
3331        let err = storage
3332            .validate_root_contains_direct_refs(&root_cid)
3333            .expect_err("missing ref-tip object should fail validation");
3334        assert!(
3335            err.to_string().contains(&commit_oid.to_hex()),
3336            "validation error should mention missing commit oid: {}",
3337            err
3338        );
3339    }
3340}