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