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#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct LfsPointer {
10 pub version: String,
12 pub oid: String,
14 pub size: u64,
16}
17
18impl LfsPointer {
19 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 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 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 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
70fn 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 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
91fn 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 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 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
120fn matches_lfs_pattern(file_path: &str, patterns: &[String]) -> bool {
122 for pattern in patterns {
123 if pattern.starts_with("*.") {
124 let ext = &pattern[1..]; 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
135fn 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 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 fs::write(&obj_path, content).map_err(|e| format!("Failed to write LFS object: {}", e))?;
159
160 Ok(pointer)
161}
162
163pub 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
184pub 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, ¤t)?;
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
210pub 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); let patterns = load_track_patterns(&repo_root);
218 let mut files_migrated = 0u64;
219 let mut bytes_saved = 0u64;
220
221 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 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 let content = match fs::read(path) {
259 Ok(c) => c,
260 Err(_) => continue,
261 };
262
263 if LfsPointer::is_pointer(&content) {
265 continue;
266 }
267
268 let original_size = content.len() as u64;
269
270 let pointer = store_lfs_object(&repo_root, &content)?;
272
273 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}