Skip to main content

lit/commands/
import_git.rs

1use crate::core::{find_repo_root, Blob, Commit, Object, ObjectHash, Tag, Tree};
2use crate::response::ImportGitResponse;
3use crate::storage::ObjectStore;
4use sha1::Digest as Sha1Digest;
5use std::collections::{HashMap, HashSet};
6use std::fs;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10/// Import a Git repository into Lit format.
11/// Reads Git objects (SHA-1), converts them to Lit objects (SHA3-512 + BLAKE3),
12/// and imports all refs.
13pub fn execute(source: String) -> Result<ImportGitResponse, crate::errors::LitError> {
14    let source_path = PathBuf::from(&source);
15    let git_dir = find_git_dir(&source_path)?;
16
17    // Initialize lit repo in current directory if not already
18    let repo_root = match find_repo_root() {
19        Ok(r) => r,
20        Err(_) => {
21            crate::commands::init::execute(false, None)?;
22            find_repo_root()?
23        }
24    };
25
26    let store = ObjectStore::new(&repo_root);
27    let mut hash_map: HashMap<String, ObjectHash> = HashMap::new();
28    let mut objects_imported = 0u64;
29    let mut refs_imported = 0u64;
30
31    // A Lit tree records its children by Lit hash, and a Lit commit records
32    // its tree and parents the same way, so an object can only be converted
33    // once everything it references already has a Lit hash. Neither the
34    // filesystem order of `objects/XX/…` nor pack order guarantees that, so
35    // discovery and conversion are separate phases: find every object first,
36    // then convert the graph in dependency order.
37    let mut discovered: HashMap<String, DiscoveredObject> = HashMap::new();
38    let mut deltas_unresolved = 0u64;
39
40    // Phase 1: Discover all loose objects
41    let objects_dir = git_dir.join("objects");
42    if objects_dir.exists() {
43        for entry in walkdir::WalkDir::new(&objects_dir)
44            .min_depth(2)
45            .max_depth(2)
46        {
47            let entry = entry.map_err(|e| format!("Failed to walk objects: {}", e))?;
48            if !entry.file_type().is_file() {
49                continue;
50            }
51            let path = entry.path();
52            // Reconstruct the SHA-1 hex from dir/file
53            if let (Some(dir_name), Some(file_name)) =
54                (path.parent().and_then(|p| p.file_name()), path.file_name())
55            {
56                let dir_str = dir_name.to_string_lossy();
57                let file_str = file_name.to_string_lossy();
58                // Skip pack/info directories
59                if dir_str == "pack" || dir_str == "info" {
60                    continue;
61                }
62                let git_hash = format!("{}{}", dir_str, file_str);
63
64                match discover_loose_object(path) {
65                    Ok((hash, object)) => {
66                        discovered.insert(hash, object);
67                    }
68                    Err(e) => {
69                        eprintln!("Warning: skipping object {}: {}", &git_hash[..8], e);
70                    }
71                }
72            }
73        }
74    }
75
76    // Phase 2: Discover objects held in pack files
77    let mut packs: Vec<(PathBuf, HashMap<usize, PackEntry>)> = Vec::new();
78    let pack_dir = objects_dir.join("pack");
79    if pack_dir.exists() {
80        for entry in
81            fs::read_dir(&pack_dir).map_err(|e| format!("Failed to read pack dir: {}", e))?
82        {
83            let entry = entry.map_err(|e| format!("Pack dir entry error: {}", e))?;
84            let path = entry.path();
85            if path.extension().and_then(|e| e.to_str()) == Some("pack") {
86                match read_pack_entries(&path) {
87                    Ok(entries) => packs.push((path, entries)),
88                    Err(e) => eprintln!("Warning: skipping pack {}: {}", path.display(), e),
89                }
90            }
91        }
92    }
93
94    // Resolve every pack together. A REF_DELTA names its base by hash, and a
95    // thin pack leaves that base outside the file — often in a sibling pack.
96    // Resolving one pack at a time made that depend on `read_dir` order.
97    if !packs.is_empty() {
98        resolve_all_packs(&packs, &mut discovered, &mut deltas_unresolved)?;
99    }
100
101    // Phase 3: Convert the discovered graph, dependencies first
102    let roots: Vec<String> = discovered.keys().cloned().collect();
103    let mut scheduled: HashSet<String> = HashSet::new();
104    for git_hash in &roots {
105        objects_imported += import_subgraph(
106            git_hash,
107            &discovered,
108            &store,
109            &mut hash_map,
110            &mut scheduled,
111            deltas_unresolved,
112        )?;
113    }
114
115    // Phase 4: Import refs
116    // branches
117    let refs_heads = git_dir.join("refs").join("heads");
118    if refs_heads.exists() {
119        for entry in walkdir::WalkDir::new(&refs_heads).min_depth(1) {
120            let entry = entry.map_err(|e| format!("Failed to walk refs: {}", e))?;
121            if !entry.file_type().is_file() {
122                continue;
123            }
124            let branch_name = entry
125                .path()
126                .strip_prefix(&refs_heads)
127                .map_err(|e| format!("Path error: {}", e))?
128                .to_string_lossy()
129                .replace('\\', "/");
130            let git_hash = fs::read_to_string(entry.path())
131                .map_err(|e| format!("Failed to read ref: {}", e))?
132                .trim()
133                .to_string();
134            if let Some(lit_hash) = hash_map.get(&git_hash) {
135                crate::core::write_ref(
136                    &repo_root,
137                    &format!("heads/{}", branch_name),
138                    lit_hash.as_str(),
139                )?;
140                refs_imported += 1;
141            }
142        }
143    }
144
145    // tags
146    let refs_tags = git_dir.join("refs").join("tags");
147    if refs_tags.exists() {
148        for entry in walkdir::WalkDir::new(&refs_tags).min_depth(1) {
149            let entry = entry.map_err(|e| format!("Failed to walk tags: {}", e))?;
150            if !entry.file_type().is_file() {
151                continue;
152            }
153            let tag_name = entry
154                .path()
155                .strip_prefix(&refs_tags)
156                .map_err(|e| format!("Path error: {}", e))?
157                .to_string_lossy()
158                .replace('\\', "/");
159            let git_hash = fs::read_to_string(entry.path())
160                .map_err(|e| format!("Failed to read ref: {}", e))?
161                .trim()
162                .to_string();
163            if let Some(lit_hash) = hash_map.get(&git_hash) {
164                crate::core::write_ref(
165                    &repo_root,
166                    &format!("tags/{}", tag_name),
167                    lit_hash.as_str(),
168                )?;
169                refs_imported += 1;
170            }
171        }
172    }
173
174    // HEAD
175    let head_path = git_dir.join("HEAD");
176    if head_path.exists() {
177        let head_content =
178            fs::read_to_string(&head_path).map_err(|e| format!("Failed to read HEAD: {}", e))?;
179        let head_content = head_content.trim();
180        if let Some(ref_target) = head_content.strip_prefix("ref: refs/heads/") {
181            crate::core::update_head(&repo_root, ref_target)?;
182        }
183    }
184
185    // Copy .gitignore as .litignore if present
186    let gitignore = source_path.join(".gitignore");
187    let litignore = repo_root.join(".litignore");
188    if gitignore.exists() && !litignore.exists() {
189        let _ = fs::copy(&gitignore, &litignore);
190    }
191
192    Ok(ImportGitResponse {
193        source: source.clone(),
194        objects_imported,
195        refs_imported,
196        hash_mapping_count: hash_map.len(),
197        message: format!(
198            "Imported {} objects and {} refs from Git repository",
199            objects_imported, refs_imported
200        ),
201    })
202}
203
204/// Find the .git directory for a given path
205fn find_git_dir(path: &Path) -> Result<PathBuf, crate::errors::LitError> {
206    // Could be a bare repo or have .git directory
207    let dot_git = path.join(".git");
208    if dot_git.is_dir() {
209        return Ok(dot_git);
210    }
211    // Bare repository — objects dir directly present
212    if path.join("objects").is_dir() && path.join("refs").is_dir() {
213        return Ok(path.to_path_buf());
214    }
215    Err(format!("Not a Git repository: {}", path.display()).into())
216}
217
218/// Where a discovered Git object's bytes can be read back from.
219enum ObjectSource {
220    /// A loose object file, re-inflated on demand.
221    Loose(PathBuf),
222    /// Content already inflated out of a pack file. Pack entries cannot be
223    /// located a second time without building an index, so they are retained.
224    Packed(Vec<u8>),
225}
226
227/// A Git object found in the source repository, ahead of conversion to Lit.
228struct DiscoveredObject {
229    source: ObjectSource,
230    obj_type: String,
231    /// Git hashes this object points at, all of which convert before it.
232    deps: Vec<String>,
233}
234
235impl DiscoveredObject {
236    /// The object's body, without the `<type> <size>\0` header.
237    fn content(&self) -> Result<Vec<u8>, crate::errors::LitError> {
238        match &self.source {
239            ObjectSource::Loose(path) => Ok(read_loose_object(path)?.2),
240            ObjectSource::Packed(content) => Ok(content.clone()),
241        }
242    }
243}
244
245/// A step in the iterative post-order walk of the Git object graph.
246enum Step {
247    /// Expand this object's dependencies before converting it.
248    Visit(String),
249    /// Every dependency now has a Lit hash; convert and store this object.
250    Emit(String),
251}
252
253/// Read and inflate a loose Git object, returning its hash, type and body.
254fn read_loose_object(path: &Path) -> Result<(String, String, Vec<u8>), crate::errors::LitError> {
255    let compressed = fs::read(path).map_err(|e| format!("Read error: {}", e))?;
256
257    // Decompress zlib
258    let mut decoder = flate2::read::ZlibDecoder::new(&compressed[..]);
259    let mut raw = Vec::new();
260    decoder
261        .read_to_end(&mut raw)
262        .map_err(|e| format!("Decompress error: {}", e))?;
263
264    // Parse Git object format: "<type> <size>\0<content>"
265    let null_pos = raw
266        .iter()
267        .position(|&b| b == 0)
268        .ok_or("Invalid Git object: no null byte")?;
269    let header = std::str::from_utf8(&raw[..null_pos]).map_err(|_| "Invalid Git object header")?;
270    let (obj_type, _size_str) = header
271        .split_once(' ')
272        .ok_or("Invalid Git object header format")?;
273
274    // The Git hash covers the header as well as the body
275    let mut sha1 = sha1::Sha1::new();
276    sha1.update(&raw);
277    let git_hash = hex::encode(sha1.finalize());
278
279    Ok((git_hash, obj_type.to_string(), raw[null_pos + 1..].to_vec()))
280}
281
282/// Record a loose Git object and what it references, without converting it.
283fn discover_loose_object(
284    path: &Path,
285) -> Result<(String, DiscoveredObject), crate::errors::LitError> {
286    let (git_hash, obj_type, content) = read_loose_object(path)?;
287    let deps = git_dependencies(&obj_type, &content)?;
288    Ok((
289        git_hash,
290        DiscoveredObject {
291            source: ObjectSource::Loose(path.to_path_buf()),
292            obj_type,
293            deps,
294        },
295    ))
296}
297
298/// The Git hashes an object references, which must be converted before it.
299fn git_dependencies(
300    obj_type: &str,
301    content: &[u8],
302) -> Result<Vec<String>, crate::errors::LitError> {
303    Ok(match obj_type {
304        "tree" => git_tree_entries(content)?
305            .into_iter()
306            .map(|(_, _, hash)| hash)
307            .collect(),
308        "commit" => git_commit_refs(content),
309        "tag" => git_tag_target(content).into_iter().collect(),
310        _ => Vec::new(),
311    })
312}
313
314/// The object an annotated tag points at.
315fn git_tag_target(content: &[u8]) -> Option<String> {
316    let text = std::str::from_utf8(content).ok()?;
317    git_header(text)
318        .lines()
319        .find_map(|line| line.strip_prefix("object "))
320        .map(|hash| hash.trim().to_string())
321}
322
323/// The header of a Git commit or tag: everything before the first blank line.
324fn git_header(text: &str) -> &str {
325    git_header_and_message(text).0
326}
327
328/// Split a Git commit or tag into its header and its message.
329///
330/// The message is returned byte for byte — including its trailing newline —
331/// so that re-exporting the object reproduces the original Git hash.
332fn git_header_and_message(text: &str) -> (&str, &str) {
333    text.split_once("\n\n").unwrap_or((text, ""))
334}
335
336/// Convert `root` and everything it references, dependencies first.
337///
338/// Returns the number of objects written. Objects already converted are
339/// skipped, so this can be driven over every discovered hash. The walk is
340/// iterative because commit chains are as deep as the history is long.
341fn import_subgraph(
342    root: &str,
343    discovered: &HashMap<String, DiscoveredObject>,
344    store: &ObjectStore,
345    hash_map: &mut HashMap<String, ObjectHash>,
346    scheduled: &mut HashSet<String>,
347    deltas_unresolved: u64,
348) -> Result<u64, crate::errors::LitError> {
349    let mut imported = 0u64;
350    let mut stack = vec![Step::Visit(root.to_string())];
351
352    while let Some(step) = stack.pop() {
353        match step {
354            Step::Visit(git_hash) => {
355                if hash_map.contains_key(&git_hash) || !scheduled.insert(git_hash.clone()) {
356                    continue;
357                }
358                let object = discovered
359                    .get(&git_hash)
360                    .ok_or_else(|| missing_object_error(&git_hash, deltas_unresolved))?;
361                let deps = object.deps.clone();
362                stack.push(Step::Emit(git_hash));
363                for dep in deps {
364                    stack.push(Step::Visit(dep));
365                }
366            }
367            Step::Emit(git_hash) => {
368                let object = &discovered[&git_hash];
369                let content = object.content()?;
370
371                let lit_obj = match object.obj_type.as_str() {
372                    "blob" => Object::Blob(Blob::new(content)),
373                    "tree" => Object::Tree(parse_git_tree(&content, hash_map)?),
374                    "commit" => Object::Commit(parse_git_commit(&content, hash_map)?),
375                    "tag" => Object::Tag(parse_git_tag(&content, hash_map)?),
376                    other => return Err(format!("Unknown object type: {}", other).into()),
377                };
378
379                let lit_hash = store.write(&lit_obj)?;
380                hash_map.insert(git_hash, lit_hash);
381                imported += 1;
382            }
383        }
384    }
385
386    Ok(imported)
387}
388
389/// Report an object that is referenced but that the source never yielded.
390///
391/// Recording a synthesized hash instead would produce a Lit repository whose
392/// trees and commits point at objects that were never written, so an
393/// incomplete source is reported rather than silently encoded.
394fn missing_object_error(git_hash: &str, deltas_unresolved: u64) -> crate::errors::LitError {
395    let mut msg = format!(
396        "Git object {} is referenced but was not found in the source repository",
397        &git_hash[..8.min(git_hash.len())]
398    );
399    if deltas_unresolved > 0 {
400        msg.push_str(&format!(
401            ". {} pack {} could not be resolved because the base {} not present \
402             — the source looks like a thin pack; fetch it with \
403             `git -C <source> index-pack --fix-thin` or unpack it first",
404            deltas_unresolved,
405            if deltas_unresolved == 1 {
406                "delta"
407            } else {
408                "deltas"
409            },
410            if deltas_unresolved == 1 {
411                "was"
412            } else {
413                "were"
414            }
415        ));
416    }
417    crate::errors::LitError::general(msg)
418}
419
420/// Look up the Lit hash a referenced Git object was converted to.
421fn lookup_lit_hash(
422    hash_map: &HashMap<String, ObjectHash>,
423    git_hash: &str,
424    context: &str,
425) -> Result<ObjectHash, crate::errors::LitError> {
426    hash_map.get(git_hash).cloned().ok_or_else(|| {
427        crate::errors::LitError::general(format!(
428            "Cannot import {}: referenced Git object {} has not been converted",
429            context,
430            &git_hash[..8.min(git_hash.len())]
431        ))
432    })
433}
434
435/// Walk a Git tree object's binary entries as (mode, name, git hash).
436fn git_tree_entries(
437    content: &[u8],
438) -> Result<Vec<(String, String, String)>, crate::errors::LitError> {
439    let mut entries = Vec::new();
440    let mut pos = 0;
441
442    while pos < content.len() {
443        // Format: "<mode> <name>\0<20-byte-sha1>"
444        let space_pos = content[pos..]
445            .iter()
446            .position(|&b| b == b' ')
447            .ok_or("Invalid tree entry: no space")?
448            + pos;
449        let null_pos = content[space_pos..]
450            .iter()
451            .position(|&b| b == 0)
452            .ok_or("Invalid tree entry: no null")?
453            + space_pos;
454
455        let mode = std::str::from_utf8(&content[pos..space_pos])
456            .map_err(|_| "Invalid mode in tree")?
457            .to_string();
458        let name = std::str::from_utf8(&content[space_pos + 1..null_pos])
459            .map_err(|_| "Invalid name in tree")?
460            .to_string();
461
462        if null_pos + 21 > content.len() {
463            break;
464        }
465        let git_hash = hex::encode(&content[null_pos + 1..null_pos + 21]);
466
467        entries.push((mode, name, git_hash));
468        pos = null_pos + 21;
469    }
470
471    Ok(entries)
472}
473
474/// Parse a Git tree object's binary content
475fn parse_git_tree(
476    content: &[u8],
477    hash_map: &HashMap<String, ObjectHash>,
478) -> Result<Tree, crate::errors::LitError> {
479    let mut tree = Tree::new();
480
481    for (mode, name, git_hash) in git_tree_entries(content)? {
482        let lit_hash = lookup_lit_hash(hash_map, &git_hash, &format!("tree entry '{}'", name))?;
483
484        let obj_type = if mode.starts_with("40") {
485            "tree"
486        } else {
487            "blob"
488        }
489        .to_string();
490
491        tree.add_entry(mode, name, lit_hash, obj_type);
492    }
493
494    Ok(tree)
495}
496
497/// The tree and parent hashes a Git commit references.
498fn git_commit_refs(content: &[u8]) -> Vec<String> {
499    let text = match std::str::from_utf8(content) {
500        Ok(t) => t,
501        Err(_) => return Vec::new(),
502    };
503
504    let mut refs = Vec::new();
505    for line in text.lines() {
506        // The header ends at the first blank line; the message may contain
507        // anything, including lines that look like headers.
508        if line.is_empty() {
509            break;
510        }
511        if let Some(rest) = line.strip_prefix("tree ") {
512            refs.push(rest.trim().to_string());
513        } else if let Some(rest) = line.strip_prefix("parent ") {
514            refs.push(rest.trim().to_string());
515        }
516    }
517    refs
518}
519
520/// Parse a Git commit object's text content
521fn parse_git_commit(
522    content: &[u8],
523    hash_map: &HashMap<String, ObjectHash>,
524) -> Result<Commit, crate::errors::LitError> {
525    let text = std::str::from_utf8(content).map_err(|_| "Invalid commit: not UTF-8")?;
526
527    let mut tree_hash = String::new();
528    let mut parents = Vec::new();
529    let mut author = String::new();
530    let mut committer = String::new();
531    let mut timestamp: i64 = 0;
532    let mut timezone = None;
533
534    let (header, message) = git_header_and_message(text);
535
536    for line in header.lines() {
537        if let Some(rest) = line.strip_prefix("tree ") {
538            tree_hash = rest.trim().to_string();
539        } else if let Some(rest) = line.strip_prefix("parent ") {
540            parents.push(rest.trim().to_string());
541        } else if let Some(rest) = line.strip_prefix("author ") {
542            let (name, ts, tz) = parse_git_ident(rest);
543            author = name;
544            timestamp = ts;
545            timezone = tz;
546        } else if let Some(rest) = line.strip_prefix("committer ") {
547            let (name, _, _) = parse_git_ident(rest);
548            committer = name;
549        }
550    }
551
552    // Map git hashes to lit hashes
553    let lit_tree = lookup_lit_hash(hash_map, &tree_hash, "commit tree")?;
554
555    let lit_parents: Vec<ObjectHash> = parents
556        .iter()
557        .map(|p| lookup_lit_hash(hash_map, p, "commit parent"))
558        .collect::<Result<_, _>>()?;
559
560    Ok(Commit {
561        tree: lit_tree,
562        parents: lit_parents,
563        author,
564        committer,
565        timestamp,
566        message: message.to_string(),
567        pq_signature: None,
568        metadata: None,
569        timezone,
570    })
571}
572
573/// Parse a Git annotated tag object into a Lit tag.
574fn parse_git_tag(
575    content: &[u8],
576    hash_map: &HashMap<String, ObjectHash>,
577) -> Result<Tag, crate::errors::LitError> {
578    let text = std::str::from_utf8(content).map_err(|_| "Invalid tag: not UTF-8")?;
579    let (header, message) = git_header_and_message(text);
580
581    let mut target_hash = String::new();
582    let mut target_type = String::new();
583    let mut tag_name = String::new();
584    let mut tagger = String::new();
585    let mut timestamp: i64 = 0;
586    let mut timezone = None;
587
588    for line in header.lines() {
589        if let Some(rest) = line.strip_prefix("object ") {
590            target_hash = rest.trim().to_string();
591        } else if let Some(rest) = line.strip_prefix("type ") {
592            target_type = rest.trim().to_string();
593        } else if let Some(rest) = line.strip_prefix("tag ") {
594            tag_name = rest.trim().to_string();
595        } else if let Some(rest) = line.strip_prefix("tagger ") {
596            let (name, ts, tz) = parse_git_ident(rest);
597            tagger = name;
598            timestamp = ts;
599            timezone = tz;
600        }
601    }
602
603    Ok(Tag {
604        target: lookup_lit_hash(hash_map, &target_hash, "tag target")?,
605        target_type,
606        tag_name,
607        tagger,
608        timestamp,
609        message: message.to_string(),
610        pq_signature: None,
611        metadata: None,
612        timezone,
613    })
614}
615
616/// Parse a Git identity line into its name, timestamp and timezone offset.
617fn parse_git_ident(ident: &str) -> (String, i64, Option<String>) {
618    // "John Doe <john@example.com> 1234567890 +0000"
619    if let Some(bracket_pos) = ident.rfind('>') {
620        let name_email = &ident[..=bracket_pos];
621        let rest = ident[bracket_pos + 1..].trim();
622        let mut fields = rest.split_whitespace();
623        let timestamp = fields
624            .next()
625            .and_then(|s| s.parse::<i64>().ok())
626            .unwrap_or(0);
627        let timezone = fields.next().map(|tz| tz.to_string());
628        (name_email.trim().to_string(), timestamp, timezone)
629    } else {
630        (ident.to_string(), 0, None)
631    }
632}
633
634/// A pack entry as it appears on disk, before any delta is applied.
635enum PackEntry {
636    /// A complete object: its type name and body.
637    Whole { obj_type: String, content: Vec<u8> },
638    /// A delta against another entry in this same pack, named by byte offset.
639    OfsDelta { base_offset: usize, delta: Vec<u8> },
640    /// A delta against an object named by SHA-1, which a thin pack may leave
641    /// outside this file.
642    RefDelta { base: String, delta: Vec<u8> },
643}
644
645/// Upper bound on how many resolution rounds a pack may take.
646///
647/// Each round resolves one more level of delta chain, so this caps the chain
648/// depth. Git's own default packing depth is 50; the margin covers packs
649/// produced with a larger `--depth`.
650const MAX_DELTA_ROUNDS: usize = 1024;
651
652/// Discover the objects held in a Git pack file, without converting them
653fn read_pack_entries(
654    pack_path: &Path,
655) -> Result<HashMap<usize, PackEntry>, crate::errors::LitError> {
656    let data = fs::read(pack_path).map_err(|e| format!("Failed to read pack: {}", e))?;
657
658    // Validate pack header: "PACK" magic, version 2/3, object count
659    if data.len() < 12 {
660        return Err("Pack file too small".into());
661    }
662    if &data[0..4] != b"PACK" {
663        return Err("Invalid pack file magic".into());
664    }
665    let version = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
666    if version != 2 && version != 3 {
667        return Err(format!("Unsupported pack version: {}", version).into());
668    }
669    let num_objects = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
670
671    // Read every entry, deltas included, keyed by the byte offset it starts
672    // at — that is how OFS_DELTA entries name their base.
673    let mut entries: HashMap<usize, PackEntry> = HashMap::new();
674    let mut pos = 12;
675    for _ in 0..num_objects {
676        // The last 20 bytes are the pack checksum, not an entry.
677        if pos + 20 > data.len() {
678            break;
679        }
680        let start = pos;
681        match read_pack_entry(&data, &mut pos, start) {
682            Ok(entry) => {
683                entries.insert(start, entry);
684            }
685            Err(e) => {
686                eprintln!("Warning: skipping pack entry: {}", e);
687                break;
688            }
689        }
690    }
691
692    Ok(entries)
693}
694
695/// Read one pack entry starting at `*pos`, advancing past it.
696///
697/// `entry_start` is the offset the entry begins at, which an OFS_DELTA needs
698/// in order to turn its backward distance into an absolute base offset.
699fn read_pack_entry(
700    data: &[u8],
701    pos: &mut usize,
702    entry_start: usize,
703) -> Result<PackEntry, crate::errors::LitError> {
704    // Read type and size from variable-length header
705    let mut byte = *data.get(*pos).ok_or("Unexpected end of pack")?;
706    let obj_type = (byte >> 4) & 0x07;
707    let mut _size: u64 = (byte & 0x0f) as u64;
708    let mut shift = 4;
709    *pos += 1;
710
711    while byte & 0x80 != 0 {
712        byte = *data.get(*pos).ok_or("Truncated pack header")?;
713        _size |= ((byte & 0x7f) as u64) << shift;
714        shift += 7;
715        *pos += 1;
716    }
717
718    match obj_type {
719        1..=4 => {
720            // Regular object types: commit, tree, blob, tag
721            let obj_type = match obj_type {
722                1 => "commit",
723                2 => "tree",
724                3 => "blob",
725                4 => "tag",
726                _ => unreachable!(),
727            };
728            Ok(PackEntry::Whole {
729                obj_type: obj_type.to_string(),
730                content: inflate_at(data, pos)?,
731            })
732        }
733        6 => {
734            // OFS_DELTA: a distance *backwards* from this entry's own start,
735            // encoded with an increment per continuation byte.
736            let mut byte = *data.get(*pos).ok_or("Truncated offset delta")?;
737            let mut back: u64 = (byte & 0x7f) as u64;
738            *pos += 1;
739            while byte & 0x80 != 0 {
740                byte = *data.get(*pos).ok_or("Truncated offset delta")?;
741                back = ((back + 1) << 7) | (byte & 0x7f) as u64;
742                *pos += 1;
743            }
744            let base_offset = entry_start
745                .checked_sub(back as usize)
746                .ok_or("Offset delta points before the start of the pack")?;
747            Ok(PackEntry::OfsDelta {
748                base_offset,
749                delta: inflate_at(data, pos)?,
750            })
751        }
752        7 => {
753            // REF_DELTA: the base is named by SHA-1
754            if *pos + 20 > data.len() {
755                return Err("Truncated ref delta".into());
756            }
757            let base = hex::encode(&data[*pos..*pos + 20]);
758            *pos += 20;
759            Ok(PackEntry::RefDelta {
760                base,
761                delta: inflate_at(data, pos)?,
762            })
763        }
764        _ => Err(format!("Unknown pack object type: {}", obj_type).into()),
765    }
766}
767
768/// Inflate the zlib stream at `*pos`, advancing past the compressed bytes.
769fn inflate_at(data: &[u8], pos: &mut usize) -> Result<Vec<u8>, crate::errors::LitError> {
770    let mut decoder = flate2::read::ZlibDecoder::new(&data[*pos..]);
771    let mut out = Vec::new();
772    decoder
773        .read_to_end(&mut out)
774        .map_err(|e| format!("Decompress error: {}", e))?;
775    *pos += decoder.total_in() as usize;
776    Ok(out)
777}
778
779/// The SHA-1 Git would store an object under.
780fn git_object_hash(obj_type: &str, content: &[u8]) -> String {
781    let mut sha1 = sha1::Sha1::new();
782    sha1.update(format!("{} {}\0", obj_type, content.len()).as_bytes());
783    sha1.update(content);
784    hex::encode(sha1.finalize())
785}
786
787/// Resolve every delta across all of a repository's packs at once.
788///
789/// Resolution runs in rounds: each round settles the entries whose base is
790/// already known, so a round peels one more level of delta chain. Bases
791/// generally precede their deltas, but nothing requires it, and rounds make the
792/// outcome independent of the order entries appear in.
793///
794/// All packs are resolved together rather than one at a time. A REF_DELTA names
795/// its base by hash and a thin pack leaves that base outside the file — very
796/// often in a sibling pack. Resolving each pack in isolation made that work only
797/// when `read_dir` happened to return the base's pack first, so an import could
798/// succeed or fail on directory order alone. Entries are keyed by pack and
799/// offset so a base is found wherever it lives.
800///
801/// Returns the number of objects recorded; entries that never resolve — a base
802/// that is genuinely absent from the repository — are counted in
803/// `deltas_unresolved`.
804fn resolve_all_packs(
805    packs: &[(PathBuf, HashMap<usize, PackEntry>)],
806    discovered: &mut HashMap<String, DiscoveredObject>,
807    deltas_unresolved: &mut u64,
808) -> Result<u64, crate::errors::LitError> {
809    // (pack index, byte offset) — an OFS_DELTA's base offset is relative to its
810    // own pack, so the pack index travels with it.
811    let mut keys: Vec<(usize, usize)> = Vec::new();
812    for (pack, entries) in packs.iter().enumerate() {
813        let mut offsets: Vec<usize> = entries.1.keys().copied().collect();
814        offsets.sort_unstable();
815        keys.extend(offsets.into_iter().map(|offset| (pack, offset)));
816    }
817
818    let mut resolved: HashMap<(usize, usize), (String, String, Vec<u8>)> = HashMap::new();
819    // git hash -> where it resolved, so a REF_DELTA can find a base in any pack
820    let mut by_hash: HashMap<String, (usize, usize)> = HashMap::new();
821
822    for _ in 0..MAX_DELTA_ROUNDS {
823        let mut progressed = false;
824        for &key in &keys {
825            if resolved.contains_key(&key) {
826                continue;
827            }
828            if let Some(object) = try_resolve_entry(key, packs, &resolved, &by_hash, discovered)? {
829                by_hash.insert(object.0.clone(), key);
830                resolved.insert(key, object);
831                progressed = true;
832            }
833        }
834        if !progressed {
835            break;
836        }
837    }
838
839    let mut recorded = 0u64;
840    for key in &keys {
841        match resolved.remove(key) {
842            Some((git_hash, obj_type, content)) => {
843                let deps = git_dependencies(&obj_type, &content)?;
844                discovered.insert(
845                    git_hash,
846                    DiscoveredObject {
847                        source: ObjectSource::Packed(content),
848                        obj_type,
849                        deps,
850                    },
851                );
852                recorded += 1;
853            }
854            None => *deltas_unresolved += 1,
855        }
856    }
857
858    Ok(recorded)
859}
860
861/// Resolve one entry if its base is available, else `None` to retry next round.
862fn try_resolve_entry(
863    key: (usize, usize),
864    packs: &[(PathBuf, HashMap<usize, PackEntry>)],
865    resolved: &HashMap<(usize, usize), (String, String, Vec<u8>)>,
866    by_hash: &HashMap<String, (usize, usize)>,
867    discovered: &HashMap<String, DiscoveredObject>,
868) -> Result<Option<(String, String, Vec<u8>)>, crate::errors::LitError> {
869    let (pack, offset) = key;
870    let entry = match packs
871        .get(pack)
872        .and_then(|(_, entries)| entries.get(&offset))
873    {
874        Some(entry) => entry,
875        None => return Ok(None),
876    };
877
878    let (obj_type, content) = match entry {
879        PackEntry::Whole { obj_type, content } => (obj_type.clone(), content.clone()),
880        // An OFS_DELTA's offset is relative to its own pack, so it stays there.
881        PackEntry::OfsDelta { base_offset, delta } => match resolved.get(&(pack, *base_offset)) {
882            Some((_, base_type, base)) => (base_type.clone(), apply_delta(base, delta)?),
883            None => return Ok(None),
884        },
885        PackEntry::RefDelta { base, delta } => {
886            // Named by hash, so the base may be in any pack, or have arrived
887            // with the loose objects already folded into `discovered`.
888            let resolved_base = by_hash
889                .get(base)
890                .and_then(|key| resolved.get(key))
891                .map(|(_, base_type, base)| (base_type.clone(), base.clone()));
892
893            match resolved_base {
894                Some((base_type, base_content)) => (base_type, apply_delta(&base_content, delta)?),
895                None => match discovered.get(base) {
896                    Some(object) => (
897                        object.obj_type.clone(),
898                        apply_delta(&object.content()?, delta)?,
899                    ),
900                    None => return Ok(None),
901                },
902            }
903        }
904    };
905
906    let git_hash = git_object_hash(&obj_type, &content);
907    Ok(Some((git_hash, obj_type, content)))
908}
909
910/// Read a little-endian base-128 varint, as used for the delta header sizes.
911fn read_delta_varint(data: &[u8], pos: &mut usize) -> Result<u64, crate::errors::LitError> {
912    let mut value: u64 = 0;
913    let mut shift = 0;
914    loop {
915        let byte = *data.get(*pos).ok_or("Truncated delta size")?;
916        *pos += 1;
917        value |= ((byte & 0x7f) as u64) << shift;
918        if byte & 0x80 == 0 {
919            return Ok(value);
920        }
921        shift += 7;
922        if shift > 63 {
923            return Err("Delta size overflows 64 bits".into());
924        }
925    }
926}
927
928/// Apply a Git delta to its base object, producing the target content.
929///
930/// A delta is a source size, a target size, then a run of instructions: a
931/// high-bit-set byte copies a range out of the base, and any other non-zero
932/// byte inserts that many literal bytes that follow it.
933fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>, crate::errors::LitError> {
934    let mut pos = 0;
935
936    let base_size = read_delta_varint(delta, &mut pos)?;
937    if base_size != base.len() as u64 {
938        return Err(format!(
939            "Delta expects a {}-byte base, but the base object is {} bytes",
940            base_size,
941            base.len()
942        )
943        .into());
944    }
945    let target_size = read_delta_varint(delta, &mut pos)?;
946
947    let mut out: Vec<u8> = Vec::with_capacity(target_size as usize);
948    while pos < delta.len() {
949        let instruction = delta[pos];
950        pos += 1;
951
952        if instruction & 0x80 != 0 {
953            // Copy: the low nibble flags which offset bytes are present, the
954            // next three bits which size bytes are.
955            let mut copy_offset: usize = 0;
956            for shift in 0..4 {
957                if instruction & (1 << shift) != 0 {
958                    let byte = *delta.get(pos).ok_or("Truncated delta copy offset")?;
959                    pos += 1;
960                    copy_offset |= (byte as usize) << (8 * shift);
961                }
962            }
963            let mut copy_size: usize = 0;
964            for shift in 0..3 {
965                if instruction & (0x10 << shift) != 0 {
966                    let byte = *delta.get(pos).ok_or("Truncated delta copy size")?;
967                    pos += 1;
968                    copy_size |= (byte as usize) << (8 * shift);
969                }
970            }
971            if copy_size == 0 {
972                copy_size = 0x10000; // a zero size means 64K
973            }
974
975            let end = copy_offset
976                .checked_add(copy_size)
977                .ok_or("Delta copy range overflows")?;
978            if end > base.len() {
979                return Err(format!(
980                    "Delta copies bytes {}..{} from a {}-byte base",
981                    copy_offset,
982                    end,
983                    base.len()
984                )
985                .into());
986            }
987            out.extend_from_slice(&base[copy_offset..end]);
988        } else if instruction != 0 {
989            // Insert: the instruction byte is the length of the literal run.
990            let len = (instruction & 0x7f) as usize;
991            let end = pos.checked_add(len).ok_or("Delta insert range overflows")?;
992            if end > delta.len() {
993                return Err("Delta insert runs past the end of the delta".into());
994            }
995            out.extend_from_slice(&delta[pos..end]);
996            pos = end;
997        } else {
998            return Err("Delta contains a reserved 0x00 instruction".into());
999        }
1000    }
1001
1002    if out.len() as u64 != target_size {
1003        return Err(format!(
1004            "Delta produced {} bytes, but its header declares {}",
1005            out.len(),
1006            target_size
1007        )
1008        .into());
1009    }
1010
1011    Ok(out)
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017
1018    /// Encode a little-endian base-128 varint, as the delta header uses.
1019    fn varint(mut value: u64) -> Vec<u8> {
1020        let mut out = Vec::new();
1021        loop {
1022            let mut byte = (value & 0x7f) as u8;
1023            value >>= 7;
1024            if value != 0 {
1025                byte |= 0x80;
1026            }
1027            out.push(byte);
1028            if value == 0 {
1029                return out;
1030            }
1031        }
1032    }
1033
1034    /// Build a copy instruction, omitting zero bytes the way Git does.
1035    ///
1036    /// A size of 0x10000 therefore encodes as no size bytes at all, which is
1037    /// how the format spells "64K".
1038    fn copy(offset: u32, size: u32) -> Vec<u8> {
1039        let mut instruction = 0x80u8;
1040        let mut operands = Vec::new();
1041        for i in 0..4 {
1042            let byte = ((offset >> (8 * i)) & 0xff) as u8;
1043            if byte != 0 {
1044                instruction |= 1 << i;
1045                operands.push(byte);
1046            }
1047        }
1048        for i in 0..3 {
1049            let byte = ((size >> (8 * i)) & 0xff) as u8;
1050            if byte != 0 {
1051                instruction |= 0x10 << i;
1052                operands.push(byte);
1053            }
1054        }
1055        let mut out = vec![instruction];
1056        out.extend(operands);
1057        out
1058    }
1059
1060    /// Build an insert instruction carrying literal bytes.
1061    fn insert(data: &[u8]) -> Vec<u8> {
1062        let mut out = vec![data.len() as u8];
1063        out.extend_from_slice(data);
1064        out
1065    }
1066
1067    /// Assemble a delta from its declared sizes and instruction stream.
1068    fn delta(base_len: u64, target_len: u64, body: &[Vec<u8>]) -> Vec<u8> {
1069        let mut out = varint(base_len);
1070        out.extend(varint(target_len));
1071        for chunk in body {
1072            out.extend_from_slice(chunk);
1073        }
1074        out
1075    }
1076
1077    #[test]
1078    fn apply_delta_inserts_literal_bytes() {
1079        let d = delta(0, 5, &[insert(b"hello")]);
1080        assert_eq!(apply_delta(b"", &d).unwrap(), b"hello");
1081    }
1082
1083    #[test]
1084    fn apply_delta_copies_from_base() {
1085        let base = b"hello world";
1086        let d = delta(
1087            base.len() as u64,
1088            11,
1089            &[copy(6, 5), insert(b" "), copy(0, 5)],
1090        );
1091        assert_eq!(apply_delta(base, &d).unwrap(), b"world hello");
1092    }
1093
1094    #[test]
1095    fn apply_delta_treats_zero_size_as_64k() {
1096        // All three size bytes zero is the format's encoding of 0x10000.
1097        let base = vec![b'x'; 0x10000];
1098        let d = delta(base.len() as u64, 0x10000, &[copy(0, 0x10000)]);
1099        assert_eq!(apply_delta(&base, &d).unwrap(), base);
1100    }
1101
1102    #[test]
1103    fn apply_delta_rejects_a_base_of_the_wrong_size() {
1104        let d = delta(99, 5, &[insert(b"hello")]);
1105        let err = apply_delta(b"short", &d).unwrap_err();
1106        // `Display` is deliberately sanitized, so assert on the internal text.
1107        let detail = err.internal_message();
1108        assert!(
1109            detail.contains("99"),
1110            "error should name the expected size: {}",
1111            detail
1112        );
1113    }
1114
1115    #[test]
1116    fn apply_delta_rejects_a_copy_past_the_end_of_the_base() {
1117        let base = b"tiny";
1118        let d = delta(base.len() as u64, 100, &[copy(0, 100)]);
1119        assert!(apply_delta(base, &d).is_err());
1120    }
1121
1122    #[test]
1123    fn apply_delta_rejects_the_reserved_instruction() {
1124        let d = delta(0, 1, &[vec![0x00]]);
1125        assert!(apply_delta(b"", &d).is_err());
1126    }
1127
1128    #[test]
1129    fn apply_delta_rejects_output_of_the_wrong_length() {
1130        // The header claims 10 bytes; the instructions produce 5.
1131        let d = delta(0, 10, &[insert(b"hello")]);
1132        assert!(apply_delta(b"", &d).is_err());
1133    }
1134
1135    /// A REF_DELTA whose base sits in a different pack must resolve, whichever
1136    /// order the packs are handed over.
1137    ///
1138    /// Packs used to be resolved one at a time and folded into `discovered`
1139    /// afterwards, so a base in a sibling pack was only found when `read_dir`
1140    /// happened to return that pack first. Same inputs, both orders, same
1141    /// result is the property that was missing.
1142    #[test]
1143    fn test_ref_delta_resolves_against_a_sibling_pack_in_either_order() {
1144        let base_content = b"the base object contents".to_vec();
1145        let base_hash = git_object_hash("blob", &base_content);
1146
1147        // A delta that keeps the base and appends to it.
1148        let suffix = b" plus more";
1149        let body = delta(
1150            base_content.len() as u64,
1151            (base_content.len() + suffix.len()) as u64,
1152            &[copy(0, base_content.len() as u32), insert(suffix)],
1153        );
1154
1155        // PackEntry is not Clone, so each ordering builds its own pair.
1156        let build = |base_first: bool| -> Vec<(PathBuf, HashMap<usize, PackEntry>)> {
1157            let base_pack: HashMap<usize, PackEntry> = [(
1158                0usize,
1159                PackEntry::Whole {
1160                    obj_type: "blob".to_string(),
1161                    content: base_content.clone(),
1162                },
1163            )]
1164            .into_iter()
1165            .collect();
1166            let delta_pack: HashMap<usize, PackEntry> = [(
1167                0usize,
1168                PackEntry::RefDelta {
1169                    base: base_hash.clone(),
1170                    delta: body.clone(),
1171                },
1172            )]
1173            .into_iter()
1174            .collect();
1175
1176            if base_first {
1177                vec![
1178                    (PathBuf::from("base.pack"), base_pack),
1179                    (PathBuf::from("delta.pack"), delta_pack),
1180                ]
1181            } else {
1182                vec![
1183                    (PathBuf::from("delta.pack"), delta_pack),
1184                    (PathBuf::from("base.pack"), base_pack),
1185                ]
1186            }
1187        };
1188
1189        let mut expected = base_content.clone();
1190        expected.extend_from_slice(suffix);
1191        let expected_hash = git_object_hash("blob", &expected);
1192
1193        for (label, packs) in [
1194            ("base pack first", build(true)),
1195            ("delta pack first", build(false)),
1196        ] {
1197            let mut discovered = HashMap::new();
1198            let mut unresolved = 0u64;
1199            let recorded = resolve_all_packs(&packs, &mut discovered, &mut unresolved).unwrap();
1200
1201            assert_eq!(
1202                unresolved, 0,
1203                "{}: nothing should be left unresolved",
1204                label
1205            );
1206            assert_eq!(recorded, 2, "{}: both objects should be recorded", label);
1207            assert_eq!(
1208                discovered.get(&expected_hash).map(|o| o.content().unwrap()),
1209                Some(expected.clone()),
1210                "{}: the delta should rebuild against the sibling pack's base",
1211                label
1212            );
1213        }
1214    }
1215}