A tool to help managing forked repos with their own history
1//! Integration tests for forkme
2
3use std::fs;
4use tempfile::TempDir;
5
6use forkme::config::{Config, Upstream};
7use forkme::git::FileContent;
8use forkme::patch::{self, PatchEntry};
9use git2::Repository;
10
11/// Helper to create a test git repository using git2
12fn create_test_repo(dir: &std::path::Path) -> Repository {
13 let repo = Repository::init(dir).unwrap();
14
15 // Configure repo
16 let mut config = repo.config().unwrap();
17 config.set_str("user.email", "test@test.com").unwrap();
18 config.set_str("user.name", "Test User").unwrap();
19
20 repo
21}
22
23/// Helper to commit files to a repo
24fn commit_files(repo: &Repository, files: &[(&str, &str)], message: &str) {
25 let mut index = repo.index().unwrap();
26
27 for (path, content) in files {
28 let full_path = repo.workdir().unwrap().join(path);
29 if let Some(parent) = full_path.parent() {
30 fs::create_dir_all(parent).unwrap();
31 }
32 fs::write(&full_path, content).unwrap();
33 index.add_path(std::path::Path::new(path)).unwrap();
34 }
35
36 index.write().unwrap();
37 let tree_id = index.write_tree().unwrap();
38 let tree = repo.find_tree(tree_id).unwrap();
39
40 let sig = repo.signature().unwrap();
41
42 // Get parent commit if exists
43 let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
44
45 match parent {
46 Some(p) => {
47 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&p])
48 .unwrap();
49 }
50 None => {
51 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
52 .unwrap();
53 }
54 }
55}
56
57#[test]
58fn test_patch_generation_and_application() {
59 let original = "line1\nline2\nline3\n";
60 let modified = "line1\nmodified_line2\nline3\nnew_line4\n";
61
62 // Generate patch
63 let patch_content = patch::generate_patch(Some(original), Some(modified));
64
65 // Verify patch contains expected changes
66 assert!(patch_content.contains("-line2"));
67 assert!(patch_content.contains("+modified_line2"));
68 assert!(patch_content.contains("+new_line4"));
69
70 // Apply patch
71 let result = patch::apply_patch(original, &patch_content).unwrap();
72 assert_eq!(result, modified);
73}
74
75#[test]
76fn test_config_roundtrip() {
77 let temp_dir = TempDir::new().unwrap();
78 let config_path = temp_dir.path().join("forkme.toml");
79
80 let config = Config {
81 upstream: Upstream {
82 url: "https://github.com/example/repo.git".to_string(),
83 branch: "main".to_string(),
84 },
85 };
86
87 config.save_to(&config_path).unwrap();
88
89 let loaded = Config::load_from(&config_path).unwrap();
90 assert_eq!(loaded.upstream.url, config.upstream.url);
91 assert_eq!(loaded.upstream.branch, config.upstream.branch);
92}
93
94#[test]
95fn test_patch_entry_types() {
96 let text = PatchEntry::TextPatch("src/lib.rs".to_string());
97 let binary = PatchEntry::Binary("assets/logo.png".to_string());
98 let deleted = PatchEntry::Deleted("old_file.rs".to_string());
99
100 assert_eq!(text.file_path(), "src/lib.rs");
101 assert_eq!(binary.file_path(), "assets/logo.png");
102 assert_eq!(deleted.file_path(), "old_file.rs");
103}
104
105#[test]
106fn test_new_file_patch() {
107 // Create a patch for a completely new file
108 let new_content = "pub fn new_function() {\n // todo\n}\n";
109 let patch_content = patch::generate_patch(None, Some(new_content));
110
111 // Should indicate this is a new file (starts from nothing)
112 assert!(patch_content.contains("@@ -0,0"));
113
114 // Apply to empty string should work
115 let result = patch::apply_patch("", &patch_content).unwrap();
116 assert_eq!(result, new_content);
117}
118
119#[test]
120fn test_file_deletion_patch() {
121 let old_content = "this file will be deleted\n";
122 let patch_content = patch::generate_patch(Some(old_content), None);
123
124 // Should indicate deletion
125 assert!(patch_content.contains("-this file will be deleted"));
126}
127
128#[test]
129fn test_file_content_text_detection() {
130 let text = FileContent::Text("hello world".to_string());
131 assert!(!text.is_binary());
132 assert_eq!(text.as_text(), Some("hello world"));
133}
134
135#[test]
136fn test_file_content_binary_detection() {
137 let binary = FileContent::Binary(vec![0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes
138 assert!(binary.is_binary());
139 assert_eq!(binary.as_text(), None);
140 assert_eq!(binary.as_bytes(), &[0x89, 0x50, 0x4E, 0x47]);
141}
142
143#[test]
144fn test_create_repo_with_git2() {
145 let temp_dir = TempDir::new().unwrap();
146 let repo = create_test_repo(temp_dir.path());
147
148 // Create initial commit
149 commit_files(
150 &repo,
151 &[("README.md", "# Test\n"), ("src/main.rs", "fn main() {}\n")],
152 "Initial commit",
153 );
154
155 // Verify commit exists
156 let head = repo.head().unwrap();
157 let commit = head.peel_to_commit().unwrap();
158 assert_eq!(commit.message(), Some("Initial commit"));
159
160 // Verify files exist
161 assert!(temp_dir.path().join("README.md").exists());
162 assert!(temp_dir.path().join("src/main.rs").exists());
163}
164
165#[test]
166fn test_repo_diff_detection() {
167 let temp_dir = TempDir::new().unwrap();
168 let repo = create_test_repo(temp_dir.path());
169
170 // Create initial commit
171 commit_files(&repo, &[("file.txt", "original\n")], "Initial");
172
173 // Create second commit with changes
174 commit_files(&repo, &[("file.txt", "modified\n")], "Modified");
175
176 // Get the two commits
177 let head = repo.head().unwrap().peel_to_commit().unwrap();
178 let parent = head.parent(0).unwrap();
179
180 // Diff between commits
181 let old_tree = parent.tree().unwrap();
182 let new_tree = head.tree().unwrap();
183 let diff = repo
184 .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
185 .unwrap();
186
187 // Should have one file changed
188 assert_eq!(diff.deltas().count(), 1);
189}
190
191/// End-to-end test simulating the forkme workflow:
192/// 1. Create upstream repo with initial commit on main
193/// 2. Clone and create forkme branch
194/// 3. Make changes on forkme branch
195/// 4. Use library functions to generate patches
196/// 5. Verify patches can be applied to restore changes
197#[test]
198fn test_full_sync_workflow() {
199 // Create "upstream" repo
200 let upstream_dir = TempDir::new().unwrap();
201 let upstream_repo = create_test_repo(upstream_dir.path());
202
203 // Initial commit on main
204 commit_files(
205 &upstream_repo,
206 &[
207 ("README.md", "# Original Project\n"),
208 ("src/lib.rs", "pub fn original() {}\n"),
209 ],
210 "Initial commit",
211 );
212
213 // Create "local" repo (simulating clone)
214 let local_dir = TempDir::new().unwrap();
215 let local_repo =
216 Repository::clone(upstream_dir.path().to_str().unwrap(), local_dir.path()).unwrap();
217
218 // Configure local repo
219 let mut config = local_repo.config().unwrap();
220 config.set_str("user.email", "test@test.com").unwrap();
221 config.set_str("user.name", "Test User").unwrap();
222
223 // Create forkme branch from origin/main (which is now called origin/master or main)
224 let head = local_repo.head().unwrap();
225 let head_commit = head.peel_to_commit().unwrap();
226 local_repo.branch("forkme", &head_commit, false).unwrap();
227
228 // Checkout forkme branch
229 let obj = local_repo.revparse_single("refs/heads/forkme").unwrap();
230 local_repo.checkout_tree(&obj, None).unwrap();
231 local_repo.set_head("refs/heads/forkme").unwrap();
232
233 // Make changes on forkme branch
234 commit_files(
235 &local_repo,
236 &[
237 ("README.md", "# Modified Project\n\nWith extra content.\n"),
238 (
239 "src/lib.rs",
240 "pub fn original() {}\n\npub fn new_function() {\n // added by fork\n}\n",
241 ),
242 (
243 "src/new_file.rs",
244 "// Completely new file\npub fn fork_only() {}\n",
245 ),
246 ],
247 "Fork modifications",
248 );
249
250 // Now simulate what sync does: get changes between upstream and forkme
251 // First, find the upstream commit (origin/master or origin/main)
252 let upstream_ref = local_repo
253 .find_reference("refs/remotes/origin/master")
254 .or_else(|_| local_repo.find_reference("refs/remotes/origin/main"))
255 .unwrap();
256 let upstream_commit = upstream_ref.peel_to_commit().unwrap();
257 let upstream_tree = upstream_commit.tree().unwrap();
258
259 let forkme_head = local_repo.head().unwrap().peel_to_commit().unwrap();
260 let forkme_tree = forkme_head.tree().unwrap();
261
262 // Get diff
263 let diff = local_repo
264 .diff_tree_to_tree(Some(&upstream_tree), Some(&forkme_tree), None)
265 .unwrap();
266
267 // Should have 3 files changed
268 assert_eq!(diff.deltas().count(), 3);
269
270 // Collect changes and generate patches
271 let mut patches: Vec<(String, String)> = Vec::new();
272
273 diff.foreach(
274 &mut |delta, _| {
275 let path = delta
276 .new_file()
277 .path()
278 .unwrap()
279 .to_string_lossy()
280 .to_string();
281
282 let old_content = if delta.old_file().id().is_zero() {
283 None
284 } else {
285 local_repo
286 .find_blob(delta.old_file().id())
287 .ok()
288 .and_then(|blob| String::from_utf8(blob.content().to_vec()).ok())
289 };
290
291 let new_content = if delta.new_file().id().is_zero() {
292 None
293 } else {
294 local_repo
295 .find_blob(delta.new_file().id())
296 .ok()
297 .and_then(|blob| String::from_utf8(blob.content().to_vec()).ok())
298 };
299
300 let patch_content =
301 patch::generate_patch(old_content.as_deref(), new_content.as_deref());
302 patches.push((path, patch_content));
303 true
304 },
305 None,
306 None,
307 None,
308 )
309 .unwrap();
310
311 // Verify we have patches for all 3 files
312 assert_eq!(patches.len(), 3);
313
314 // Verify README patch contains the changes
315 let readme_patch = patches.iter().find(|(p, _)| p == "README.md").unwrap();
316 assert!(readme_patch.1.contains("-# Original Project"));
317 assert!(readme_patch.1.contains("+# Modified Project"));
318
319 // Verify new file patch
320 let new_file_patch = patches
321 .iter()
322 .find(|(p, _)| p == "src/new_file.rs")
323 .unwrap();
324 assert!(new_file_patch.1.contains("@@ -0,0")); // New file indicator
325
326 // Test applying patches to original content restores the fork changes
327 let original_readme = "# Original Project\n";
328 let patched_readme = patch::apply_patch(original_readme, &readme_patch.1).unwrap();
329 assert_eq!(
330 patched_readme,
331 "# Modified Project\n\nWith extra content.\n"
332 );
333
334 // Test new file can be created from patch
335 let new_file_content = patch::apply_patch("", &new_file_patch.1).unwrap();
336 assert!(new_file_content.contains("pub fn fork_only()"));
337}