Skip to main content

lit/commands/
import_git.rs

1use crate::core::{find_repo_root, Blob, Commit, Object, ObjectHash, Tree};
2use crate::response::ImportGitResponse;
3use crate::storage::ObjectStore;
4use sha1::Digest as Sha1Digest;
5use std::collections::HashMap;
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    // Phase 1: Import all loose objects
32    let objects_dir = git_dir.join("objects");
33    if objects_dir.exists() {
34        for entry in walkdir::WalkDir::new(&objects_dir)
35            .min_depth(2)
36            .max_depth(2)
37        {
38            let entry = entry.map_err(|e| format!("Failed to walk objects: {}", e))?;
39            if !entry.file_type().is_file() {
40                continue;
41            }
42            let path = entry.path();
43            // Reconstruct the SHA-1 hex from dir/file
44            if let (Some(dir_name), Some(file_name)) =
45                (path.parent().and_then(|p| p.file_name()), path.file_name())
46            {
47                let dir_str = dir_name.to_string_lossy();
48                let file_str = file_name.to_string_lossy();
49                // Skip pack/info directories
50                if dir_str == "pack" || dir_str == "info" {
51                    continue;
52                }
53                let git_hash = format!("{}{}", dir_str, file_str);
54
55                match import_loose_object(path, &git_hash, &store, &mut hash_map) {
56                    Ok(_) => objects_imported += 1,
57                    Err(e) => {
58                        eprintln!("Warning: skipping object {}: {}", &git_hash[..8], e);
59                    }
60                }
61            }
62        }
63    }
64
65    // Phase 2: Import pack files
66    let pack_dir = objects_dir.join("pack");
67    if pack_dir.exists() {
68        for entry in
69            fs::read_dir(&pack_dir).map_err(|e| format!("Failed to read pack dir: {}", e))?
70        {
71            let entry = entry.map_err(|e| format!("Pack dir entry error: {}", e))?;
72            let path = entry.path();
73            if path.extension().and_then(|e| e.to_str()) == Some("pack") {
74                match import_pack_file(&path, &store, &mut hash_map) {
75                    Ok(count) => objects_imported += count,
76                    Err(e) => {
77                        eprintln!("Warning: skipping pack {}: {}", path.display(), e);
78                    }
79                }
80            }
81        }
82    }
83
84    // Phase 3: Import refs
85    // branches
86    let refs_heads = git_dir.join("refs").join("heads");
87    if refs_heads.exists() {
88        for entry in walkdir::WalkDir::new(&refs_heads).min_depth(1) {
89            let entry = entry.map_err(|e| format!("Failed to walk refs: {}", e))?;
90            if !entry.file_type().is_file() {
91                continue;
92            }
93            let branch_name = entry
94                .path()
95                .strip_prefix(&refs_heads)
96                .map_err(|e| format!("Path error: {}", e))?
97                .to_string_lossy()
98                .replace('\\', "/");
99            let git_hash = fs::read_to_string(entry.path())
100                .map_err(|e| format!("Failed to read ref: {}", e))?
101                .trim()
102                .to_string();
103            if let Some(lit_hash) = hash_map.get(&git_hash) {
104                crate::core::write_ref(
105                    &repo_root,
106                    &format!("heads/{}", branch_name),
107                    lit_hash.as_str(),
108                )?;
109                refs_imported += 1;
110            }
111        }
112    }
113
114    // tags
115    let refs_tags = git_dir.join("refs").join("tags");
116    if refs_tags.exists() {
117        for entry in walkdir::WalkDir::new(&refs_tags).min_depth(1) {
118            let entry = entry.map_err(|e| format!("Failed to walk tags: {}", e))?;
119            if !entry.file_type().is_file() {
120                continue;
121            }
122            let tag_name = entry
123                .path()
124                .strip_prefix(&refs_tags)
125                .map_err(|e| format!("Path error: {}", e))?
126                .to_string_lossy()
127                .replace('\\', "/");
128            let git_hash = fs::read_to_string(entry.path())
129                .map_err(|e| format!("Failed to read ref: {}", e))?
130                .trim()
131                .to_string();
132            if let Some(lit_hash) = hash_map.get(&git_hash) {
133                crate::core::write_ref(
134                    &repo_root,
135                    &format!("tags/{}", tag_name),
136                    lit_hash.as_str(),
137                )?;
138                refs_imported += 1;
139            }
140        }
141    }
142
143    // HEAD
144    let head_path = git_dir.join("HEAD");
145    if head_path.exists() {
146        let head_content =
147            fs::read_to_string(&head_path).map_err(|e| format!("Failed to read HEAD: {}", e))?;
148        let head_content = head_content.trim();
149        if let Some(ref_target) = head_content.strip_prefix("ref: refs/heads/") {
150            crate::core::update_head(&repo_root, ref_target)?;
151        }
152    }
153
154    // Copy .gitignore as .litignore if present
155    let gitignore = source_path.join(".gitignore");
156    let litignore = repo_root.join(".litignore");
157    if gitignore.exists() && !litignore.exists() {
158        let _ = fs::copy(&gitignore, &litignore);
159    }
160
161    Ok(ImportGitResponse {
162        source: source.clone(),
163        objects_imported,
164        refs_imported,
165        hash_mapping_count: hash_map.len(),
166        message: format!(
167            "Imported {} objects and {} refs from Git repository",
168            objects_imported, refs_imported
169        ),
170    })
171}
172
173/// Find the .git directory for a given path
174fn find_git_dir(path: &Path) -> Result<PathBuf, crate::errors::LitError> {
175    // Could be a bare repo or have .git directory
176    let dot_git = path.join(".git");
177    if dot_git.is_dir() {
178        return Ok(dot_git);
179    }
180    // Bare repository — objects dir directly present
181    if path.join("objects").is_dir() && path.join("refs").is_dir() {
182        return Ok(path.to_path_buf());
183    }
184    Err(format!("Not a Git repository: {}", path.display()).into())
185}
186
187/// Import a single loose Git object
188fn import_loose_object(
189    path: &Path,
190    _git_hash: &str,
191    store: &ObjectStore,
192    hash_map: &mut HashMap<String, ObjectHash>,
193) -> Result<(), crate::errors::LitError> {
194    let compressed = fs::read(path).map_err(|e| format!("Read error: {}", e))?;
195
196    // Decompress zlib
197    let mut decoder = flate2::read::ZlibDecoder::new(&compressed[..]);
198    let mut raw = Vec::new();
199    decoder
200        .read_to_end(&mut raw)
201        .map_err(|e| format!("Decompress error: {}", e))?;
202
203    // Parse Git object format: "<type> <size>\0<content>"
204    let null_pos = raw
205        .iter()
206        .position(|&b| b == 0)
207        .ok_or("Invalid Git object: no null byte")?;
208    let header = std::str::from_utf8(&raw[..null_pos]).map_err(|_| "Invalid Git object header")?;
209    let content = &raw[null_pos + 1..];
210
211    let (obj_type, _size_str) = header
212        .split_once(' ')
213        .ok_or("Invalid Git object header format")?;
214
215    // Compute the original git hash for mapping
216    let mut sha1 = sha1::Sha1::new();
217    sha1.update(&raw);
218    let git_hash_computed = hex::encode(sha1.finalize());
219
220    let lit_obj = match obj_type {
221        "blob" => Object::Blob(Blob::new(content.to_vec())),
222        "tree" => {
223            let tree = parse_git_tree(content, hash_map)?;
224            Object::Tree(tree)
225        }
226        "commit" => {
227            let commit = parse_git_commit(content, hash_map)?;
228            Object::Commit(commit)
229        }
230        "tag" => {
231            // Treat as blob for now — full tag object parsing is complex
232            Object::Blob(Blob::new(content.to_vec()))
233        }
234        other => return Err(format!("Unknown object type: {}", other).into()),
235    };
236
237    let lit_hash = store.write(&lit_obj)?;
238    hash_map.insert(git_hash_computed, lit_hash);
239    Ok(())
240}
241
242/// Parse a Git tree object's binary content
243fn parse_git_tree(
244    content: &[u8],
245    hash_map: &HashMap<String, ObjectHash>,
246) -> Result<Tree, crate::errors::LitError> {
247    let mut tree = Tree::new();
248    let mut pos = 0;
249
250    while pos < content.len() {
251        // Format: "<mode> <name>\0<20-byte-sha1>"
252        let space_pos = content[pos..]
253            .iter()
254            .position(|&b| b == b' ')
255            .ok_or("Invalid tree entry: no space")?
256            + pos;
257        let null_pos = content[space_pos..]
258            .iter()
259            .position(|&b| b == 0)
260            .ok_or("Invalid tree entry: no null")?
261            + space_pos;
262
263        let mode = std::str::from_utf8(&content[pos..space_pos])
264            .map_err(|_| "Invalid mode in tree")?
265            .to_string();
266        let name = std::str::from_utf8(&content[space_pos + 1..null_pos])
267            .map_err(|_| "Invalid name in tree")?
268            .to_string();
269
270        if null_pos + 21 > content.len() {
271            break;
272        }
273        let sha1_bytes = &content[null_pos + 1..null_pos + 21];
274        let git_hash = hex::encode(sha1_bytes);
275
276        // Map to lit hash or use placeholder
277        let lit_hash = hash_map
278            .get(&git_hash)
279            .cloned()
280            .unwrap_or_else(|| ObjectHash::from_hex(format!("{:0>192}", git_hash)));
281
282        let obj_type = if mode.starts_with("40") {
283            "tree"
284        } else {
285            "blob"
286        }
287        .to_string();
288
289        tree.add_entry(mode, name, lit_hash, obj_type);
290        pos = null_pos + 21;
291    }
292
293    Ok(tree)
294}
295
296/// Parse a Git commit object's text content
297fn parse_git_commit(
298    content: &[u8],
299    hash_map: &HashMap<String, ObjectHash>,
300) -> Result<Commit, crate::errors::LitError> {
301    let text = std::str::from_utf8(content).map_err(|_| "Invalid commit: not UTF-8")?;
302
303    let mut tree_hash = String::new();
304    let mut parents = Vec::new();
305    let mut author = String::new();
306    let mut committer = String::new();
307    let mut timestamp: i64 = 0;
308    let mut in_body = false;
309    let mut message_lines = Vec::new();
310
311    for line in text.lines() {
312        if in_body {
313            message_lines.push(line);
314            continue;
315        }
316        if line.is_empty() {
317            in_body = true;
318            continue;
319        }
320        if let Some(rest) = line.strip_prefix("tree ") {
321            tree_hash = rest.trim().to_string();
322        } else if let Some(rest) = line.strip_prefix("parent ") {
323            parents.push(rest.trim().to_string());
324        } else if let Some(rest) = line.strip_prefix("author ") {
325            let (name, ts) = parse_git_ident(rest);
326            author = name;
327            timestamp = ts;
328        } else if let Some(rest) = line.strip_prefix("committer ") {
329            let (name, _) = parse_git_ident(rest);
330            committer = name;
331        }
332    }
333
334    // Map git hashes to lit hashes
335    let lit_tree = hash_map
336        .get(&tree_hash)
337        .cloned()
338        .unwrap_or_else(|| ObjectHash::from_hex(format!("{:0>192}", tree_hash)));
339
340    let lit_parents: Vec<ObjectHash> = parents
341        .iter()
342        .map(|p| {
343            hash_map
344                .get(p)
345                .cloned()
346                .unwrap_or_else(|| ObjectHash::from_hex(format!("{:0>192}", p)))
347        })
348        .collect();
349
350    Ok(Commit {
351        tree: lit_tree,
352        parents: lit_parents,
353        author,
354        committer,
355        timestamp,
356        message: message_lines.join("\n"),
357        pq_signature: None,
358        metadata: None,
359    })
360}
361
362/// Parse a Git identity line: "Name <email> timestamp timezone"
363fn parse_git_ident(ident: &str) -> (String, i64) {
364    // "John Doe <john@example.com> 1234567890 +0000"
365    if let Some(bracket_pos) = ident.rfind('>') {
366        let name_email = &ident[..=bracket_pos];
367        let rest = ident[bracket_pos + 1..].trim();
368        let timestamp = rest
369            .split_whitespace()
370            .next()
371            .and_then(|s| s.parse::<i64>().ok())
372            .unwrap_or(0);
373        (name_email.trim().to_string(), timestamp)
374    } else {
375        (ident.to_string(), 0)
376    }
377}
378
379/// Import objects from a Git pack file
380fn import_pack_file(
381    pack_path: &Path,
382    store: &ObjectStore,
383    hash_map: &mut HashMap<String, ObjectHash>,
384) -> Result<u64, crate::errors::LitError> {
385    let data = fs::read(pack_path).map_err(|e| format!("Failed to read pack: {}", e))?;
386
387    // Validate pack header: "PACK" magic, version 2/3, object count
388    if data.len() < 12 {
389        return Err("Pack file too small".into());
390    }
391    if &data[0..4] != b"PACK" {
392        return Err("Invalid pack file magic".into());
393    }
394    let version = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
395    if version != 2 && version != 3 {
396        return Err(format!("Unsupported pack version: {}", version).into());
397    }
398    let num_objects = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
399    let mut imported = 0u64;
400
401    // Parse pack entries
402    let mut pos = 12;
403    for _ in 0..num_objects {
404        if pos >= data.len() - 20 {
405            break;
406        }
407        match parse_pack_entry(&data, &mut pos, store, hash_map) {
408            Ok(_) => imported += 1,
409            Err(e) => {
410                eprintln!("Warning: skipping pack entry: {}", e);
411                break;
412            }
413        }
414    }
415
416    Ok(imported)
417}
418
419/// Parse a single pack entry
420fn parse_pack_entry(
421    data: &[u8],
422    pos: &mut usize,
423    store: &ObjectStore,
424    hash_map: &mut HashMap<String, ObjectHash>,
425) -> Result<(), crate::errors::LitError> {
426    if *pos >= data.len() {
427        return Err("Unexpected end of pack".into());
428    }
429
430    // Read type and size from variable-length header
431    let mut byte = data[*pos];
432    let obj_type = (byte >> 4) & 0x07;
433    let mut _size: u64 = (byte & 0x0f) as u64;
434    let mut shift = 4;
435    *pos += 1;
436
437    while byte & 0x80 != 0 {
438        if *pos >= data.len() {
439            return Err("Truncated pack header".into());
440        }
441        byte = data[*pos];
442        _size |= ((byte & 0x7f) as u64) << shift;
443        shift += 7;
444        *pos += 1;
445    }
446
447    match obj_type {
448        1..=4 => {
449            // Regular object types: commit, tree, blob, tag
450            let mut decoder = flate2::read::ZlibDecoder::new(&data[*pos..]);
451            let mut content = Vec::new();
452            decoder
453                .read_to_end(&mut content)
454                .map_err(|e| format!("Decompress error: {}", e))?;
455            *pos += decoder.total_in() as usize;
456
457            // Compute git hash
458            let type_name = match obj_type {
459                1 => "commit",
460                2 => "tree",
461                3 => "blob",
462                4 => "tag",
463                _ => unreachable!(),
464            };
465            let header = format!("{} {}\0", type_name, content.len());
466            let mut sha1 = sha1::Sha1::new();
467            sha1.update(header.as_bytes());
468            sha1.update(&content);
469            let git_hash = hex::encode(sha1.finalize());
470
471            let lit_obj = match obj_type {
472                3 => Object::Blob(Blob::new(content)),
473                2 => {
474                    let tree = parse_git_tree(&content, hash_map)?;
475                    Object::Tree(tree)
476                }
477                1 => {
478                    let commit = parse_git_commit(&content, hash_map)?;
479                    Object::Commit(commit)
480                }
481                _ => Object::Blob(Blob::new(content)),
482            };
483
484            let lit_hash = store.write(&lit_obj)?;
485            hash_map.insert(git_hash, lit_hash);
486        }
487        6 => {
488            // OFS_DELTA — skip for now
489            // Read negative offset
490            let mut byte = data[*pos];
491            let mut _offset: u64 = (byte & 0x7f) as u64;
492            *pos += 1;
493            while byte & 0x80 != 0 {
494                byte = data[*pos];
495                _offset = ((_offset + 1) << 7) | (byte & 0x7f) as u64;
496                *pos += 1;
497            }
498            // Skip compressed delta data
499            let mut decoder = flate2::read::ZlibDecoder::new(&data[*pos..]);
500            let mut delta = Vec::new();
501            let _ = decoder.read_to_end(&mut delta);
502            *pos += decoder.total_in() as usize;
503        }
504        7 => {
505            // REF_DELTA
506            if *pos + 20 > data.len() {
507                return Err("Truncated ref delta".into());
508            }
509            *pos += 20; // Skip base hash
510            let mut decoder = flate2::read::ZlibDecoder::new(&data[*pos..]);
511            let mut delta = Vec::new();
512            let _ = decoder.read_to_end(&mut delta);
513            *pos += decoder.total_in() as usize;
514        }
515        _ => {
516            return Err(format!("Unknown pack object type: {}", obj_type).into());
517        }
518    }
519
520    Ok(())
521}