Skip to main content

git_remote_htree/git/
storage.rs

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