1use anyhow::{Context, Result};
7use chrono::NaiveDate;
8use std::fs;
9use std::path::Path;
10use std::process::Command;
11
12pub fn get_author(path: impl AsRef<Path>) -> String {
15 let path = path.as_ref();
16
17 let output =
19 Command::new("git").args(["log", "--format=%an", "--reverse", "--"]).arg(path).output();
20
21 if let Ok(output) = output {
22 if output.status.success() {
23 let stdout = String::from_utf8_lossy(&output.stdout);
24 if let Some(author) = stdout.lines().next() {
25 let author = author.trim();
26 if !author.is_empty() {
27 return author.to_string();
28 }
29 }
30 }
31 }
32
33 let output = Command::new("git").args(["config", "user.name"]).output();
35
36 if let Ok(output) = output {
37 if output.status.success() {
38 let stdout = String::from_utf8_lossy(&output.stdout);
39 let name = stdout.trim();
40 if !name.is_empty() {
41 return name.to_string();
42 }
43 }
44 }
45
46 "Unknown Author".to_string()
47}
48
49pub fn get_created_date(path: impl AsRef<Path>) -> NaiveDate {
52 let path = path.as_ref();
53
54 let output =
55 Command::new("git").args(["log", "--format=%ai", "--reverse", "--"]).arg(path).output();
56
57 if let Ok(output) = output {
58 if output.status.success() {
59 let stdout = String::from_utf8_lossy(&output.stdout);
60 if let Some(line) = stdout.lines().next() {
61 if let Some(date_str) = line.split_whitespace().next() {
63 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
64 return date;
65 }
66 }
67 }
68 }
69 }
70
71 chrono::Local::now().naive_local().date()
72}
73
74pub fn get_updated_date(path: impl AsRef<Path>) -> NaiveDate {
77 let path = path.as_ref();
78
79 let output = Command::new("git").args(["log", "--format=%ai", "-1", "--"]).arg(path).output();
80
81 if let Ok(output) = output {
82 if output.status.success() {
83 let stdout = String::from_utf8_lossy(&output.stdout);
84 if let Some(date_str) = stdout.split_whitespace().next() {
86 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
87 return date;
88 }
89 }
90 }
91 }
92
93 chrono::Local::now().naive_local().date()
94}
95
96pub fn git_mv(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<()> {
100 let src = src.as_ref();
101 let dst = dst.as_ref();
102
103 if let Some(parent) = dst.parent() {
105 fs::create_dir_all(parent).context("Failed to create destination directory")?;
106 }
107
108 const MAX_RETRIES: u32 = 5;
110 const INITIAL_DELAY_MS: u64 = 50;
111
112 let mut last_error = None;
113 for attempt in 0..MAX_RETRIES {
114 if attempt > 0 {
115 let delay_ms = INITIAL_DELAY_MS * (1 << (attempt - 1));
117 std::thread::sleep(std::time::Duration::from_millis(delay_ms));
118 }
119
120 let output = Command::new("git")
122 .arg("mv")
123 .arg(src)
124 .arg(dst)
125 .output()
126 .context("Failed to execute git mv")?;
127
128 if output.status.success() {
129 return Ok(());
130 }
131
132 let stderr = String::from_utf8_lossy(&output.stderr);
133 let stderr_str = stderr.trim();
134
135 if stderr_str.contains("index.lock") || stderr_str.contains("unable to create") {
137 last_error = Some(stderr_str.to_string());
138 continue;
139 }
140
141 anyhow::bail!("git mv failed: {}", stderr_str);
143 }
144
145 anyhow::bail!(
147 "git mv failed after {} retries: {}",
148 MAX_RETRIES,
149 last_error.unwrap_or_else(|| "unknown error".to_string())
150 )
151}
152
153pub fn git_add(path: impl AsRef<Path>) -> Result<()> {
155 let path = path.as_ref();
156
157 let output =
158 Command::new("git").arg("add").arg(path).output().context("Failed to execute git add")?;
159
160 if !output.status.success() {
161 let stderr = String::from_utf8_lossy(&output.stderr);
162 anyhow::bail!("git add failed: {}", stderr.trim());
163 }
164
165 Ok(())
166}
167
168pub fn is_git_repo(path: impl AsRef<Path>) -> bool {
170 let path = path.as_ref();
171 let dir = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
172
173 Command::new("git")
174 .args(["rev-parse", "--git-dir"])
175 .current_dir(dir)
176 .output()
177 .map(|output| output.status.success())
178 .unwrap_or(false)
179}
180
181pub fn is_tracked(path: impl AsRef<Path>) -> bool {
183 let path = path.as_ref();
184
185 Command::new("git")
186 .args(["ls-files", "--error-unmatch", "--"])
187 .arg(path)
188 .output()
189 .map(|output| output.status.success())
190 .unwrap_or(false)
191}
192
193pub fn get_repo_root() -> Option<std::path::PathBuf> {
195 let output = Command::new("git").args(["rev-parse", "--show-toplevel"]).output().ok()?;
196
197 if output.status.success() {
198 let stdout = String::from_utf8_lossy(&output.stdout);
199 Some(std::path::PathBuf::from(stdout.trim()))
200 } else {
201 None
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use serial_test::serial;
209 use std::process::Command;
210 use tempfile::TempDir;
211
212 fn in_dir<F, R>(dir: &std::path::Path, f: F) -> R
214 where
215 F: FnOnce() -> R,
216 {
217 let original_dir = std::env::current_dir().ok();
218 std::env::set_current_dir(dir).unwrap();
219 let result = f();
220
221 if let Some(orig) = original_dir {
223 let _ = std::env::set_current_dir(orig);
224 }
225
226 result
227 }
228
229 fn create_test_git_repo() -> TempDir {
231 let temp = TempDir::new().unwrap();
232
233 Command::new("git")
235 .args(["init"])
236 .current_dir(temp.path())
237 .output()
238 .expect("Failed to init git");
239
240 Command::new("git")
242 .args(["config", "user.name", "Test Author"])
243 .current_dir(temp.path())
244 .output()
245 .expect("Failed to config user.name");
246
247 Command::new("git")
248 .args(["config", "user.email", "test@example.com"])
249 .current_dir(temp.path())
250 .output()
251 .expect("Failed to config user.email");
252
253 temp
254 }
255
256 fn create_and_commit_file(repo: &TempDir, filename: &str, content: &str, author: &str) {
258 let file_path = repo.path().join(filename);
259 fs::write(&file_path, content).unwrap();
260
261 Command::new("git")
262 .args(["add", filename])
263 .current_dir(repo.path())
264 .output()
265 .expect("Failed to add file");
266
267 Command::new("git")
268 .args([
269 "commit",
270 "-m",
271 "Test commit",
272 &format!("--author={} <test@example.com>", author),
273 ])
274 .current_dir(repo.path())
275 .output()
276 .expect("Failed to commit");
277 }
278
279 mod get_author {
280 use super::*;
281
282 #[test]
283 #[serial]
284 #[serial]
285 fn test_gets_author_from_git_history() {
286 let repo = create_test_git_repo();
287 create_and_commit_file(&repo, "test.md", "content", "Original Author");
288
289 let author = in_dir(repo.path(), || get_author("test.md"));
290
291 assert_eq!(author, "Original Author");
292 }
293
294 #[test]
295 #[serial]
296 fn test_gets_first_author_for_multiple_commits() {
297 let repo = create_test_git_repo();
298
299 create_and_commit_file(&repo, "test.md", "v1", "Original Author");
301
302 fs::write(repo.path().join("test.md"), "v2").unwrap();
304 Command::new("git").args(["add", "test.md"]).current_dir(repo.path()).output().unwrap();
305 Command::new("git")
306 .args([
307 "commit",
308 "-m",
309 "Second commit",
310 "--author=Second Author <test@example.com>",
311 ])
312 .current_dir(repo.path())
313 .output()
314 .unwrap();
315
316 let author = in_dir(repo.path(), || get_author("test.md"));
317
318 assert_eq!(author, "Original Author");
320 }
321
322 #[test]
323 #[serial]
324 fn test_fallback_to_config_for_untracked_file() {
325 let repo = create_test_git_repo();
326
327 fs::write(repo.path().join("untracked.md"), "content").unwrap();
329
330 let author = in_dir(repo.path(), || get_author("untracked.md"));
331
332 assert_eq!(author, "Test Author");
334 }
335
336 #[test]
337 #[serial]
338 fn test_fallback_outside_repo() {
339 let temp = TempDir::new().unwrap();
340 fs::write(temp.path().join("file.md"), "content").unwrap();
341
342 let author = in_dir(temp.path(), || get_author("file.md"));
343
344 assert!(!author.is_empty());
347 }
348 }
349
350 mod get_created_date {
351 use super::*;
352
353 #[test]
354 #[serial]
355 fn test_gets_date_from_first_commit() {
356 let repo = create_test_git_repo();
357 create_and_commit_file(&repo, "test.md", "v1", "Author");
358
359 let created = in_dir(repo.path(), || get_created_date("test.md"));
360
361 let today = chrono::Local::now().naive_local().date();
363 assert_eq!(created, today);
364 }
365
366 #[test]
367 #[serial]
368 fn test_gets_first_commit_date_not_last() {
369 let repo = create_test_git_repo();
370
371 create_and_commit_file(&repo, "test.md", "v1", "Author");
373 let first_date = in_dir(repo.path(), || get_created_date("test.md"));
374
375 std::thread::sleep(std::time::Duration::from_millis(1100));
377
378 fs::write(repo.path().join("test.md"), "v2").unwrap();
380 Command::new("git").args(["add", "test.md"]).current_dir(repo.path()).output().unwrap();
381 Command::new("git")
382 .args(["commit", "-m", "Update"])
383 .current_dir(repo.path())
384 .output()
385 .unwrap();
386
387 let created = in_dir(repo.path(), || get_created_date("test.md"));
389 assert_eq!(created, first_date);
390 }
391
392 #[test]
393 #[serial]
394 fn test_fallback_to_today_for_untracked() {
395 let repo = create_test_git_repo();
396 fs::write(repo.path().join("untracked.md"), "content").unwrap();
397
398 let created = in_dir(repo.path(), || get_created_date("untracked.md"));
399 let today = chrono::Local::now().naive_local().date();
400
401 assert_eq!(created, today);
402 }
403 }
404
405 mod get_updated_date {
406 use super::*;
407
408 #[test]
409 #[serial]
410 fn test_gets_date_from_last_commit() {
411 let repo = create_test_git_repo();
412 create_and_commit_file(&repo, "test.md", "v1", "Author");
413
414 let updated = in_dir(repo.path(), || get_updated_date("test.md"));
415
416 let today = chrono::Local::now().naive_local().date();
418 assert_eq!(updated, today);
419 }
420
421 #[test]
422 #[serial]
423 fn test_gets_last_commit_date_not_first() {
424 let repo = create_test_git_repo();
425
426 create_and_commit_file(&repo, "test.md", "v1", "Author");
428
429 std::thread::sleep(std::time::Duration::from_millis(1100));
431
432 fs::write(repo.path().join("test.md"), "v2").unwrap();
434 Command::new("git").args(["add", "test.md"]).current_dir(repo.path()).output().unwrap();
435 Command::new("git")
436 .args(["commit", "-m", "Update"])
437 .current_dir(repo.path())
438 .output()
439 .unwrap();
440
441 let updated = in_dir(repo.path(), || get_updated_date("test.md"));
443 let today = chrono::Local::now().naive_local().date();
444 assert_eq!(updated, today);
445 }
446
447 #[test]
448 #[serial]
449 fn test_fallback_to_today_for_untracked() {
450 let repo = create_test_git_repo();
451 fs::write(repo.path().join("untracked.md"), "content").unwrap();
452
453 let updated = in_dir(repo.path(), || get_updated_date("untracked.md"));
454 let today = chrono::Local::now().naive_local().date();
455
456 assert_eq!(updated, today);
457 }
458 }
459
460 mod git_mv {
461 use super::*;
462
463 #[test]
464 #[serial]
465 fn test_moves_tracked_file() {
466 let repo = create_test_git_repo();
467 create_and_commit_file(&repo, "src.md", "content", "Author");
468
469 let result = in_dir(repo.path(), || git_mv("src.md", "dest.md"));
470 assert!(result.is_ok());
471 assert!(!repo.path().join("src.md").exists());
472 assert!(repo.path().join("dest.md").exists());
473 }
474
475 #[test]
476 #[serial]
477 fn test_creates_destination_directory() {
478 let repo = create_test_git_repo();
479 create_and_commit_file(&repo, "src.md", "content", "Author");
480
481 let result = in_dir(repo.path(), || git_mv("src.md", "subdir/nested/dest.md"));
482 assert!(result.is_ok());
483 assert!(!repo.path().join("src.md").exists());
484 assert!(repo.path().join("subdir/nested/dest.md").exists());
485 }
486
487 #[test]
488 #[serial]
489 fn test_fails_for_untracked_file() {
490 let repo = create_test_git_repo();
491 fs::write(repo.path().join("untracked.md"), "content").unwrap();
492
493 let result = in_dir(repo.path(), || git_mv("untracked.md", "dest.md"));
494 assert!(result.is_err());
495 }
496
497 #[test]
498 #[serial]
499 fn test_fails_for_nonexistent_file() {
500 let repo = create_test_git_repo();
501
502 let result = in_dir(repo.path(), || git_mv("nonexistent.md", "dest.md"));
503 assert!(result.is_err());
504 }
505 }
506
507 mod git_add {
508 use super::*;
509
510 #[test]
511 #[serial]
512 fn test_stages_untracked_file() {
513 let repo = create_test_git_repo();
514 fs::write(repo.path().join("new.md"), "content").unwrap();
515
516 let result = in_dir(repo.path(), || git_add("new.md"));
517 assert!(result.is_ok());
518
519 let status = Command::new("git")
521 .args(["status", "--porcelain"])
522 .current_dir(repo.path())
523 .output()
524 .unwrap();
525 let output = String::from_utf8_lossy(&status.stdout);
526 assert!(output.contains("A new.md"));
527 }
528
529 #[test]
530 #[serial]
531 fn test_stages_modified_file() {
532 let repo = create_test_git_repo();
533 create_and_commit_file(&repo, "test.md", "v1", "Author");
534
535 fs::write(repo.path().join("test.md"), "v2").unwrap();
537
538 let result = in_dir(repo.path(), || git_add("test.md"));
539 assert!(result.is_ok());
540
541 let status = Command::new("git")
543 .args(["status", "--porcelain"])
544 .current_dir(repo.path())
545 .output()
546 .unwrap();
547 let output = String::from_utf8_lossy(&status.stdout);
548 assert!(output.contains("M test.md"));
549 }
550
551 #[test]
552 #[serial]
553 fn test_fails_for_nonexistent_file() {
554 let repo = create_test_git_repo();
555
556 let result = in_dir(repo.path(), || git_add("nonexistent.md"));
557 assert!(result.is_err());
558 }
559 }
560
561 mod is_git_repo {
562 use super::*;
563
564 #[test]
565 #[serial]
566 fn test_returns_true_in_git_repo() {
567 let repo = create_test_git_repo();
568 assert!(is_git_repo(repo.path()));
569 }
570
571 #[test]
572 #[serial]
573 fn test_returns_true_for_file_in_repo() {
574 let repo = create_test_git_repo();
575 let file_path = repo.path().join("test.md");
576 fs::write(&file_path, "content").unwrap();
577
578 assert!(is_git_repo(&file_path));
579 }
580
581 #[test]
582 #[serial]
583 fn test_returns_true_in_subdirectory() {
584 let repo = create_test_git_repo();
585 let subdir = repo.path().join("subdir");
586 fs::create_dir(&subdir).unwrap();
587
588 assert!(is_git_repo(&subdir));
589 }
590
591 #[test]
592 #[serial]
593 fn test_returns_false_outside_repo() {
594 let temp = TempDir::new().unwrap();
595 assert!(!is_git_repo(temp.path()));
596 }
597 }
598
599 mod is_tracked {
600 use super::*;
601
602 #[test]
603 #[serial]
604 fn test_returns_true_for_tracked_file() {
605 let repo = create_test_git_repo();
606 create_and_commit_file(&repo, "tracked.md", "content", "Author");
607
608 let tracked = in_dir(repo.path(), || is_tracked("tracked.md"));
609 assert!(tracked);
610 }
611
612 #[test]
613 #[serial]
614 fn test_returns_false_for_untracked_file() {
615 let repo = create_test_git_repo();
616 fs::write(repo.path().join("untracked.md"), "content").unwrap();
617
618 let tracked = in_dir(repo.path(), || is_tracked("untracked.md"));
619 assert!(!tracked);
620 }
621
622 #[test]
623 #[serial]
624 fn test_returns_false_for_nonexistent_file() {
625 let repo = create_test_git_repo();
626
627 let tracked = in_dir(repo.path(), || is_tracked("nonexistent.md"));
628 assert!(!tracked);
629 }
630
631 #[test]
632 #[serial]
633 fn test_returns_false_outside_repo() {
634 let temp = TempDir::new().unwrap();
635 fs::write(temp.path().join("file.md"), "content").unwrap();
636
637 let tracked = in_dir(temp.path(), || is_tracked("file.md"));
638 assert!(!tracked);
639 }
640 }
641
642 mod get_repo_root {
643 use super::*;
644
645 #[test]
646 #[serial]
647 fn test_returns_root_in_repo() {
648 let repo = create_test_git_repo();
649
650 std::env::set_current_dir(repo.path()).unwrap();
652
653 let root = get_repo_root();
654 assert!(root.is_some());
655
656 let root = root.unwrap();
657 assert_eq!(root, repo.path().canonicalize().unwrap());
658 }
659
660 #[test]
661 #[serial]
662 fn test_returns_root_from_subdirectory() {
663 let repo = create_test_git_repo();
664 let subdir = repo.path().join("subdir");
665 fs::create_dir(&subdir).unwrap();
666
667 std::env::set_current_dir(&subdir).unwrap();
669
670 let root = get_repo_root();
671 assert!(root.is_some());
672
673 let root = root.unwrap();
674 assert_eq!(root, repo.path().canonicalize().unwrap());
675 }
676
677 #[test]
678 #[serial]
679 fn test_returns_none_outside_repo() {
680 let temp = TempDir::new().unwrap();
681 std::env::set_current_dir(temp.path()).unwrap();
682
683 let root = get_repo_root();
684 assert!(root.is_none());
685 }
686 }
687}