Skip to main content

design/
git.rs

1//! Git integration for design documents
2//!
3//! Provides functions for extracting metadata from git history
4//! and performing git operations while preserving history.
5
6use anyhow::{Context, Result};
7use chrono::NaiveDate;
8use std::fs;
9use std::path::Path;
10use std::process::Command;
11
12/// Extract the original author from git history
13/// Falls back to git config user.name, then "Unknown Author" if git fails
14pub fn get_author(path: impl AsRef<Path>) -> String {
15    let path = path.as_ref();
16
17    // Try to get author from git log (first commit)
18    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    // Fallback to git config user.name
34    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
49/// Extract creation date from git history (first commit)
50/// Falls back to today's date if git fails
51pub 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                // Extract just the date portion (YYYY-MM-DD)
62                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
74/// Extract last modified date from git history
75/// Falls back to today's date if git fails
76pub 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            // Extract just the date portion (YYYY-MM-DD)
85            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
96/// Move a file using git mv to preserve history
97/// Creates destination directory if needed
98/// Retries on index.lock errors with exponential backoff
99pub 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    // Ensure destination directory exists
104    if let Some(parent) = dst.parent() {
105        fs::create_dir_all(parent).context("Failed to create destination directory")?;
106    }
107
108    // Retry logic for index.lock race conditions
109    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            // Exponential backoff: 50ms, 100ms, 200ms, 400ms, 800ms
116            let delay_ms = INITIAL_DELAY_MS * (1 << (attempt - 1));
117            std::thread::sleep(std::time::Duration::from_millis(delay_ms));
118        }
119
120        // Execute git mv
121        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        // Check if this is an index.lock error that we should retry
136        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        // For other errors, fail immediately
142        anyhow::bail!("git mv failed: {}", stderr_str);
143    }
144
145    // All retries exhausted
146    anyhow::bail!(
147        "git mv failed after {} retries: {}",
148        MAX_RETRIES,
149        last_error.unwrap_or_else(|| "unknown error".to_string())
150    )
151}
152
153/// Stage a file with git add
154pub 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
168/// Check if a path is in a git repository
169pub 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
181/// Check if a file is tracked by git
182pub 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
193/// Get the git repository root directory
194pub 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    /// Helper to run code in a directory and restore the original directory afterward
213    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        // Try to restore original directory, but don't panic if it no longer exists
222        if let Some(orig) = original_dir {
223            let _ = std::env::set_current_dir(orig);
224        }
225
226        result
227    }
228
229    /// Helper to create a git repo for testing
230    fn create_test_git_repo() -> TempDir {
231        let temp = TempDir::new().unwrap();
232
233        // Initialize git repo
234        Command::new("git")
235            .args(["init"])
236            .current_dir(temp.path())
237            .output()
238            .expect("Failed to init git");
239
240        // Configure user
241        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    /// Helper to create and commit a file in a git repo
257    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            // First commit by Original Author
300            create_and_commit_file(&repo, "test.md", "v1", "Original Author");
301
302            // Second commit by different author
303            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            // Should return first author, not second
319            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            // Create file but don't commit
328            fs::write(repo.path().join("untracked.md"), "content").unwrap();
329
330            let author = in_dir(repo.path(), || get_author("untracked.md"));
331
332            // Should fallback to git config user.name
333            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            // Should fallback to git config user.name when not in git repo
345            // (will be global config value, or "Unknown Author" if not set)
346            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            // Should be today (since we just created it)
362            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            // First commit
372            create_and_commit_file(&repo, "test.md", "v1", "Author");
373            let first_date = in_dir(repo.path(), || get_created_date("test.md"));
374
375            // Sleep briefly to ensure different timestamp
376            std::thread::sleep(std::time::Duration::from_millis(1100));
377
378            // Second commit
379            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            // Should still return first commit date
388            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            // Should be today
417            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            // First commit
427            create_and_commit_file(&repo, "test.md", "v1", "Author");
428
429            // Sleep briefly
430            std::thread::sleep(std::time::Duration::from_millis(1100));
431
432            // Second commit
433            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            // Should return today (last commit)
442            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            // Verify it's staged
520            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            // Modify the file
536            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            // Verify it's staged
542            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            // Change to repo directory
651            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            // Change to subdirectory
668            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}