Skip to main content

git_metadata/
lib.rs

1use git_filter_tree::FilterTree as _;
2use git2::{Error, ErrorCode, Oid, Repository};
3
4/// Options that control mutating metadata operations.
5#[derive(Debug, Clone)]
6pub struct MetadataOptions {
7    /// Fanout depth (number of 2-hex-char directory segments).
8    /// 1 means `ab/cdef01...` (like git-notes), 2 means `ab/cd/ef01...`.
9    pub shard_level: u8,
10    /// Overwrite an existing entry without error.
11    pub force: bool,
12}
13
14impl Default for MetadataOptions {
15    fn default() -> Self {
16        Self {
17            shard_level: 1,
18            force: false,
19        }
20    }
21}
22
23/// A single entry in a metadata tree: a path and optional blob content.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct MetadataEntry {
26    /// Path relative to the metadata tree root (e.g. `labels/bug`).
27    pub path: String,
28    /// Blob content, if the entry is a blob. `None` for tree-only entries.
29    pub content: Option<Vec<u8>>,
30    /// The OID of the entry (blob or tree).
31    pub oid: Oid,
32    /// Whether this entry is a tree (directory) rather than a blob.
33    pub is_tree: bool,
34}
35
36/// A metadata index maps [`Oid`] → [`git2::Tree`], stored as a fanout tree
37/// under a Git reference (e.g. `refs/metadata/commits`).
38///
39/// This is analogous to Git notes, which map Oid → Blob, but metadata
40/// entries are trees containing arbitrary paths.
41pub trait MetadataIndex {
42    /// List all targets that have metadata entries.
43    /// Returns `(target_oid, tree_oid)` pairs.
44    fn metadata_list(&self, ref_name: &str) -> Result<Vec<(Oid, Oid)>, Error>;
45
46    /// Get the raw metadata tree OID for a target.
47    /// Returns `None` if no entry exists.
48    fn metadata_get(&self, ref_name: &str, target: &Oid) -> Result<Option<Oid>, Error>;
49
50    /// Set the raw metadata tree OID for a target.
51    ///
52    /// Builds the fanout index tree and returns the new root tree OID.
53    /// Does **not** commit; call [`Self::metadata_commit`] to persist.
54    fn metadata(
55        &self,
56        ref_name: &str,
57        target: &Oid,
58        tree: &Oid,
59        opts: &MetadataOptions,
60    ) -> Result<Oid, Error>;
61
62    /// Commit a new root tree OID to `ref_name` with the given message.
63    ///
64    /// Returns the new commit OID.
65    fn metadata_commit(&self, ref_name: &str, root: Oid, message: &str) -> Result<Oid, Error>;
66
67    /// Set the raw metadata tree OID for a target.
68    /// Returns the new root tree OID committed under `ref_name`.
69    ///
70    /// # Deprecated
71    ///
72    /// Use [`Self::metadata`] followed by [`Self::metadata_commit`] instead.
73    #[deprecated(since = "0.1.0", note = "use `metadata` + `metadata_commit` instead")]
74    fn metadata_set(
75        &self,
76        ref_name: &str,
77        target: &Oid,
78        tree: &Oid,
79        opts: &MetadataOptions,
80    ) -> Result<Oid, Error> {
81        #[allow(deprecated)]
82        let new_root = self.metadata(ref_name, target, tree, opts)?;
83        let msg = format!("metadata: set {} -> {}", target, tree);
84        self.metadata_commit(ref_name, new_root, &msg)?;
85        Ok(new_root)
86    }
87
88    /// Show all entries in the metadata tree for a target.
89    /// Returns leaf blob entries with their paths and content.
90    fn metadata_show(&self, ref_name: &str, target: &Oid) -> Result<Vec<MetadataEntry>, Error>;
91
92    /// Add a path entry (with optional blob content) to a target's metadata tree.
93    ///
94    /// If `content` is `Some`, a blob is created at `path`.
95    /// If `content` is `None`, an empty blob is created as a marker.
96    /// If the target has no metadata yet, a new tree is created.
97    /// Errors if the path already exists unless `opts.force` is true.
98    fn metadata_add(
99        &self,
100        ref_name: &str,
101        target: &Oid,
102        path: &str,
103        content: Option<&[u8]>,
104        opts: &MetadataOptions,
105    ) -> Result<Oid, Error>;
106
107    /// Remove path entries matching `patterns` from a target's metadata tree.
108    ///
109    /// When `keep` is false, entries matching any pattern are removed.
110    /// When `keep` is true, only entries matching a pattern are kept.
111    /// Returns `Ok(true)` if anything was removed, `Ok(false)` otherwise.
112    fn metadata_remove_paths(
113        &self,
114        ref_name: &str,
115        target: &Oid,
116        patterns: &[&str],
117        keep: bool,
118    ) -> Result<bool, Error>;
119
120    /// Remove the entire metadata entry for a target.
121    /// Returns `Ok(true)` if removed, `Ok(false)` if no entry existed.
122    fn metadata_remove(&self, ref_name: &str, target: &Oid) -> Result<bool, Error>;
123
124    /// Copy the metadata tree from one target to another.
125    /// Errors if `to` already has metadata unless `force` is true.
126    /// Errors if `from` has no metadata.
127    fn metadata_copy(
128        &self,
129        ref_name: &str,
130        from: &Oid,
131        to: &Oid,
132        opts: &MetadataOptions,
133    ) -> Result<Oid, Error>;
134
135    /// Remove metadata entries for targets that no longer exist in the object database.
136    /// Returns the list of pruned target OIDs.
137    fn metadata_prune(&self, ref_name: &str, dry_run: bool) -> Result<Vec<Oid>, Error>;
138
139    /// Return the resolved ref name (identity for now, but allows future indirection).
140    fn metadata_get_ref(&self, ref_name: &str) -> String;
141
142    /// Create a bidirectional link between two keys.
143    ///
144    /// Writes `<a>/<forward>/<b>` and `<b>/<reverse>/<a>` in one commit.
145    /// `meta` is optional blob content stored at each link entry.
146    fn link(
147        &self,
148        ref_name: &str,
149        a: &str,
150        b: &str,
151        forward: &str,
152        reverse: &str,
153        meta: Option<&[u8]>,
154    ) -> Result<Oid, Error>;
155
156    /// Remove a bidirectional link between two keys.
157    ///
158    /// Removes `<a>/<forward>/<b>` and `<b>/<reverse>/<a>` in one commit.
159    fn unlink(
160        &self,
161        ref_name: &str,
162        a: &str,
163        b: &str,
164        forward: &str,
165        reverse: &str,
166    ) -> Result<Oid, Error>;
167
168    /// List all links for a key, optionally filtered by relation name.
169    ///
170    /// Returns `(relation, target)` pairs.
171    fn linked(
172        &self,
173        ref_name: &str,
174        key: &str,
175        relation: Option<&str>,
176    ) -> Result<Vec<(String, String)>, Error>;
177
178    /// Check whether a specific link exists.
179    fn is_linked(&self, ref_name: &str, a: &str, b: &str, forward: &str) -> Result<bool, Error>;
180}
181
182// ---------------------------------------------------------------------------
183// Helpers
184// ---------------------------------------------------------------------------
185
186/// Maximum allowed shard level. A SHA-1 hex string is 40 chars; each level
187/// consumes 2 chars, so the leaf must keep at least 2 chars.
188const MAX_SHARD_LEVEL: u8 = 19;
189
190/// Split a hex OID string into `(prefix_segments, leaf)` according to `shard_level`.
191///
192/// Returns an error if `shard_level` exceeds [`MAX_SHARD_LEVEL`].
193fn shard_oid(oid: &Oid, shard_level: u8) -> Result<(Vec<String>, String), Error> {
194    if shard_level > MAX_SHARD_LEVEL {
195        return Err(Error::from_str(&format!(
196            "shard_level {} exceeds maximum of {}",
197            shard_level, MAX_SHARD_LEVEL
198        )));
199    }
200    let hex = oid.to_string();
201    let mut segments = Vec::with_capacity(shard_level as usize);
202    let mut pos = 0;
203    for _ in 0..shard_level {
204        segments.push(hex[pos..pos + 2].to_string());
205        pos += 2;
206    }
207    let leaf = hex[pos..].to_string();
208    Ok((segments, leaf))
209}
210
211/// Resolve an existing root tree from a reference, if it exists.
212fn resolve_root_tree<'r>(
213    repo: &'r Repository,
214    ref_name: &str,
215) -> Result<Option<git2::Tree<'r>>, Error> {
216    match repo.find_reference(ref_name) {
217        Ok(reference) => {
218            let commit = reference.peel_to_commit()?;
219            let tree = commit.tree()?;
220            Ok(Some(tree))
221        }
222        Err(e) if e.code() == ErrorCode::NotFound => Ok(None),
223        Err(e) => Err(e),
224    }
225}
226
227/// Walk into a tree following `segments`, returning the final sub-tree.
228fn walk_tree<'a>(
229    repo: &'a Repository,
230    root: &git2::Tree<'a>,
231    segments: &[String],
232) -> Result<Option<git2::Tree<'a>>, Error> {
233    let mut current = root.clone();
234    for seg in segments {
235        let id = match current.get_name(seg) {
236            Some(entry) => entry.id(),
237            None => return Ok(None),
238        };
239        current = repo.find_tree(id)?;
240    }
241    Ok(Some(current))
242}
243
244/// Returns `true` if `name` is a 2-char hex string (fanout directory name).
245fn is_fanout_segment(name: &str) -> bool {
246    name.len() == 2 && name.bytes().all(|b| b.is_ascii_hexdigit())
247}
248
249/// Recursively collect all `(target_oid, tree_oid)` entries from a fanout tree.
250fn collect_entries(
251    repo: &Repository,
252    tree: &git2::Tree<'_>,
253    prefix: &str,
254) -> Result<Vec<(Oid, Oid)>, Error> {
255    let mut results = Vec::new();
256    for entry in tree.iter() {
257        let name = entry.name().unwrap_or("");
258        if entry.kind() != Some(git2::ObjectType::Tree) {
259            continue;
260        }
261        let full = format!("{prefix}{name}");
262        if is_fanout_segment(name) {
263            let subtree = repo.find_tree(entry.id())?;
264            results.extend(collect_entries(repo, &subtree, &full)?);
265        } else if let Ok(oid) = Oid::from_str(&full)
266            && oid.to_string() == full
267        {
268            results.push((oid, entry.id()));
269        }
270    }
271    Ok(results)
272}
273
274/// Detect the fanout path for `target` in `root` by probing all possible depths.
275fn detect_fanout(
276    repo: &Repository,
277    root: &git2::Tree<'_>,
278    target: &Oid,
279) -> Result<Option<(Vec<String>, String, Oid)>, Error> {
280    let hex = target.to_string();
281    let max_depth = hex.len() / 2;
282    for depth in 0..max_depth {
283        let prefix_len = depth * 2;
284        let segments: Vec<String> = (0..depth)
285            .map(|i| hex[i * 2..i * 2 + 2].to_string())
286            .collect();
287        let leaf = &hex[prefix_len..];
288
289        if let Some(subtree) = walk_tree(repo, root, &segments)?
290            && let Some(entry) = subtree.get_name(leaf)
291            && entry.kind() == Some(git2::ObjectType::Tree)
292        {
293            return Ok(Some((segments, leaf.to_string(), entry.id())));
294        }
295    }
296    Ok(None)
297}
298
299/// Build the nested fanout tree for an upsert, returning the new root tree OID.
300fn build_fanout(
301    repo: &Repository,
302    existing_root: Option<&git2::Tree<'_>>,
303    segments: &[String],
304    leaf: &str,
305    value_tree_oid: &Oid,
306) -> Result<Oid, Error> {
307    let mut existing_subtrees: Vec<Option<git2::Tree<'_>>> = Vec::new();
308    if let Some(root) = existing_root {
309        let mut current = Some(root.clone());
310        existing_subtrees.push(current.clone());
311        for seg in segments {
312            current = match &current {
313                Some(t) => match t.get_name(seg) {
314                    Some(e) => Some(repo.find_tree(e.id())?),
315                    None => None,
316                },
317                None => None,
318            };
319            existing_subtrees.push(current.clone());
320        }
321    } else {
322        for _ in 0..=segments.len() {
323            existing_subtrees.push(None);
324        }
325    }
326
327    let deepest_existing = existing_subtrees.last().and_then(|o| o.as_ref());
328    let mut builder = repo.treebuilder(deepest_existing)?;
329    builder.insert(leaf, *value_tree_oid, 0o040000)?;
330    let mut child_oid = builder.write()?;
331
332    for (i, seg) in segments.iter().enumerate().rev() {
333        let parent_existing = existing_subtrees[i].as_ref();
334        let mut builder = repo.treebuilder(parent_existing)?;
335        builder.insert(seg, child_oid, 0o040000)?;
336        child_oid = builder.write()?;
337    }
338
339    Ok(child_oid)
340}
341
342/// Result of a fanout removal operation.
343enum RemoveResult {
344    NotFound,
345    Empty,
346    Removed(Oid),
347}
348
349/// Build the nested fanout tree for a removal, returning the new root tree OID.
350fn build_fanout_remove(
351    repo: &Repository,
352    root: &git2::Tree<'_>,
353    segments: &[String],
354    leaf: &str,
355) -> Result<RemoveResult, Error> {
356    let mut chain_oids: Vec<Oid> = vec![root.id()];
357    {
358        let mut current = root.clone();
359        for seg in segments {
360            let id = match current.get_name(seg) {
361                Some(e) => e.id(),
362                None => return Ok(RemoveResult::NotFound),
363            };
364            chain_oids.push(id);
365            current = repo.find_tree(id)?;
366        }
367    }
368
369    let deepest = repo.find_tree(*chain_oids.last().unwrap())?;
370    let mut builder = repo.treebuilder(Some(&deepest))?;
371    if builder.get(leaf)?.is_none() {
372        return Ok(RemoveResult::NotFound);
373    }
374    builder.remove(leaf)?;
375
376    let mut child_oid = if builder.is_empty() {
377        None
378    } else {
379        Some(builder.write()?)
380    };
381
382    for (i, seg) in segments.iter().enumerate().rev() {
383        let parent = repo.find_tree(chain_oids[i])?;
384        let mut builder = repo.treebuilder(Some(&parent))?;
385        match child_oid {
386            Some(oid) => {
387                builder.insert(seg, oid, 0o040000)?;
388            }
389            None => {
390                builder.remove(seg)?;
391            }
392        }
393        child_oid = if builder.is_empty() {
394            None
395        } else {
396            Some(builder.write()?)
397        };
398    }
399
400    match child_oid {
401        Some(oid) => Ok(RemoveResult::Removed(oid)),
402        None => Ok(RemoveResult::Empty),
403    }
404}
405
406/// Commit a new root tree under `ref_name`, parenting on the existing commit.
407fn commit_index(
408    repo: &Repository,
409    ref_name: &str,
410    tree_oid: Oid,
411    message: &str,
412) -> Result<Oid, Error> {
413    let tree = repo.find_tree(tree_oid)?;
414    let sig = repo.signature()?;
415
416    let parent = match repo.find_reference(ref_name) {
417        Ok(r) => Some(r.peel_to_commit()?),
418        Err(e) if e.code() == ErrorCode::NotFound => None,
419        Err(e) => return Err(e),
420    };
421
422    let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
423    let commit_oid = repo.commit(Some(ref_name), &sig, &sig, message, &tree, &parents)?;
424    Ok(commit_oid)
425}
426
427/// Recursively collect leaf entries from a metadata tree.
428fn collect_tree_entries(
429    repo: &Repository,
430    tree: &git2::Tree<'_>,
431    prefix: &str,
432) -> Result<Vec<MetadataEntry>, Error> {
433    let mut results = Vec::new();
434    for entry in tree.iter() {
435        let name = entry.name().unwrap_or("");
436        let path = if prefix.is_empty() {
437            name.to_string()
438        } else {
439            format!("{prefix}/{name}")
440        };
441        match entry.kind() {
442            Some(git2::ObjectType::Tree) => {
443                let subtree = repo.find_tree(entry.id())?;
444                results.extend(collect_tree_entries(repo, &subtree, &path)?);
445            }
446            Some(git2::ObjectType::Blob) => {
447                let blob = repo.find_blob(entry.id())?;
448                results.push(MetadataEntry {
449                    path,
450                    content: Some(blob.content().to_vec()),
451                    oid: entry.id(),
452                    is_tree: false,
453                });
454            }
455            _ => {}
456        }
457    }
458    Ok(results)
459}
460
461/// Insert a blob at `path` within an existing tree (or create a new tree).
462/// Path components are split on `/`. Returns the new tree OID.
463fn insert_path_into_tree(
464    repo: &Repository,
465    existing: Option<&git2::Tree<'_>>,
466    path: &str,
467    blob_oid: Oid,
468) -> Result<Oid, Error> {
469    let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
470    if components.is_empty() {
471        return Err(Error::from_str("empty path"));
472    }
473    insert_path_recursive(repo, existing, &components, blob_oid)
474}
475
476fn insert_path_recursive(
477    repo: &Repository,
478    existing: Option<&git2::Tree<'_>>,
479    components: &[&str],
480    blob_oid: Oid,
481) -> Result<Oid, Error> {
482    assert!(!components.is_empty());
483
484    let name = components[0];
485
486    if components.len() == 1 {
487        // Leaf: insert the blob.
488        let mut builder = repo.treebuilder(existing)?;
489        builder.insert(name, blob_oid, 0o100644)?;
490        return builder.write();
491    }
492
493    // Intermediate directory: recurse.
494    let sub_existing = match existing {
495        Some(tree) => match tree.get_name(name) {
496            Some(entry) if entry.kind() == Some(git2::ObjectType::Tree) => {
497                Some(repo.find_tree(entry.id())?)
498            }
499            _ => None,
500        },
501        None => None,
502    };
503
504    let child_oid = insert_path_recursive(repo, sub_existing.as_ref(), &components[1..], blob_oid)?;
505
506    let mut builder = repo.treebuilder(existing)?;
507    builder.insert(name, child_oid, 0o040000)?;
508    builder.write()
509}
510
511/// Remove a `/`-separated path from a tree, cleaning up empty parent directories.
512/// Returns `None` if the tree becomes empty.
513fn remove_path_from_tree(
514    repo: &Repository,
515    tree: &git2::Tree<'_>,
516    path: &str,
517) -> Result<Option<Oid>, Error> {
518    let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
519    if components.is_empty() {
520        return Err(Error::from_str("empty path"));
521    }
522    remove_path_recursive(repo, tree, &components)
523}
524
525fn remove_path_recursive(
526    repo: &Repository,
527    tree: &git2::Tree<'_>,
528    components: &[&str],
529) -> Result<Option<Oid>, Error> {
530    assert!(!components.is_empty());
531    let name = components[0];
532
533    if components.len() == 1 {
534        // Leaf: remove the entry.
535        let mut builder = repo.treebuilder(Some(tree))?;
536        if builder.get(name)?.is_none() {
537            return Err(Error::from_str("path not found"));
538        }
539        builder.remove(name)?;
540        if builder.is_empty() {
541            Ok(None)
542        } else {
543            Ok(Some(builder.write()?))
544        }
545    } else {
546        // Intermediate: recurse into subtree.
547        let entry = tree
548            .get_name(name)
549            .ok_or_else(|| Error::from_str("path not found"))?;
550        let subtree = repo.find_tree(entry.id())?;
551        let child_oid = remove_path_recursive(repo, &subtree, &components[1..])?;
552
553        let mut builder = repo.treebuilder(Some(tree))?;
554        match child_oid {
555            Some(oid) => {
556                builder.insert(name, oid, 0o040000)?;
557            }
558            None => {
559                builder.remove(name)?;
560            }
561        }
562        if builder.is_empty() {
563            Ok(None)
564        } else {
565            Ok(Some(builder.write()?))
566        }
567    }
568}
569
570/// Check if a path exists in a tree.
571fn path_exists_in_tree(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> bool {
572    let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
573    if components.is_empty() {
574        return false;
575    }
576    path_exists_recursive(repo, tree, &components)
577}
578
579fn path_exists_recursive(repo: &Repository, tree: &git2::Tree<'_>, components: &[&str]) -> bool {
580    if components.is_empty() {
581        return false;
582    }
583    match tree.get_name(components[0]) {
584        None => false,
585        Some(entry) => {
586            if components.len() == 1 {
587                true
588            } else if entry.kind() == Some(git2::ObjectType::Tree) {
589                match repo.find_tree(entry.id()) {
590                    Ok(subtree) => path_exists_recursive(repo, &subtree, &components[1..]),
591                    Err(_) => false,
592                }
593            } else {
594                false
595            }
596        }
597    }
598}
599
600/// Match a path against a glob-like pattern.
601/// Supports `*` (any single component) and `**` (any number of components).
602/// Also supports plain prefix matching (e.g. `labels` matches `labels/bug`).
603fn glob_matches(pattern: &str, path: &str) -> bool {
604    let pat_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
605    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
606
607    // Exact match shortcut.
608    if pattern == path {
609        return true;
610    }
611
612    // Prefix match: pattern `foo` matches `foo/bar/baz`.
613    if !pat_parts.is_empty()
614        && !pat_parts.iter().any(|p| *p == "*" || *p == "**")
615        && path_parts.starts_with(&pat_parts)
616    {
617        return true;
618    }
619
620    glob_match_recursive(&pat_parts, &path_parts)
621}
622
623fn glob_match_recursive(pattern: &[&str], path: &[&str]) -> bool {
624    if pattern.is_empty() {
625        return path.is_empty();
626    }
627
628    if pattern[0] == "**" {
629        // `**` matches zero or more components.
630        let rest_pat = &pattern[1..];
631        for i in 0..=path.len() {
632            if glob_match_recursive(rest_pat, &path[i..]) {
633                return true;
634            }
635        }
636        return false;
637    }
638
639    if path.is_empty() {
640        return false;
641    }
642
643    let matches_component = pattern[0] == "*" || pattern[0] == path[0];
644    if matches_component {
645        glob_match_recursive(&pattern[1..], &path[1..])
646    } else {
647        false
648    }
649}
650
651/// Recursively collect leaf paths (blobs) from a tree, building up the
652/// `/`-separated path as we descend.  Calls `cb` for each leaf found.
653fn collect_leaf_paths(
654    repo: &Repository,
655    tree: &git2::Tree<'_>,
656    prefix: &str,
657    cb: &mut dyn FnMut(String),
658) -> Result<(), Error> {
659    for entry in tree.iter() {
660        let name = match entry.name() {
661            Some(n) => n,
662            None => continue,
663        };
664        let full = if prefix.is_empty() {
665            name.to_string()
666        } else {
667            format!("{}/{}", prefix, name)
668        };
669        if entry.kind() == Some(git2::ObjectType::Tree) {
670            let subtree = repo.find_tree(entry.id())?;
671            collect_leaf_paths(repo, &subtree, &full, cb)?;
672        } else {
673            cb(full);
674        }
675    }
676    Ok(())
677}
678
679// ---------------------------------------------------------------------------
680// Implementation for git2::Repository
681// ---------------------------------------------------------------------------
682
683impl MetadataIndex for Repository {
684    fn metadata_list(&self, ref_name: &str) -> Result<Vec<(Oid, Oid)>, Error> {
685        let root = match resolve_root_tree(self, ref_name)? {
686            Some(t) => t,
687            None => return Ok(Vec::new()),
688        };
689        collect_entries(self, &root, "")
690    }
691
692    fn metadata_get(&self, ref_name: &str, target: &Oid) -> Result<Option<Oid>, Error> {
693        let root = match resolve_root_tree(self, ref_name)? {
694            Some(t) => t,
695            None => return Ok(None),
696        };
697        Ok(detect_fanout(self, &root, target)?.map(|(_, _, oid)| oid))
698    }
699
700    fn metadata(
701        &self,
702        ref_name: &str,
703        target: &Oid,
704        tree: &Oid,
705        opts: &MetadataOptions,
706    ) -> Result<Oid, Error> {
707        self.find_tree(*tree)?;
708
709        let (segments, leaf) = shard_oid(target, opts.shard_level)?;
710        let existing_root = resolve_root_tree(self, ref_name)?;
711
712        if !opts.force
713            && let Some(ref root) = existing_root
714            && detect_fanout(self, root, target)?.is_some()
715        {
716            return Err(Error::from_str(
717                "metadata entry already exists (use force to overwrite)",
718            ));
719        }
720
721        build_fanout(self, existing_root.as_ref(), &segments, &leaf, tree)
722    }
723
724    fn metadata_commit(&self, ref_name: &str, root: Oid, message: &str) -> Result<Oid, Error> {
725        commit_index(self, ref_name, root, message)
726    }
727
728    fn metadata_show(&self, ref_name: &str, target: &Oid) -> Result<Vec<MetadataEntry>, Error> {
729        let root = match resolve_root_tree(self, ref_name)? {
730            Some(t) => t,
731            None => return Ok(Vec::new()),
732        };
733
734        let tree_oid = match detect_fanout(self, &root, target)? {
735            Some((_, _, oid)) => oid,
736            None => return Ok(Vec::new()),
737        };
738
739        let tree = self.find_tree(tree_oid)?;
740        collect_tree_entries(self, &tree, "")
741    }
742
743    fn metadata_add(
744        &self,
745        ref_name: &str,
746        target: &Oid,
747        path: &str,
748        content: Option<&[u8]>,
749        opts: &MetadataOptions,
750    ) -> Result<Oid, Error> {
751        let blob_oid = self.blob(content.unwrap_or(b""))?;
752
753        let existing_root = resolve_root_tree(self, ref_name)?;
754
755        // Get existing metadata tree for this target, if any.
756        let existing_meta_tree = match &existing_root {
757            Some(root) => match detect_fanout(self, root, target)? {
758                Some((_, _, oid)) => Some(self.find_tree(oid)?),
759                None => None,
760            },
761            None => None,
762        };
763
764        // Check if path already exists.
765        if !opts.force
766            && let Some(ref meta_tree) = existing_meta_tree
767            && path_exists_in_tree(self, meta_tree, path)
768        {
769            return Err(Error::from_str(
770                "path already exists in metadata (use --force to overwrite)",
771            ));
772        }
773
774        // Build new metadata tree with the path inserted.
775        let new_meta_tree_oid =
776            insert_path_into_tree(self, existing_meta_tree.as_ref(), path, blob_oid)?;
777
778        // Now set this as the metadata tree for the target.
779        let (segments, leaf) = if existing_meta_tree.is_some() {
780            // Re-detect to find the current shard layout.
781            match &existing_root {
782                Some(root) => match detect_fanout(self, root, target)? {
783                    Some((s, l, _)) => (s, l),
784                    None => shard_oid(target, opts.shard_level)?,
785                },
786                None => shard_oid(target, opts.shard_level)?,
787            }
788        } else {
789            shard_oid(target, opts.shard_level)?
790        };
791
792        let new_root = build_fanout(
793            self,
794            existing_root.as_ref(),
795            &segments,
796            &leaf,
797            &new_meta_tree_oid,
798        )?;
799
800        let msg = format!("metadata: add {} to {}", path, target);
801        commit_index(self, ref_name, new_root, &msg)?;
802
803        Ok(new_meta_tree_oid)
804    }
805
806    fn metadata_remove_paths(
807        &self,
808        ref_name: &str,
809        target: &Oid,
810        patterns: &[&str],
811        keep: bool,
812    ) -> Result<bool, Error> {
813        let root = match resolve_root_tree(self, ref_name)? {
814            Some(t) => t,
815            None => return Ok(false),
816        };
817
818        let (segments, leaf, meta_oid) = match detect_fanout(self, &root, target)? {
819            Some(t) => t,
820            None => return Ok(false),
821        };
822
823        let meta_tree = self.find_tree(meta_oid)?;
824        let patterns_owned: Vec<String> = patterns.iter().map(|s| s.to_string()).collect();
825        let new_meta_tree = self.filter_by_predicate(&meta_tree, |_repo, path| {
826            let path_str = path.to_str().unwrap_or("");
827            let matched = patterns_owned.iter().any(|p| glob_matches(p, path_str));
828            if keep { matched } else { !matched }
829        })?;
830
831        if new_meta_tree.is_empty() {
832            // Metadata tree is now empty — remove the entire entry.
833            match build_fanout_remove(self, &root, &segments, &leaf)? {
834                RemoveResult::NotFound => Ok(false),
835                RemoveResult::Empty => {
836                    let mut reference = self.find_reference(ref_name)?;
837                    reference.delete()?;
838                    Ok(true)
839                }
840                RemoveResult::Removed(new_root) => {
841                    let msg = format!("metadata: remove paths from {}", target);
842                    commit_index(self, ref_name, new_root, &msg)?;
843                    Ok(true)
844                }
845            }
846        } else if new_meta_tree.id() == meta_oid {
847            Ok(false)
848        } else {
849            let new_root = build_fanout(self, Some(&root), &segments, &leaf, &new_meta_tree.id())?;
850            let msg = format!("metadata: remove paths from {}", target);
851            commit_index(self, ref_name, new_root, &msg)?;
852            Ok(true)
853        }
854    }
855
856    fn metadata_remove(&self, ref_name: &str, target: &Oid) -> Result<bool, Error> {
857        let root = match resolve_root_tree(self, ref_name)? {
858            Some(t) => t,
859            None => return Ok(false),
860        };
861
862        let (segments, leaf) = match detect_fanout(self, &root, target)? {
863            Some((segments, leaf, _)) => (segments, leaf),
864            None => return Ok(false),
865        };
866
867        match build_fanout_remove(self, &root, &segments, &leaf)? {
868            RemoveResult::NotFound => Ok(false),
869            RemoveResult::Empty => {
870                let mut reference = self.find_reference(ref_name)?;
871                reference.delete()?;
872                Ok(true)
873            }
874            RemoveResult::Removed(new_root) => {
875                let msg = format!("metadata: remove {}", target);
876                commit_index(self, ref_name, new_root, &msg)?;
877                Ok(true)
878            }
879        }
880    }
881
882    fn metadata_copy(
883        &self,
884        ref_name: &str,
885        from: &Oid,
886        to: &Oid,
887        opts: &MetadataOptions,
888    ) -> Result<Oid, Error> {
889        let root = match resolve_root_tree(self, ref_name)? {
890            Some(t) => t,
891            None => {
892                return Err(Error::from_str(&format!(
893                    "no metadata entry for source {}",
894                    from
895                )));
896            }
897        };
898
899        let source_tree_oid = match detect_fanout(self, &root, from)? {
900            Some((_, _, oid)) => oid,
901            None => {
902                return Err(Error::from_str(&format!(
903                    "no metadata entry for source {}",
904                    from
905                )));
906            }
907        };
908
909        if !opts.force && detect_fanout(self, &root, to)?.is_some() {
910            return Err(Error::from_str(
911                "metadata entry already exists for target (use --force to overwrite)",
912            ));
913        }
914
915        let (segments, leaf) = shard_oid(to, opts.shard_level)?;
916        let new_root = build_fanout(self, Some(&root), &segments, &leaf, &source_tree_oid)?;
917
918        let msg = format!("metadata: copy {} -> {}", from, to);
919        commit_index(self, ref_name, new_root, &msg)?;
920
921        Ok(source_tree_oid)
922    }
923
924    fn metadata_prune(&self, ref_name: &str, dry_run: bool) -> Result<Vec<Oid>, Error> {
925        let entries = self.metadata_list(ref_name)?;
926        let mut pruned = Vec::new();
927        let odb = self.odb()?;
928
929        for (target, _) in &entries {
930            if !odb.exists(*target) {
931                pruned.push(*target);
932            }
933        }
934
935        if !dry_run && !pruned.is_empty() {
936            let mut root = match resolve_root_tree(self, ref_name)? {
937                Some(t) => t,
938                None => return Ok(pruned),
939            };
940
941            for target in &pruned {
942                let (segments, leaf) = match detect_fanout(self, &root, target)? {
943                    Some((segments, leaf, _)) => (segments, leaf),
944                    None => continue,
945                };
946
947                match build_fanout_remove(self, &root, &segments, &leaf)? {
948                    RemoveResult::NotFound => {}
949                    RemoveResult::Empty => {
950                        let mut reference = self.find_reference(ref_name)?;
951                        reference.delete()?;
952                        return Ok(pruned);
953                    }
954                    RemoveResult::Removed(new_root) => {
955                        root = self.find_tree(new_root)?;
956                    }
957                }
958            }
959
960            // Single commit for all removals
961            let msg = format!("metadata: prune {} entries", pruned.len());
962            commit_index(self, ref_name, root.id(), &msg)?;
963        }
964
965        Ok(pruned)
966    }
967
968    fn metadata_get_ref(&self, ref_name: &str) -> String {
969        ref_name.to_string()
970    }
971
972    fn link(
973        &self,
974        ref_name: &str,
975        a: &str,
976        b: &str,
977        forward: &str,
978        reverse: &str,
979        meta: Option<&[u8]>,
980    ) -> Result<Oid, Error> {
981        let blob_oid = self.blob(meta.unwrap_or(b""))?;
982        let existing_root = resolve_root_tree(self, ref_name)?;
983
984        // Insert a/<forward>/<b>
985        let forward_path = format!("{}/{}/{}", a, forward, b);
986        let tree1 = insert_path_into_tree(self, existing_root.as_ref(), &forward_path, blob_oid)?;
987
988        // Insert b/<reverse>/<a> into the same tree
989        let reverse_path = format!("{}/{}/{}", b, reverse, a);
990        let tree1_obj = self.find_tree(tree1)?;
991        let tree2 = insert_path_into_tree(self, Some(&tree1_obj), &reverse_path, blob_oid)?;
992
993        let msg = format!("link: {} -[{}]-> {}", a, forward, b);
994        commit_index(self, ref_name, tree2, &msg)?;
995        Ok(tree2)
996    }
997
998    fn unlink(
999        &self,
1000        ref_name: &str,
1001        a: &str,
1002        b: &str,
1003        forward: &str,
1004        reverse: &str,
1005    ) -> Result<Oid, Error> {
1006        let root =
1007            resolve_root_tree(self, ref_name)?.ok_or_else(|| Error::from_str("ref not found"))?;
1008
1009        // Remove a/<forward>/<b>
1010        let forward_path = format!("{}/{}/{}", a, forward, b);
1011        let tree1 = remove_path_from_tree(self, &root, &forward_path)?
1012            .ok_or_else(|| Error::from_str("tree became empty after unlink"))?;
1013
1014        // Remove b/<reverse>/<a>
1015        let tree1_obj = self.find_tree(tree1)?;
1016        let reverse_path = format!("{}/{}/{}", b, reverse, a);
1017        let tree2_opt = remove_path_from_tree(self, &tree1_obj, &reverse_path)?;
1018
1019        match tree2_opt {
1020            Some(tree2) => {
1021                let msg = format!("unlink: {} -[{}]-> {}", a, forward, b);
1022                commit_index(self, ref_name, tree2, &msg)?;
1023                Ok(tree2)
1024            }
1025            None => {
1026                // Tree is empty — delete the ref
1027                let mut reference = self.find_reference(ref_name)?;
1028                reference.delete()?;
1029                let empty = self.treebuilder(None)?.write()?;
1030                Ok(empty)
1031            }
1032        }
1033    }
1034
1035    fn linked(
1036        &self,
1037        ref_name: &str,
1038        key: &str,
1039        relation: Option<&str>,
1040    ) -> Result<Vec<(String, String)>, Error> {
1041        let root = match resolve_root_tree(self, ref_name)? {
1042            Some(t) => t,
1043            None => return Ok(Vec::new()),
1044        };
1045
1046        // Find the key's subtree — handle keys containing '/'
1047        let key_tree = if key.contains('/') {
1048            let components: Vec<&str> = key.split('/').filter(|s| !s.is_empty()).collect();
1049            let mut current = root.clone();
1050            for component in &components {
1051                let next_id = match current.get_name(component) {
1052                    Some(e) if e.kind() == Some(git2::ObjectType::Tree) => e.id(),
1053                    _ => return Ok(Vec::new()),
1054                };
1055                current = self.find_tree(next_id)?;
1056            }
1057            current
1058        } else {
1059            let key_entry = match root.get_name(key) {
1060                Some(e) => e,
1061                None => return Ok(Vec::new()),
1062            };
1063            self.find_tree(key_entry.id())?
1064        };
1065
1066        let mut results = Vec::new();
1067
1068        if let Some(rel) = relation {
1069            // Only look at one relation
1070            if let Some(rel_entry) = key_tree.get_name(rel)
1071                && rel_entry.kind() == Some(git2::ObjectType::Tree)
1072            {
1073                let rel_tree = self.find_tree(rel_entry.id())?;
1074                collect_leaf_paths(self, &rel_tree, "", &mut |path| {
1075                    results.push((rel.to_string(), path));
1076                })?;
1077            }
1078        } else {
1079            // All relations
1080            for rel_entry in key_tree.iter() {
1081                if rel_entry.kind() == Some(git2::ObjectType::Tree) {
1082                    let rel_name = rel_entry.name().unwrap_or("").to_string();
1083                    let rel_tree = self.find_tree(rel_entry.id())?;
1084                    collect_leaf_paths(self, &rel_tree, "", &mut |path| {
1085                        results.push((rel_name.clone(), path));
1086                    })?;
1087                }
1088            }
1089        }
1090
1091        Ok(results)
1092    }
1093
1094    fn is_linked(&self, ref_name: &str, a: &str, b: &str, forward: &str) -> Result<bool, Error> {
1095        let root = match resolve_root_tree(self, ref_name)? {
1096            Some(t) => t,
1097            None => return Ok(false),
1098        };
1099        let path = format!("{}/{}/{}", a, forward, b);
1100        Ok(path_exists_in_tree(self, &root, &path))
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests;