Skip to main content

lit/commands/
lfs.rs

1use crate::core::{find_repo_root, ObjectHash};
2use crate::response::{LfsMigrateResponse, LfsTrackResponse};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6
7/// LFS pointer file content format
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct LfsPointer {
10    /// LFS spec version
11    pub version: String,
12    /// SHA3+BLAKE3 hash of the original content
13    pub oid: String,
14    /// Original file size in bytes
15    pub size: u64,
16}
17
18impl LfsPointer {
19    /// Create a new LFS pointer from content
20    pub fn from_content(content: &[u8]) -> Self {
21        let hash = ObjectHash::from_bytes(content);
22        LfsPointer {
23            version: "https://lit-lfs.spec/v1".to_string(),
24            oid: hash.as_str().to_string(),
25            size: content.len() as u64,
26        }
27    }
28
29    /// Serialize to pointer file text
30    pub fn to_pointer_text(&self) -> String {
31        format!(
32            "version {}\noid sha3-blake3:{}\nsize {}\n",
33            self.version, self.oid, self.size
34        )
35    }
36
37    /// Parse from pointer file text
38    pub fn from_pointer_text(text: &str) -> Option<Self> {
39        let mut version = None;
40        let mut oid = None;
41        let mut size = None;
42
43        for line in text.lines() {
44            if let Some(v) = line.strip_prefix("version ") {
45                version = Some(v.to_string());
46            } else if let Some(o) = line.strip_prefix("oid sha3-blake3:") {
47                oid = Some(o.to_string());
48            } else if let Some(s) = line.strip_prefix("size ") {
49                size = s.parse::<u64>().ok();
50            }
51        }
52
53        Some(LfsPointer {
54            version: version?,
55            oid: oid?,
56            size: size?,
57        })
58    }
59
60    /// Check if a byte slice looks like an LFS pointer
61    pub fn is_pointer(data: &[u8]) -> bool {
62        if let Ok(text) = std::str::from_utf8(data) {
63            text.starts_with("version ") && text.contains("oid sha3-blake3:")
64        } else {
65            false
66        }
67    }
68}
69
70/// Get LFS tracking patterns from .litattributes
71fn load_track_patterns(repo_root: &Path) -> Vec<String> {
72    let attrs_path = repo_root.join(".litattributes");
73    if let Ok(content) = fs::read_to_string(&attrs_path) {
74        content
75            .lines()
76            .filter_map(|line| {
77                let line = line.trim();
78                if line.contains("filter=lfs") {
79                    // Format: "pattern filter=lfs diff=lfs merge=lfs -text"
80                    line.split_whitespace().next().map(|s| s.to_string())
81                } else {
82                    None
83                }
84            })
85            .collect()
86    } else {
87        Vec::new()
88    }
89}
90
91/// Save LFS tracking patterns to .litattributes
92fn save_track_patterns(
93    repo_root: &Path,
94    patterns: &[String],
95) -> Result<(), crate::errors::LitError> {
96    let attrs_path = repo_root.join(".litattributes");
97
98    // Read existing non-LFS lines
99    let existing = if attrs_path.exists() {
100        fs::read_to_string(&attrs_path).unwrap_or_default()
101    } else {
102        String::new()
103    };
104
105    let mut lines: Vec<String> = existing
106        .lines()
107        .filter(|l| !l.contains("filter=lfs"))
108        .map(|s| s.to_string())
109        .collect();
110
111    // Add LFS patterns
112    for pat in patterns {
113        lines.push(format!("{} filter=lfs diff=lfs merge=lfs -text", pat));
114    }
115
116    fs::write(&attrs_path, lines.join("\n") + "\n")
117        .map_err(|e| format!("Failed to write .litattributes: {}", e).into())
118}
119
120/// Check if a file matches any LFS pattern
121fn matches_lfs_pattern(file_path: &str, patterns: &[String]) -> bool {
122    for pattern in patterns {
123        if pattern.starts_with("*.") {
124            let ext = &pattern[1..]; // ".ext"
125            if file_path.ends_with(ext) {
126                return true;
127            }
128        } else if file_path == pattern {
129            return true;
130        }
131    }
132    false
133}
134
135/// Store a large file in LFS storage
136fn store_lfs_object(
137    repo_root: &Path,
138    content: &[u8],
139) -> Result<LfsPointer, crate::errors::LitError> {
140    let pointer = LfsPointer::from_content(content);
141
142    // Store in .lit/lfs/objects/{oid[..4]}/{oid[4..]}
143    let lfs_dir = repo_root
144        .join(".lit")
145        .join("lfs")
146        .join("objects")
147        .join(&pointer.oid[..4.min(pointer.oid.len())]);
148    fs::create_dir_all(&lfs_dir).map_err(|e| format!("Failed to create LFS dir: {}", e))?;
149
150    let oid_rest = if pointer.oid.len() > 4 {
151        &pointer.oid[4..]
152    } else {
153        "data"
154    };
155    let obj_path = lfs_dir.join(oid_rest);
156
157    // Write raw (uncompressed) for direct streaming access
158    fs::write(&obj_path, content).map_err(|e| format!("Failed to write LFS object: {}", e))?;
159
160    Ok(pointer)
161}
162
163/// Retrieve a large file from LFS storage
164pub fn read_lfs_object(
165    repo_root: &Path,
166    pointer: &LfsPointer,
167) -> Result<Vec<u8>, crate::errors::LitError> {
168    let oid_prefix = &pointer.oid[..4.min(pointer.oid.len())];
169    let oid_rest = if pointer.oid.len() > 4 {
170        &pointer.oid[4..]
171    } else {
172        "data"
173    };
174    let obj_path = repo_root
175        .join(".lit")
176        .join("lfs")
177        .join("objects")
178        .join(oid_prefix)
179        .join(oid_rest);
180
181    fs::read(&obj_path).map_err(|e| format!("Failed to read LFS object: {}", e).into())
182}
183
184/// Execute `lfs track` — add tracking patterns for large files
185pub fn execute_track(patterns: Vec<String>) -> Result<LfsTrackResponse, crate::errors::LitError> {
186    let repo_root = find_repo_root()?;
187
188    let mut current = load_track_patterns(&repo_root);
189    let mut added = Vec::new();
190
191    for pat in &patterns {
192        if !current.contains(pat) {
193            current.push(pat.clone());
194            added.push(pat.clone());
195        }
196    }
197
198    save_track_patterns(&repo_root, &current)?;
199
200    Ok(LfsTrackResponse {
201        patterns: current,
202        message: if added.is_empty() {
203            "No new patterns added".to_string()
204        } else {
205            format!("Tracking {} new pattern(s)", added.len())
206        },
207    })
208}
209
210/// Execute `lfs migrate` — convert existing large files to LFS pointers
211pub fn execute_migrate(
212    threshold: Option<u64>,
213) -> Result<LfsMigrateResponse, crate::errors::LitError> {
214    let repo_root = find_repo_root()?;
215    let threshold = threshold.unwrap_or(10 * 1024 * 1024); // 10MB default
216
217    let patterns = load_track_patterns(&repo_root);
218    let mut files_migrated = 0u64;
219    let mut bytes_saved = 0u64;
220
221    // Walk the working tree
222    for entry in walkdir::WalkDir::new(&repo_root)
223        .into_iter()
224        .filter_entry(|e| {
225            let name = e.file_name().to_string_lossy();
226            name != ".lit" && name != ".git" && name != "target"
227        })
228    {
229        let entry = match entry {
230            Ok(e) => e,
231            Err(_) => continue,
232        };
233
234        if !entry.file_type().is_file() {
235            continue;
236        }
237
238        let path = entry.path();
239        let rel_path = path
240            .strip_prefix(&repo_root)
241            .unwrap_or(path)
242            .to_string_lossy()
243            .replace('\\', "/");
244
245        // Check size threshold or pattern match
246        let meta = match fs::metadata(path) {
247            Ok(m) => m,
248            Err(_) => continue,
249        };
250
251        let should_migrate = meta.len() >= threshold || matches_lfs_pattern(&rel_path, &patterns);
252
253        if !should_migrate {
254            continue;
255        }
256
257        // Read file content
258        let content = match fs::read(path) {
259            Ok(c) => c,
260            Err(_) => continue,
261        };
262
263        // Skip if already an LFS pointer
264        if LfsPointer::is_pointer(&content) {
265            continue;
266        }
267
268        let original_size = content.len() as u64;
269
270        // Store in LFS
271        let pointer = store_lfs_object(&repo_root, &content)?;
272
273        // Replace file with pointer
274        let pointer_text = pointer.to_pointer_text();
275        fs::write(path, &pointer_text)
276            .map_err(|e| format!("Failed to write pointer for {}: {}", rel_path, e))?;
277
278        bytes_saved += original_size - pointer_text.len() as u64;
279        files_migrated += 1;
280    }
281
282    Ok(LfsMigrateResponse {
283        files_migrated,
284        bytes_saved,
285        message: format!(
286            "Migrated {} file(s) to LFS, saved {} bytes",
287            files_migrated, bytes_saved
288        ),
289    })
290}