A tool to help managing forked repos with their own history
at main 332 lines 9.8 kB view raw
1use anyhow::{Context, Result}; 2use diffy::{apply, create_patch, Patch}; 3use std::fs; 4use std::path::{Path, PathBuf}; 5use walkdir::WalkDir; 6 7pub const PATCHES_DIR: &str = "patches"; 8 9// Extension for text patches 10const PATCH_EXT: &str = ".patch"; 11// Extension for deleted file markers 12const DELETED_EXT: &str = ".deleted"; 13 14pub fn generate_patch(old_content: Option<&str>, new_content: Option<&str>) -> String { 15 let old = old_content.unwrap_or(""); 16 let new = new_content.unwrap_or(""); 17 create_patch(old, new).to_string() 18} 19 20pub fn apply_patch(original: &str, patch_content: &str) -> Result<String> { 21 let patch: Patch<'_, str> = Patch::from_str(patch_content)?; 22 apply(original, &patch).with_context(|| "Failed to apply patch") 23} 24 25pub fn patch_path_for_file(file_path: &str) -> PathBuf { 26 PathBuf::from(PATCHES_DIR).join(format!("{}{}", file_path, PATCH_EXT)) 27} 28 29pub fn binary_path_for_file(file_path: &str) -> PathBuf { 30 PathBuf::from(PATCHES_DIR).join(file_path) 31} 32 33pub fn deleted_path_for_file(file_path: &str) -> PathBuf { 34 PathBuf::from(PATCHES_DIR).join(format!("{}{}", file_path, DELETED_EXT)) 35} 36 37pub fn file_path_from_patch(patch_path: &Path) -> Option<String> { 38 let patches_prefix = PathBuf::from(PATCHES_DIR); 39 patch_path 40 .strip_prefix(&patches_prefix) 41 .ok() 42 .and_then(|p| p.to_str()) 43 .map(|s| s.trim_end_matches(PATCH_EXT).to_string()) 44} 45 46pub fn file_path_from_binary(binary_path: &Path) -> Option<String> { 47 let patches_prefix = PathBuf::from(PATCHES_DIR); 48 binary_path 49 .strip_prefix(&patches_prefix) 50 .ok() 51 .and_then(|p| p.to_str()) 52 .map(|s| s.to_string()) 53} 54 55pub fn file_path_from_deleted(deleted_path: &Path) -> Option<String> { 56 let patches_prefix = PathBuf::from(PATCHES_DIR); 57 deleted_path 58 .strip_prefix(&patches_prefix) 59 .ok() 60 .and_then(|p| p.to_str()) 61 .map(|s| s.trim_end_matches(DELETED_EXT).to_string()) 62} 63 64pub fn save_patch(file_path: &str, patch_content: &str) -> Result<()> { 65 let patch_file = patch_path_for_file(file_path); 66 67 if let Some(parent) = patch_file.parent() { 68 fs::create_dir_all(parent)?; 69 } 70 71 fs::write(&patch_file, patch_content) 72 .with_context(|| format!("Failed to write patch file {}", patch_file.display()))?; 73 74 Ok(()) 75} 76 77pub fn save_binary(file_path: &str, content: &[u8]) -> Result<()> { 78 let binary_file = binary_path_for_file(file_path); 79 80 if let Some(parent) = binary_file.parent() { 81 fs::create_dir_all(parent)?; 82 } 83 84 fs::write(&binary_file, content) 85 .with_context(|| format!("Failed to write binary file {}", binary_file.display()))?; 86 87 Ok(()) 88} 89 90pub fn save_deleted_marker(file_path: &str) -> Result<()> { 91 let deleted_file = deleted_path_for_file(file_path); 92 93 if let Some(parent) = deleted_file.parent() { 94 fs::create_dir_all(parent)?; 95 } 96 97 fs::write(&deleted_file, "") 98 .with_context(|| format!("Failed to write deleted marker {}", deleted_file.display()))?; 99 100 Ok(()) 101} 102 103pub fn read_patch(file_path: &str) -> Result<String> { 104 let patch_file = patch_path_for_file(file_path); 105 fs::read_to_string(&patch_file) 106 .with_context(|| format!("Failed to read patch file {}", patch_file.display())) 107} 108 109pub fn read_binary(file_path: &str) -> Result<Vec<u8>> { 110 let binary_file = binary_path_for_file(file_path); 111 fs::read(&binary_file) 112 .with_context(|| format!("Failed to read binary file {}", binary_file.display())) 113} 114 115pub fn delete_patch(file_path: &str) -> Result<()> { 116 let patch_file = patch_path_for_file(file_path); 117 if patch_file.exists() { 118 fs::remove_file(&patch_file)?; 119 } 120 Ok(()) 121} 122 123pub fn delete_binary(file_path: &str) -> Result<()> { 124 let binary_file = binary_path_for_file(file_path); 125 if binary_file.exists() { 126 fs::remove_file(&binary_file)?; 127 } 128 Ok(()) 129} 130 131pub fn delete_deleted_marker(file_path: &str) -> Result<()> { 132 let deleted_file = deleted_path_for_file(file_path); 133 if deleted_file.exists() { 134 fs::remove_file(&deleted_file)?; 135 } 136 Ok(()) 137} 138 139pub fn delete_all_for_file(file_path: &str) -> Result<()> { 140 delete_patch(file_path)?; 141 delete_binary(file_path)?; 142 delete_deleted_marker(file_path)?; 143 Ok(()) 144} 145 146#[derive(Debug, Clone, PartialEq)] 147pub enum PatchEntry { 148 TextPatch(String), // file path 149 Binary(String), // file path 150 Deleted(String), // file path 151} 152 153impl PatchEntry { 154 pub fn file_path(&self) -> &str { 155 match self { 156 PatchEntry::TextPatch(p) => p, 157 PatchEntry::Binary(p) => p, 158 PatchEntry::Deleted(p) => p, 159 } 160 } 161} 162 163pub fn list_all_entries() -> Result<Vec<PatchEntry>> { 164 let patches_dir = Path::new(PATCHES_DIR); 165 if !patches_dir.exists() { 166 return Ok(Vec::new()); 167 } 168 169 let mut entries = Vec::new(); 170 171 for entry in WalkDir::new(patches_dir) 172 .into_iter() 173 .filter_map(|e| e.ok()) 174 .filter(|e| e.file_type().is_file()) 175 { 176 let path = entry.path(); 177 let path_str = path.to_string_lossy(); 178 179 if path_str.ends_with(PATCH_EXT) { 180 if let Some(file_path) = file_path_from_patch(path) { 181 entries.push(PatchEntry::TextPatch(file_path)); 182 } 183 } else if path_str.ends_with(DELETED_EXT) { 184 if let Some(file_path) = file_path_from_deleted(path) { 185 entries.push(PatchEntry::Deleted(file_path)); 186 } 187 } else { 188 // It's a binary file (no special extension) 189 if let Some(file_path) = file_path_from_binary(path) { 190 entries.push(PatchEntry::Binary(file_path)); 191 } 192 } 193 } 194 195 Ok(entries) 196} 197 198pub fn list_patches() -> Result<Vec<String>> { 199 let entries = list_all_entries()?; 200 Ok(entries 201 .into_iter() 202 .filter_map(|e| match e { 203 PatchEntry::TextPatch(p) => Some(p), 204 _ => None, 205 }) 206 .collect()) 207} 208 209pub fn ensure_patches_dir() -> Result<()> { 210 fs::create_dir_all(PATCHES_DIR)?; 211 Ok(()) 212} 213 214pub fn cleanup_empty_dirs() -> Result<()> { 215 let patches_dir = Path::new(PATCHES_DIR); 216 if !patches_dir.exists() { 217 return Ok(()); 218 } 219 220 // Collect directories in reverse depth order (deepest first) 221 let mut dirs: Vec<_> = WalkDir::new(patches_dir) 222 .into_iter() 223 .filter_map(|e| e.ok()) 224 .filter(|e| e.file_type().is_dir()) 225 .map(|e| e.path().to_path_buf()) 226 .collect(); 227 228 dirs.sort_by_key(|b| std::cmp::Reverse(b.components().count())); 229 230 for dir in dirs { 231 if dir == patches_dir { 232 continue; 233 } 234 // Try to remove; will fail if not empty (which is fine) 235 let _ = fs::remove_dir(&dir); 236 } 237 238 Ok(()) 239} 240 241#[cfg(test)] 242mod tests { 243 use super::*; 244 use std::path::Path; 245 246 #[test] 247 fn test_generate_patch_addition() { 248 let patch = generate_patch(None, Some("hello\nworld\n")); 249 assert!(patch.contains("@@ -0,0 +1,2 @@")); 250 assert!(patch.contains("+hello")); 251 assert!(patch.contains("+world")); 252 } 253 254 #[test] 255 fn test_generate_patch_modification() { 256 let patch = generate_patch(Some("hello\n"), Some("hello\nworld\n")); 257 assert!(patch.contains("+world")); 258 } 259 260 #[test] 261 fn test_generate_patch_deletion() { 262 let patch = generate_patch(Some("hello\nworld\n"), None); 263 assert!(patch.contains("-hello")); 264 assert!(patch.contains("-world")); 265 } 266 267 #[test] 268 fn test_apply_patch_new_file() { 269 let patch = generate_patch(None, Some("hello\nworld\n")); 270 let result = apply_patch("", &patch).unwrap(); 271 assert_eq!(result, "hello\nworld\n"); 272 } 273 274 #[test] 275 fn test_apply_patch_modification() { 276 let original = "line1\nline2\nline3\n"; 277 let modified = "line1\nmodified\nline3\n"; 278 let patch = generate_patch(Some(original), Some(modified)); 279 let result = apply_patch(original, &patch).unwrap(); 280 assert_eq!(result, modified); 281 } 282 283 #[test] 284 fn test_patch_path_for_file() { 285 let path = patch_path_for_file("src/main.rs"); 286 assert_eq!(path, PathBuf::from("patches/src/main.rs.patch")); 287 } 288 289 #[test] 290 fn test_binary_path_for_file() { 291 let path = binary_path_for_file("assets/logo.png"); 292 assert_eq!(path, PathBuf::from("patches/assets/logo.png")); 293 } 294 295 #[test] 296 fn test_deleted_path_for_file() { 297 let path = deleted_path_for_file("old/file.rs"); 298 assert_eq!(path, PathBuf::from("patches/old/file.rs.deleted")); 299 } 300 301 #[test] 302 fn test_file_path_from_patch() { 303 let patch_path = Path::new("patches/src/lib.rs.patch"); 304 let file_path = file_path_from_patch(patch_path); 305 assert_eq!(file_path, Some("src/lib.rs".to_string())); 306 } 307 308 #[test] 309 fn test_file_path_from_binary() { 310 let binary_path = Path::new("patches/assets/image.png"); 311 let file_path = file_path_from_binary(binary_path); 312 assert_eq!(file_path, Some("assets/image.png".to_string())); 313 } 314 315 #[test] 316 fn test_file_path_from_deleted() { 317 let deleted_path = Path::new("patches/old/removed.rs.deleted"); 318 let file_path = file_path_from_deleted(deleted_path); 319 assert_eq!(file_path, Some("old/removed.rs".to_string())); 320 } 321 322 #[test] 323 fn test_patch_entry_file_path() { 324 let text = PatchEntry::TextPatch("src/main.rs".to_string()); 325 let binary = PatchEntry::Binary("logo.png".to_string()); 326 let deleted = PatchEntry::Deleted("old.rs".to_string()); 327 328 assert_eq!(text.file_path(), "src/main.rs"); 329 assert_eq!(binary.file_path(), "logo.png"); 330 assert_eq!(deleted.file_path(), "old.rs"); 331 } 332}