A tool to help managing forked repos with their own history
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}