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;
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    let all_objects = store
34        .list()
35        .map_err(|e| format!("Failed to list objects: {}", e))?;
36
37    for lit_hash in &all_objects {
38        match export_object(&store, lit_hash, &dest_path, &mut hash_map) {
39            Ok(_) => objects_exported += 1,
40            Err(e) => {
41                eprintln!("Warning: skipping object {}: {}", lit_hash.short(), e);
42            }
43        }
44    }
45
46    // Export refs
47    // Branches
48    let branches =
49        list_refs(&repo_root, "heads").map_err(|e| format!("Failed to list branches: {}", e))?;
50    for branch_ref in &branches {
51        if let Some(git_hash) = hash_map.get(&branch_ref.hash) {
52            let ref_path = dest_path.join("refs").join("heads").join(&branch_ref.name);
53            if let Some(parent) = ref_path.parent() {
54                let _ = fs::create_dir_all(parent);
55            }
56            fs::write(&ref_path, format!("{}\n", git_hash))
57                .map_err(|e| format!("Failed to write ref: {}", e))?;
58            refs_exported += 1;
59        }
60    }
61
62    // Tags
63    let tags = list_refs(&repo_root, "tags").map_err(|e| format!("Failed to list tags: {}", e))?;
64    for tag_ref in &tags {
65        if let Some(git_hash) = hash_map.get(&tag_ref.hash) {
66            let ref_path = dest_path.join("refs").join("tags").join(&tag_ref.name);
67            if let Some(parent) = ref_path.parent() {
68                let _ = fs::create_dir_all(parent);
69            }
70            fs::write(&ref_path, format!("{}\n", git_hash))
71                .map_err(|e| format!("Failed to write ref: {}", e))?;
72            refs_exported += 1;
73        }
74    }
75
76    // Remote-tracking refs
77    let remotes = list_refs(&repo_root, "remotes").unwrap_or_default();
78    for remote_ref in &remotes {
79        if let Some(git_hash) = hash_map.get(&remote_ref.hash) {
80            let ref_path = dest_path
81                .join("refs")
82                .join("remotes")
83                .join(&remote_ref.name);
84            if let Some(parent) = ref_path.parent() {
85                let _ = fs::create_dir_all(parent);
86            }
87            fs::write(&ref_path, format!("{}\n", git_hash))
88                .map_err(|e| format!("Failed to write remote ref: {}", e))?;
89            refs_exported += 1;
90        }
91    }
92
93    // Write packed-refs for efficiency (Git optimization)
94    let mut packed_refs = String::from("# pack-refs with: peeled fully-peeled sorted \n");
95    let mut has_packed = false;
96    for branch_ref in &branches {
97        if let Some(git_hash) = hash_map.get(&branch_ref.hash) {
98            packed_refs.push_str(&format!("{} refs/heads/{}\n", git_hash, branch_ref.name));
99            has_packed = true;
100        }
101    }
102    for tag_ref in &tags {
103        if let Some(git_hash) = hash_map.get(&tag_ref.hash) {
104            packed_refs.push_str(&format!("{} refs/tags/{}\n", git_hash, tag_ref.name));
105            has_packed = true;
106        }
107    }
108    if has_packed {
109        fs::write(dest_path.join("packed-refs"), &packed_refs)
110            .map_err(|e| format!("Failed to write packed-refs: {}", e))?;
111    }
112
113    // Update HEAD to point to current branch
114    let head = read_head(&repo_root).unwrap_or_else(|_| "main".to_string());
115    if head.contains('/') || head.len() > 50 {
116        // Detached HEAD — try to map hash
117        if let Some(git_hash) = hash_map.get(&head) {
118            fs::write(dest_path.join("HEAD"), format!("{}\n", git_hash))
119                .map_err(|e| format!("Failed to write HEAD: {}", e))?;
120        }
121    } else {
122        fs::write(
123            dest_path.join("HEAD"),
124            format!("ref: refs/heads/{}\n", head),
125        )
126        .map_err(|e| format!("Failed to write HEAD: {}", e))?;
127    }
128
129    // Copy .litignore as .gitignore
130    let litignore = repo_root.join(".litignore");
131    let gitignore = dest_path.parent().unwrap_or(&dest_path).join(".gitignore");
132    if litignore.exists() && !gitignore.exists() {
133        let _ = fs::copy(&litignore, &gitignore);
134    }
135
136    Ok(ExportGitResponse {
137        destination: destination.clone(),
138        objects_exported,
139        refs_exported,
140        message: format!(
141            "Exported {} objects and {} refs to Git repository",
142            objects_exported, refs_exported
143        ),
144    })
145}
146
147/// Export a single Lit object to Git format
148fn export_object(
149    store: &ObjectStore,
150    lit_hash: &ObjectHash,
151    dest: &Path,
152    hash_map: &mut HashMap<String, String>,
153) -> Result<(), crate::errors::LitError> {
154    let obj = store.read(lit_hash)?;
155
156    let (type_name, content) = match &obj {
157        Object::Blob(blob) => ("blob", blob.content.clone()),
158        Object::Tree(tree) => {
159            let content = serialize_git_tree(tree, hash_map)?;
160            ("tree", content)
161        }
162        Object::Commit(commit) => {
163            let content = serialize_git_commit(commit, hash_map)?;
164            ("commit", content)
165        }
166        Object::Tag(tag) => {
167            let content = serialize_git_tag(tag, hash_map)?;
168            ("tag", content)
169        }
170    };
171
172    // Compute Git SHA-1
173    let header = format!("{} {}\0", type_name, content.len());
174    let mut sha1 = sha1::Sha1::new();
175    sha1.update(header.as_bytes());
176    sha1.update(&content);
177    let git_hash = hex::encode(sha1.finalize());
178
179    // Write as loose Git object
180    let obj_dir = dest.join("objects").join(&git_hash[..2]);
181    let obj_path = obj_dir.join(&git_hash[2..]);
182    fs::create_dir_all(&obj_dir).map_err(|e| format!("Failed to create object dir: {}", e))?;
183
184    let mut raw = Vec::new();
185    raw.extend_from_slice(header.as_bytes());
186    raw.extend_from_slice(&content);
187
188    let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
189    encoder
190        .write_all(&raw)
191        .map_err(|e| format!("Compress error: {}", e))?;
192    let compressed = encoder
193        .finish()
194        .map_err(|e| format!("Compress finish error: {}", e))?;
195
196    fs::write(&obj_path, &compressed).map_err(|e| format!("Failed to write object: {}", e))?;
197
198    hash_map.insert(lit_hash.as_str().to_string(), git_hash);
199    Ok(())
200}
201
202/// Serialize a Lit tree into Git tree binary format
203fn serialize_git_tree(
204    tree: &crate::core::Tree,
205    hash_map: &HashMap<String, String>,
206) -> Result<Vec<u8>, crate::errors::LitError> {
207    let mut buf = Vec::new();
208    for entry in &tree.entries {
209        // mode SP name NUL sha1-bytes
210        buf.extend_from_slice(entry.mode.as_bytes());
211        buf.push(b' ');
212        buf.extend_from_slice(entry.name.as_bytes());
213        buf.push(0);
214
215        // Convert lit hash to git hash, take first 40 chars (SHA-1 hex), decode to 20 bytes
216        let git_hex = if let Some(gh) = hash_map.get(entry.hash.as_str()) {
217            gh.clone()
218        } else {
219            // Use first 40 chars of lit hash as placeholder
220            entry.hash.as_str()[..40.min(entry.hash.len())].to_string()
221        };
222        let sha1_bytes = hex::decode(&git_hex).map_err(|e| format!("Invalid hash hex: {}", e))?;
223        buf.extend_from_slice(&sha1_bytes[..20.min(sha1_bytes.len())]);
224        // Pad if SHA-1 hash is shorter than 20 bytes
225        while sha1_bytes.len() < 20 && buf.len() < (buf.len() + 20 - sha1_bytes.len()) {
226            buf.push(0);
227        }
228    }
229    Ok(buf)
230}
231
232/// Serialize a Lit commit into Git commit text format
233fn serialize_git_commit(
234    commit: &crate::core::Commit,
235    hash_map: &HashMap<String, String>,
236) -> Result<Vec<u8>, crate::errors::LitError> {
237    let mut lines = Vec::new();
238
239    // tree
240    let tree_hash = hash_map
241        .get(commit.tree.as_str())
242        .cloned()
243        .unwrap_or_else(|| commit.tree.as_str()[..40.min(commit.tree.len())].to_string());
244    lines.push(format!("tree {}", tree_hash));
245
246    // parents
247    for parent in &commit.parents {
248        let parent_hash = hash_map
249            .get(parent.as_str())
250            .cloned()
251            .unwrap_or_else(|| parent.as_str()[..40.min(parent.len())].to_string());
252        lines.push(format!("parent {}", parent_hash));
253    }
254
255    // author and committer
256    lines.push(format!(
257        "author {} {} +0000",
258        commit.author, commit.timestamp
259    ));
260    lines.push(format!(
261        "committer {} {} +0000",
262        commit.committer, commit.timestamp
263    ));
264
265    // Lit metadata as Git notes (appended to message)
266    let mut message = commit.message.clone();
267    if let Some(ref meta) = commit.metadata {
268        message.push_str(&format!("\n\nLit-Metadata: {}", meta));
269    }
270
271    lines.push(String::new()); // empty line before message
272    lines.push(message);
273
274    Ok(lines.join("\n").into_bytes())
275}
276
277/// Serialize a Lit tag into Git tag text format
278fn serialize_git_tag(
279    tag: &crate::core::Tag,
280    hash_map: &HashMap<String, String>,
281) -> Result<Vec<u8>, crate::errors::LitError> {
282    let target_hash = hash_map
283        .get(tag.target.as_str())
284        .cloned()
285        .unwrap_or_else(|| tag.target.as_str()[..40.min(tag.target.len())].to_string());
286
287    let mut lines = Vec::new();
288    lines.push(format!("object {}", target_hash));
289    lines.push(format!("type {}", tag.target_type));
290    lines.push(format!("tag {}", tag.tag_name));
291    lines.push(format!("tagger {} {} +0000", tag.tagger, tag.timestamp));
292    lines.push(String::new());
293    lines.push(tag.message.clone());
294
295    Ok(lines.join("\n").into_bytes())
296}