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