Skip to main content

git_remote_htree/git/
storage.rs

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