Skip to main content

lit/commands/
export_git.rs

1use crate::core::{find_repo_root, list_refs, read_head, Object, ObjectHash};
2use crate::response::ExportGitResponse;
3use crate::storage::ObjectStore;
4use sha1::Digest as Sha1Digest;
5use std::collections::{HashMap, HashSet};
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10/// Export a Lit repository to Git format.
11/// Converts Lit objects (SHA3-512 + BLAKE3) back to Git objects (SHA-1).
12pub fn execute(destination: String) -> Result<ExportGitResponse, crate::errors::LitError> {
13    let repo_root = find_repo_root()?;
14    let dest_path = PathBuf::from(&destination);
15
16    // Create bare Git repository structure
17    fs::create_dir_all(&dest_path).map_err(|e| format!("Failed to create destination: {}", e))?;
18    for dir in &["objects", "refs/heads", "refs/tags"] {
19        fs::create_dir_all(dest_path.join(dir))
20            .map_err(|e| format!("Failed to create {}: {}", dir, e))?;
21    }
22
23    // Write HEAD
24    fs::write(dest_path.join("HEAD"), "ref: refs/heads/main\n")
25        .map_err(|e| format!("Failed to write HEAD: {}", e))?;
26
27    let store = ObjectStore::new(&repo_root);
28    let mut hash_map: HashMap<String, String> = HashMap::new(); // lit_hash -> git_hash
29    let mut objects_exported = 0u64;
30    let mut refs_exported = 0u64;
31
32    // Export all objects.
33    //
34    // Git trees, commits and tags embed the SHA-1 of the objects they point at,
35    // so an object can only be serialized once everything it references has
36    // been written and its Lit -> Git mapping recorded. `list()` returns
37    // objects in filesystem order, which puts no such guarantee on the caller,
38    // so walk the object graph in dependency order instead.
39    let all_objects = store
40        .list()
41        .map_err(|e| format!("Failed to list objects: {}", e))?;
42
43    let mut scheduled: HashSet<String> = HashSet::new();
44    for lit_hash in &all_objects {
45        objects_exported +=
46            export_subgraph(&store, lit_hash, &dest_path, &mut hash_map, &mut scheduled)?;
47    }
48
49    // Export refs
50    // Branches
51    let branches =
52        list_refs(&repo_root, "heads").map_err(|e| format!("Failed to list branches: {}", e))?;
53    for branch_ref in &branches {
54        if let Some(git_hash) = hash_map.get(&branch_ref.hash) {
55            let ref_path = dest_path.join("refs").join("heads").join(&branch_ref.name);
56            if let Some(parent) = ref_path.parent() {
57                let _ = fs::create_dir_all(parent);
58            }
59            fs::write(&ref_path, format!("{}\n", git_hash))
60                .map_err(|e| format!("Failed to write ref: {}", e))?;
61            refs_exported += 1;
62        }
63    }
64
65    // Tags
66    let tags = list_refs(&repo_root, "tags").map_err(|e| format!("Failed to list tags: {}", e))?;
67    for tag_ref in &tags {
68        if let Some(git_hash) = hash_map.get(&tag_ref.hash) {
69            let ref_path = dest_path.join("refs").join("tags").join(&tag_ref.name);
70            if let Some(parent) = ref_path.parent() {
71                let _ = fs::create_dir_all(parent);
72            }
73            fs::write(&ref_path, format!("{}\n", git_hash))
74                .map_err(|e| format!("Failed to write ref: {}", e))?;
75            refs_exported += 1;
76        }
77    }
78
79    // Remote-tracking refs
80    let remotes = list_refs(&repo_root, "remotes").unwrap_or_default();
81    for remote_ref in &remotes {
82        if let Some(git_hash) = hash_map.get(&remote_ref.hash) {
83            let ref_path = dest_path
84                .join("refs")
85                .join("remotes")
86                .join(&remote_ref.name);
87            if let Some(parent) = ref_path.parent() {
88                let _ = fs::create_dir_all(parent);
89            }
90            fs::write(&ref_path, format!("{}\n", git_hash))
91                .map_err(|e| format!("Failed to write remote ref: {}", e))?;
92            refs_exported += 1;
93        }
94    }
95
96    // Write packed-refs for efficiency (Git optimization)
97    let mut packed_refs = String::from("# pack-refs with: peeled fully-peeled sorted \n");
98    let mut has_packed = false;
99    for branch_ref in &branches {
100        if let Some(git_hash) = hash_map.get(&branch_ref.hash) {
101            packed_refs.push_str(&format!("{} refs/heads/{}\n", git_hash, branch_ref.name));
102            has_packed = true;
103        }
104    }
105    for tag_ref in &tags {
106        if let Some(git_hash) = hash_map.get(&tag_ref.hash) {
107            packed_refs.push_str(&format!("{} refs/tags/{}\n", git_hash, tag_ref.name));
108            has_packed = true;
109        }
110    }
111    if has_packed {
112        fs::write(dest_path.join("packed-refs"), &packed_refs)
113            .map_err(|e| format!("Failed to write packed-refs: {}", e))?;
114    }
115
116    // Update HEAD to point to current branch
117    let head = read_head(&repo_root).unwrap_or_else(|_| "main".to_string());
118    if head.contains('/') || head.len() > 50 {
119        // Detached HEAD — try to map hash
120        if let Some(git_hash) = hash_map.get(&head) {
121            fs::write(dest_path.join("HEAD"), format!("{}\n", git_hash))
122                .map_err(|e| format!("Failed to write HEAD: {}", e))?;
123        }
124    } else {
125        fs::write(
126            dest_path.join("HEAD"),
127            format!("ref: refs/heads/{}\n", head),
128        )
129        .map_err(|e| format!("Failed to write HEAD: {}", e))?;
130    }
131
132    // Copy .litignore as .gitignore
133    let litignore = repo_root.join(".litignore");
134    let gitignore = dest_path.parent().unwrap_or(&dest_path).join(".gitignore");
135    if litignore.exists() && !gitignore.exists() {
136        let _ = fs::copy(&litignore, &gitignore);
137    }
138
139    Ok(ExportGitResponse {
140        destination: destination.clone(),
141        objects_exported,
142        refs_exported,
143        message: format!(
144            "Exported {} objects and {} refs to Git repository",
145            objects_exported, refs_exported
146        ),
147    })
148}
149
150/// A step in the iterative post-order walk of the Lit object graph.
151enum Step {
152    /// Expand this object's dependencies before writing it.
153    Visit(ObjectHash),
154    /// Every dependency has been written; serialize and write this object.
155    Emit(ObjectHash),
156}
157
158/// Export `root` and everything it references, dependencies first.
159///
160/// Returns the number of objects written. Objects already exported are
161/// skipped, so this can be driven over every hash in the store without
162/// writing anything twice. The walk is iterative rather than recursive
163/// because commit chains are as deep as the repository is long.
164fn export_subgraph(
165    store: &ObjectStore,
166    root: &ObjectHash,
167    dest: &Path,
168    hash_map: &mut HashMap<String, String>,
169    scheduled: &mut HashSet<String>,
170) -> Result<u64, crate::errors::LitError> {
171    let mut exported = 0u64;
172    let mut stack = vec![Step::Visit(root.clone())];
173
174    while let Some(step) = stack.pop() {
175        match step {
176            Step::Visit(hash) => {
177                if hash_map.contains_key(hash.as_str())
178                    || !scheduled.insert(hash.as_str().to_string())
179                {
180                    continue;
181                }
182                let deps = match dependencies_of(store, &hash) {
183                    Ok(deps) => deps,
184                    Err(e) => {
185                        // An unreadable object stays unmapped. Anything
186                        // referencing it now fails loudly in the serializers
187                        // below instead of being written against a made-up
188                        // hash, which would corrupt the exported repository.
189                        eprintln!("Warning: skipping object {}: {}", hash.short(), e);
190                        continue;
191                    }
192                };
193                stack.push(Step::Emit(hash));
194                for dep in deps {
195                    stack.push(Step::Visit(dep));
196                }
197            }
198            Step::Emit(hash) => {
199                export_object(store, &hash, dest, hash_map)?;
200                exported += 1;
201            }
202        }
203    }
204
205    Ok(exported)
206}
207
208/// The objects a given object references, all of which must be exported first.
209fn dependencies_of(
210    store: &ObjectStore,
211    hash: &ObjectHash,
212) -> Result<Vec<ObjectHash>, crate::errors::LitError> {
213    Ok(match store.read(hash)? {
214        Object::Blob(_) => Vec::new(),
215        Object::Tree(tree) => tree.entries.iter().map(|e| e.hash.clone()).collect(),
216        Object::Commit(commit) => std::iter::once(commit.tree.clone())
217            .chain(commit.parents.iter().cloned())
218            .collect(),
219        Object::Tag(tag) => vec![tag.target.clone()],
220    })
221}
222
223/// Look up the Git hash that a referenced Lit object was exported as.
224///
225/// A miss means the object was never written. Substituting a placeholder here
226/// would produce a Git repository whose trees and commits point at objects
227/// that do not exist, so an incomplete graph is reported rather than encoded.
228fn lookup_git_hash(
229    hash_map: &HashMap<String, String>,
230    lit_hash: &ObjectHash,
231    context: &str,
232) -> Result<String, crate::errors::LitError> {
233    hash_map.get(lit_hash.as_str()).cloned().ok_or_else(|| {
234        crate::errors::LitError::general(format!(
235            "Cannot export {}: referenced object {} is missing from this repository",
236            context,
237            lit_hash.short()
238        ))
239    })
240}
241
242/// Decode a 40-character Git SHA-1 hex string into its 20 raw bytes.
243fn decode_sha1(git_hex: &str) -> Result<Vec<u8>, crate::errors::LitError> {
244    let bytes = hex::decode(git_hex).map_err(|e| format!("Invalid Git hash hex: {}", e))?;
245    if bytes.len() != 20 {
246        return Err(format!(
247            "Expected a 20-byte Git SHA-1, got {} bytes from '{}'",
248            bytes.len(),
249            git_hex
250        )
251        .into());
252    }
253    Ok(bytes)
254}
255
256/// Export a single Lit object to Git format
257fn export_object(
258    store: &ObjectStore,
259    lit_hash: &ObjectHash,
260    dest: &Path,
261    hash_map: &mut HashMap<String, String>,
262) -> Result<(), crate::errors::LitError> {
263    let obj = store.read(lit_hash)?;
264
265    let (type_name, content) = match &obj {
266        Object::Blob(blob) => ("blob", blob.content.clone()),
267        Object::Tree(tree) => {
268            let content = serialize_git_tree(tree, hash_map)?;
269            ("tree", content)
270        }
271        Object::Commit(commit) => {
272            let content = serialize_git_commit(commit, hash_map)?;
273            ("commit", content)
274        }
275        Object::Tag(tag) => {
276            let content = serialize_git_tag(tag, hash_map)?;
277            ("tag", content)
278        }
279    };
280
281    // Compute Git SHA-1
282    let header = format!("{} {}\0", type_name, content.len());
283    let mut sha1 = sha1::Sha1::new();
284    sha1.update(header.as_bytes());
285    sha1.update(&content);
286    let git_hash = hex::encode(sha1.finalize());
287
288    // Write as loose Git object
289    let obj_dir = dest.join("objects").join(&git_hash[..2]);
290    let obj_path = obj_dir.join(&git_hash[2..]);
291    fs::create_dir_all(&obj_dir).map_err(|e| format!("Failed to create object dir: {}", e))?;
292
293    let mut raw = Vec::new();
294    raw.extend_from_slice(header.as_bytes());
295    raw.extend_from_slice(&content);
296
297    let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
298    encoder
299        .write_all(&raw)
300        .map_err(|e| format!("Compress error: {}", e))?;
301    let compressed = encoder
302        .finish()
303        .map_err(|e| format!("Compress finish error: {}", e))?;
304
305    fs::write(&obj_path, &compressed).map_err(|e| format!("Failed to write object: {}", e))?;
306
307    hash_map.insert(lit_hash.as_str().to_string(), git_hash);
308    Ok(())
309}
310
311/// Serialize a Lit tree into Git tree binary format
312fn serialize_git_tree(
313    tree: &crate::core::Tree,
314    hash_map: &HashMap<String, String>,
315) -> Result<Vec<u8>, crate::errors::LitError> {
316    let mut buf = Vec::new();
317    for entry in &tree.entries {
318        // mode SP name NUL sha1-bytes
319        buf.extend_from_slice(entry.mode.as_bytes());
320        buf.push(b' ');
321        buf.extend_from_slice(entry.name.as_bytes());
322        buf.push(0);
323
324        // Git stores the referenced object's SHA-1 as 20 raw bytes.
325        let git_hex = lookup_git_hash(
326            hash_map,
327            &entry.hash,
328            &format!("tree entry '{}'", entry.name),
329        )?;
330        buf.extend_from_slice(&decode_sha1(&git_hex)?);
331    }
332    Ok(buf)
333}
334
335/// Serialize a Lit commit into Git commit text format
336fn serialize_git_commit(
337    commit: &crate::core::Commit,
338    hash_map: &HashMap<String, String>,
339) -> Result<Vec<u8>, crate::errors::LitError> {
340    let mut lines = Vec::new();
341
342    // tree
343    let tree_hash = lookup_git_hash(hash_map, &commit.tree, "commit tree")?;
344    lines.push(format!("tree {}", tree_hash));
345
346    // parents
347    for parent in &commit.parents {
348        let parent_hash = lookup_git_hash(hash_map, parent, "commit parent")?;
349        lines.push(format!("parent {}", parent_hash));
350    }
351
352    // author and committer. A commit imported from Git carries the offset it
353    // was written with; Lit's own commits are UTC.
354    let timezone = commit.timezone.as_deref().unwrap_or("+0000");
355    lines.push(format!(
356        "author {} {} {}",
357        commit.author, commit.timestamp, timezone
358    ));
359    lines.push(format!(
360        "committer {} {} {}",
361        commit.committer, commit.timestamp, timezone
362    ));
363
364    // Lit metadata as Git notes (appended to message)
365    let mut message = commit.message.clone();
366    if let Some(ref meta) = commit.metadata {
367        message.push_str(&format!("\n\nLit-Metadata: {}", meta));
368    }
369
370    lines.push(String::new()); // empty line before message
371    lines.push(message);
372
373    Ok(lines.join("\n").into_bytes())
374}
375
376/// Serialize a Lit tag into Git tag text format
377fn serialize_git_tag(
378    tag: &crate::core::Tag,
379    hash_map: &HashMap<String, String>,
380) -> Result<Vec<u8>, crate::errors::LitError> {
381    let target_hash = lookup_git_hash(hash_map, &tag.target, "tag target")?;
382
383    // As for commits, a tag imported from Git carries the offset it was
384    // written with; Lit's own tags are UTC.
385    let timezone = tag.timezone.as_deref().unwrap_or("+0000");
386
387    let mut lines = Vec::new();
388    lines.push(format!("object {}", target_hash));
389    lines.push(format!("type {}", tag.target_type));
390    lines.push(format!("tag {}", tag.tag_name));
391    lines.push(format!(
392        "tagger {} {} {}",
393        tag.tagger, tag.timestamp, timezone
394    ));
395    lines.push(String::new());
396    lines.push(tag.message.clone());
397
398    Ok(lines.join("\n").into_bytes())
399}