Skip to main content

lit/commands/
gc.rs

1use crate::core::{find_repo_root, Object, ObjectHash};
2use crate::response::GcResponse;
3use crate::storage::ObjectStore;
4use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
5use crc32fast::Hasher as Crc32Hasher;
6use flate2::read::ZlibDecoder;
7use flate2::write::ZlibEncoder;
8use flate2::Compression;
9use std::collections::HashMap;
10use std::fs;
11use std::io::{Cursor, Read, Write};
12use std::path::{Path, PathBuf};
13
14/// Magic header for Lit pack files
15const PACK_MAGIC: &[u8; 4] = b"LITP";
16/// Pack version
17const PACK_VERSION: u32 = 1;
18
19/// Index magic header
20const INDEX_MAGIC: &[u8; 4] = b"LITI";
21/// Index version
22const INDEX_VERSION: u32 = 1;
23
24/// Pack file entry - one object in the pack
25#[derive(Debug, Clone)]
26pub struct PackEntry {
27    /// Object type: 1=blob, 2=tree, 3=commit, 4=tag
28    pub obj_type: u8,
29    /// Uncompressed size
30    pub size: u64,
31    /// Compressed data
32    pub data: Vec<u8>,
33    /// CRC32 of the compressed data
34    pub crc32: u32,
35}
36
37/// Pack index entry - maps hash to offset in pack
38#[derive(Debug, Clone)]
39pub struct PackIndexEntry {
40    pub hash: ObjectHash,
41    pub offset: u64,
42    pub crc32: u32,
43}
44
45/// Write a pack file from a set of objects
46pub fn write_pack(
47    objects: &[(ObjectHash, Object)],
48    pack_path: &Path,
49) -> Result<Vec<PackIndexEntry>, crate::errors::LitError> {
50    let mut buf: Vec<u8> = Vec::new();
51    let mut index_entries = Vec::new();
52
53    // Header: magic + version + count
54    buf.extend_from_slice(PACK_MAGIC);
55    buf.write_u32::<BigEndian>(PACK_VERSION)
56        .map_err(|e| format!("Write error: {}", e))?;
57    buf.write_u32::<BigEndian>(objects.len() as u32)
58        .map_err(|e| format!("Write error: {}", e))?;
59
60    for (hash, obj) in objects {
61        let offset = buf.len() as u64;
62
63        let type_byte = match obj {
64            Object::Blob(_) => 1u8,
65            Object::Tree(_) => 2u8,
66            Object::Commit(_) => 3u8,
67            Object::Tag(_) => 4u8,
68        };
69
70        let raw = obj.to_bytes();
71        let uncompressed_size = raw.len() as u64;
72
73        // Compress data
74        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
75        encoder
76            .write_all(&raw)
77            .map_err(|e| format!("Compress error: {}", e))?;
78        let compressed = encoder
79            .finish()
80            .map_err(|e| format!("Compress finish error: {}", e))?;
81
82        // CRC32 of compressed data
83        let mut crc32 = Crc32Hasher::new();
84        crc32.update(&compressed);
85        let crc_val = crc32.finalize();
86
87        // Write entry header: type(1) + uncompressed_size(8) + compressed_size(8)
88        buf.push(type_byte);
89        buf.write_u64::<BigEndian>(uncompressed_size)
90            .map_err(|e| format!("Write error: {}", e))?;
91        buf.write_u64::<BigEndian>(compressed.len() as u64)
92            .map_err(|e| format!("Write error: {}", e))?;
93        buf.extend_from_slice(&compressed);
94
95        index_entries.push(PackIndexEntry {
96            hash: hash.clone(),
97            offset,
98            crc32: crc_val,
99        });
100    }
101
102    fs::write(pack_path, &buf).map_err(|e| format!("Failed to write pack: {}", e))?;
103
104    Ok(index_entries)
105}
106
107/// Write a pack index file
108pub fn write_pack_index(
109    entries: &[PackIndexEntry],
110    index_path: &Path,
111) -> Result<(), crate::errors::LitError> {
112    let mut buf: Vec<u8> = Vec::new();
113
114    // Header
115    buf.extend_from_slice(INDEX_MAGIC);
116    buf.write_u32::<BigEndian>(INDEX_VERSION)
117        .map_err(|e| format!("Write error: {}", e))?;
118    buf.write_u32::<BigEndian>(entries.len() as u32)
119        .map_err(|e| format!("Write error: {}", e))?;
120
121    // Write sorted entries: hash_len(4) + hash + offset(8) + crc32(4)
122    let mut sorted = entries.to_vec();
123    sorted.sort_by(|a, b| a.hash.as_str().cmp(b.hash.as_str()));
124
125    for entry in &sorted {
126        let hash_bytes = entry.hash.as_str().as_bytes();
127        buf.write_u32::<BigEndian>(hash_bytes.len() as u32)
128            .map_err(|e| format!("Write error: {}", e))?;
129        buf.extend_from_slice(hash_bytes);
130        buf.write_u64::<BigEndian>(entry.offset)
131            .map_err(|e| format!("Write error: {}", e))?;
132        buf.write_u32::<BigEndian>(entry.crc32)
133            .map_err(|e| format!("Write error: {}", e))?;
134    }
135
136    fs::write(index_path, &buf).map_err(|e| format!("Failed to write index: {}", e))?;
137    Ok(())
138}
139
140/// Read a single object from a pack file by offset
141pub fn read_pack_object(pack_path: &Path, offset: u64) -> Result<Object, crate::errors::LitError> {
142    let pack_data = fs::read(pack_path).map_err(|e| format!("Failed to read pack: {}", e))?;
143    let mut cursor = Cursor::new(&pack_data);
144    cursor.set_position(offset);
145
146    // Read type
147    let _type_byte = cursor.read_u8().map_err(|e| format!("Read error: {}", e))?;
148
149    // Read sizes
150    let _uncompressed_size = cursor
151        .read_u64::<BigEndian>()
152        .map_err(|e| format!("Read error: {}", e))?;
153    let compressed_size = cursor
154        .read_u64::<BigEndian>()
155        .map_err(|e| format!("Read error: {}", e))?;
156
157    // Read compressed data
158    let pos = cursor.position() as usize;
159    let end = pos + compressed_size as usize;
160    if end > pack_data.len() {
161        return Err("Pack data truncated".into());
162    }
163    let compressed = &pack_data[pos..end];
164
165    // Decompress
166    let mut decoder = ZlibDecoder::new(compressed);
167    let mut raw = Vec::new();
168    decoder
169        .read_to_end(&mut raw)
170        .map_err(|e| format!("Decompress error: {}", e))?;
171
172    Object::from_bytes(&raw).map_err(Into::into)
173}
174
175/// Load a pack index and build a hash→(pack_path, offset) map
176pub fn load_pack_index(index_path: &Path) -> Result<HashMap<String, (PathBuf, u64)>, String> {
177    let data = fs::read(index_path).map_err(|e| format!("Failed to read index: {}", e))?;
178    let mut cursor = Cursor::new(&data);
179
180    // Verify header
181    let mut magic = [0u8; 4];
182    cursor
183        .read_exact(&mut magic)
184        .map_err(|e| format!("Read error: {}", e))?;
185    if &magic != INDEX_MAGIC {
186        return Err("Invalid pack index magic".into());
187    }
188
189    let _version = cursor
190        .read_u32::<BigEndian>()
191        .map_err(|e| format!("Read error: {}", e))?;
192    let count = cursor
193        .read_u32::<BigEndian>()
194        .map_err(|e| format!("Read error: {}", e))?;
195
196    let pack_path = index_path.with_extension("pack");
197    let mut map = HashMap::new();
198
199    for _ in 0..count {
200        let hash_len = cursor
201            .read_u32::<BigEndian>()
202            .map_err(|e| format!("Read error: {}", e))? as usize;
203
204        let pos = cursor.position() as usize;
205        if pos + hash_len > data.len() {
206            return Err("Index data truncated".into());
207        }
208        let hash_str = String::from_utf8(data[pos..pos + hash_len].to_vec())
209            .map_err(|e| format!("Invalid hash UTF-8: {}", e))?;
210        cursor.set_position((pos + hash_len) as u64);
211
212        let offset = cursor
213            .read_u64::<BigEndian>()
214            .map_err(|e| format!("Read error: {}", e))?;
215        let _crc32 = cursor
216            .read_u32::<BigEndian>()
217            .map_err(|e| format!("Read error: {}", e))?;
218
219        map.insert(hash_str, (pack_path.clone(), offset));
220    }
221
222    Ok(map)
223}
224
225/// Execute the `gc` (garbage collection) command.
226/// Packs all loose objects into a single pack file and removes the loose files.
227pub fn execute() -> Result<GcResponse, crate::errors::LitError> {
228    let repo_root = find_repo_root()?;
229    let store = ObjectStore::new(&repo_root);
230
231    let all_hashes = store
232        .list()
233        .map_err(|e| format!("Failed to list objects: {}", e))?;
234
235    if all_hashes.is_empty() {
236        return Ok(GcResponse {
237            objects_packed: 0,
238            packs_created: 0,
239            loose_removed: 0,
240            bytes_saved: 0,
241            message: "No objects to pack".to_string(),
242        });
243    }
244
245    // Read all loose objects
246    let mut objects: Vec<(ObjectHash, Object)> = Vec::new();
247    let mut total_loose_bytes: u64 = 0;
248    for hash in &all_hashes {
249        let obj = store.read(hash)?;
250        // Track size of loose file
251        let loose_path = repo_root
252            .join(".lit")
253            .join("objects")
254            .join(&hash.as_str()[..4])
255            .join(&hash.as_str()[4..]);
256        if let Ok(meta) = fs::metadata(&loose_path) {
257            total_loose_bytes += meta.len();
258        }
259        objects.push((hash.clone(), obj));
260    }
261
262    // Create packs directory
263    let packs_dir = repo_root.join(".lit").join("packs");
264    fs::create_dir_all(&packs_dir)
265        .map_err(|e| format!("Failed to create packs directory: {}", e))?;
266
267    // Generate pack name from timestamp
268    let pack_name = format!("pack-{}", chrono::Utc::now().format("%Y%m%d%H%M%S"));
269    let pack_path = packs_dir.join(format!("{}.pack", pack_name));
270    let index_path = packs_dir.join(format!("{}.idx", pack_name));
271
272    // Write pack and index
273    let index_entries = write_pack(&objects, &pack_path)?;
274    write_pack_index(&index_entries, &index_path)?;
275
276    let pack_bytes = fs::metadata(&pack_path).map(|m| m.len()).unwrap_or(0);
277    let index_bytes = fs::metadata(&index_path).map(|m| m.len()).unwrap_or(0);
278
279    // Remove loose objects
280    let mut loose_removed = 0u64;
281    for hash in &all_hashes {
282        let dir = hash.as_str()[..4].to_string();
283        let file = hash.as_str()[4..].to_string();
284        let loose_path = repo_root
285            .join(".lit")
286            .join("objects")
287            .join(&dir)
288            .join(&file);
289        if loose_path.exists() {
290            if fs::remove_file(&loose_path).is_ok() {
291                loose_removed += 1;
292            }
293            // Clean up empty shard dirs
294            let shard_dir = repo_root.join(".lit").join("objects").join(&dir);
295            if let Ok(mut entries) = fs::read_dir(&shard_dir) {
296                if entries.next().is_none() {
297                    let _ = fs::remove_dir(&shard_dir);
298                }
299            }
300        }
301    }
302
303    let bytes_saved = if total_loose_bytes > (pack_bytes + index_bytes) {
304        total_loose_bytes - pack_bytes - index_bytes
305    } else {
306        0
307    };
308
309    Ok(GcResponse {
310        objects_packed: objects.len() as u64,
311        packs_created: 1,
312        loose_removed,
313        bytes_saved,
314        message: format!(
315            "Packed {} objects into {} ({} bytes saved)",
316            objects.len(),
317            pack_path.display(),
318            bytes_saved
319        ),
320    })
321}