Skip to main content

chant/
git_ops.rs

1//! Low-level git operations and wrappers.
2//!
3//! This module provides pure git command wrappers without dependencies on
4//! spec, config, or operations modules. For high-level merge orchestration,
5//! see the `git` module.
6
7use anyhow::{Context, Result};
8use std::fmt;
9use std::process::Command;
10
11/// Get a git config value by key.
12///
13/// Returns `Some(value)` if the config key exists and has a non-empty value,
14/// `None` otherwise.
15pub fn get_git_config(key: &str) -> Option<String> {
16    let output = Command::new("git").args(["config", key]).output().ok()?;
17
18    if !output.status.success() {
19        return None;
20    }
21
22    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
23    if value.is_empty() {
24        None
25    } else {
26        Some(value)
27    }
28}
29
30/// Get git user name and email from config.
31///
32/// Returns a tuple of (user.name, user.email), where each is `Some` if configured.
33pub fn get_git_user_info() -> (Option<String>, Option<String>) {
34    (get_git_config("user.name"), get_git_config("user.email"))
35}
36
37/// Get the current branch name.
38/// Returns the branch name for the current HEAD, including "HEAD" for detached HEAD state.
39pub fn get_current_branch() -> Result<String> {
40    let output = Command::new("git")
41        .args(["rev-parse", "--abbrev-ref", "HEAD"])
42        .output()
43        .context("Failed to run git rev-parse")?;
44
45    if !output.status.success() {
46        anyhow::bail!("Failed to get current branch");
47    }
48
49    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
50    Ok(branch)
51}
52
53/// Check if a branch exists in the repository.
54pub fn branch_exists(branch_name: &str) -> Result<bool> {
55    let output = Command::new("git")
56        .args(["branch", "--list", branch_name])
57        .output()
58        .context("Failed to check if branch exists")?;
59
60    if !output.status.success() {
61        anyhow::bail!("Failed to check if branch exists");
62    }
63
64    let stdout = String::from_utf8_lossy(&output.stdout);
65    Ok(!stdout.trim().is_empty())
66}
67
68/// Check if a branch has been merged into a target branch.
69///
70/// # Arguments
71/// * `branch_name` - The branch to check
72/// * `target_branch` - The target branch to check against (e.g., "main")
73///
74/// # Returns
75/// * `Ok(true)` if the branch has been merged into the target
76/// * `Ok(false)` if the branch exists but hasn't been merged
77/// * `Err(_)` if git operations fail
78pub fn is_branch_merged(branch_name: &str, target_branch: &str) -> Result<bool> {
79    // Use git branch --merged to check if the branch is in the list of merged branches
80    let output = Command::new("git")
81        .args(["branch", "--merged", target_branch, "--list", branch_name])
82        .output()
83        .context("Failed to check if branch is merged")?;
84
85    if !output.status.success() {
86        anyhow::bail!("Failed to check if branch is merged");
87    }
88
89    let stdout = String::from_utf8_lossy(&output.stdout);
90    Ok(!stdout.trim().is_empty())
91}
92
93/// Checkout a specific branch or commit.
94/// If branch is "HEAD", it's a detached HEAD checkout.
95pub fn checkout_branch(branch: &str, dry_run: bool) -> Result<()> {
96    if dry_run {
97        return Ok(());
98    }
99
100    let output = Command::new("git")
101        .args(["checkout", branch])
102        .output()
103        .context("Failed to run git checkout")?;
104
105    if !output.status.success() {
106        let stderr = String::from_utf8_lossy(&output.stderr);
107        anyhow::bail!("Failed to checkout {}: {}", branch, stderr);
108    }
109
110    Ok(())
111}
112
113/// Check if branches have diverged (i.e., fast-forward is not possible).
114///
115/// Returns true if branches have diverged (fast-forward not possible).
116/// Returns false if a fast-forward merge is possible.
117///
118/// Fast-forward is possible when HEAD is an ancestor of or equal to spec_branch.
119/// Branches have diverged when HEAD has commits not in spec_branch.
120pub fn branches_have_diverged(spec_branch: &str) -> Result<bool> {
121    let output = Command::new("git")
122        .args(["merge-base", "--is-ancestor", "HEAD", spec_branch])
123        .output()
124        .context("Failed to check if branches have diverged")?;
125
126    // merge-base --is-ancestor returns 0 if HEAD is ancestor of spec_branch (fast-forward possible)
127    // Returns non-zero if HEAD is not ancestor of spec_branch (branches have diverged)
128    Ok(!output.status.success())
129}
130
131/// Result of a merge attempt with conflict details.
132#[derive(Debug)]
133pub struct MergeAttemptResult {
134    /// Whether merge succeeded
135    pub success: bool,
136    /// Type of conflict if any
137    pub conflict_type: Option<ConflictType>,
138    /// Files with conflicts if any
139    pub conflicting_files: Vec<String>,
140    /// Git stderr output
141    pub stderr: String,
142}
143
144/// Type of merge conflict encountered.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ConflictType {
147    /// Content conflict in file(s)
148    Content,
149    /// Fast-forward only merge failed due to diverged branches
150    FastForward,
151    /// Tree conflict (file vs directory, rename conflicts, etc)
152    Tree,
153    /// Unknown or unclassified conflict
154    Unknown,
155}
156
157impl fmt::Display for ConflictType {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            ConflictType::Content => write!(f, "content"),
161            ConflictType::FastForward => write!(f, "fast-forward"),
162            ConflictType::Tree => write!(f, "tree"),
163            ConflictType::Unknown => write!(f, "unknown"),
164        }
165    }
166}
167
168/// Merge a branch using appropriate strategy based on divergence.
169///
170/// Strategy:
171/// 1. Check if branches have diverged
172/// 2. If diverged: Use --no-ff to create a merge commit
173/// 3. If clean fast-forward possible: Use fast-forward merge
174/// 4. If conflicts exist: Return details about the conflict
175///
176/// Returns MergeAttemptResult with success status and conflict details.
177pub fn merge_branch_ff_only(spec_branch: &str, dry_run: bool) -> Result<MergeAttemptResult> {
178    if dry_run {
179        return Ok(MergeAttemptResult {
180            success: true,
181            conflict_type: None,
182            conflicting_files: vec![],
183            stderr: String::new(),
184        });
185    }
186
187    // Check if branches have diverged
188    let diverged = branches_have_diverged(spec_branch)?;
189
190    let merge_message = format!("Merge {}", spec_branch);
191
192    let mut cmd = Command::new("git");
193    if diverged {
194        // Branches have diverged - use --no-ff to create a merge commit
195        cmd.args(["merge", "--no-ff", spec_branch, "-m", &merge_message]);
196    } else {
197        // Can do fast-forward merge
198        cmd.args(["merge", "--ff-only", spec_branch]);
199    }
200
201    let output = cmd.output().context("Failed to run git merge")?;
202
203    if !output.status.success() {
204        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
205
206        // Get git status to classify conflict and find files
207        let status_output = Command::new("git")
208            .args(["status", "--porcelain"])
209            .output()
210            .ok()
211            .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
212
213        let conflict_type = classify_conflict_type(&stderr, status_output.as_deref());
214
215        let conflicting_files = status_output
216            .as_deref()
217            .map(parse_conflicting_files)
218            .unwrap_or_default();
219
220        // Abort the merge to restore clean state
221        let _ = Command::new("git").args(["merge", "--abort"]).output();
222
223        return Ok(MergeAttemptResult {
224            success: false,
225            conflict_type: Some(conflict_type),
226            conflicting_files,
227            stderr,
228        });
229    }
230
231    Ok(MergeAttemptResult {
232        success: true,
233        conflict_type: None,
234        conflicting_files: vec![],
235        stderr: String::new(),
236    })
237}
238
239/// Classify the type of merge conflict from stderr and status output.
240pub fn classify_conflict_type(stderr: &str, status_output: Option<&str>) -> ConflictType {
241    let stderr_lower = stderr.to_lowercase();
242
243    if stderr_lower.contains("not possible to fast-forward")
244        || stderr_lower.contains("cannot fast-forward")
245        || stderr_lower.contains("refusing to merge unrelated histories")
246    {
247        return ConflictType::FastForward;
248    }
249
250    if stderr_lower.contains("conflict (rename/delete)")
251        || stderr_lower.contains("conflict (modify/delete)")
252        || stderr_lower.contains("deleted in")
253        || stderr_lower.contains("renamed in")
254        || stderr_lower.contains("conflict (add/add)")
255    {
256        return ConflictType::Tree;
257    }
258
259    if let Some(status) = status_output {
260        if status.lines().any(|line| {
261            let prefix = line.get(..2).unwrap_or("");
262            matches!(prefix, "DD" | "AU" | "UD" | "UA" | "DU")
263        }) {
264            return ConflictType::Tree;
265        }
266
267        if status.lines().any(|line| {
268            let prefix = line.get(..2).unwrap_or("");
269            matches!(prefix, "UU" | "AA")
270        }) {
271            return ConflictType::Content;
272        }
273    }
274
275    if stderr_lower.contains("conflict") || stderr_lower.contains("merge conflict") {
276        return ConflictType::Content;
277    }
278
279    ConflictType::Unknown
280}
281
282/// Parse conflicting files from git status --porcelain output.
283pub fn parse_conflicting_files(status_output: &str) -> Vec<String> {
284    let mut files = Vec::new();
285
286    for line in status_output.lines() {
287        if line.len() >= 3 {
288            let status = &line[0..2];
289            // Conflict markers: UU, AA, DD, AU, UD, UA, DU
290            if status.contains('U') || status == "AA" || status == "DD" {
291                let file = line[3..].trim();
292                files.push(file.to_string());
293            }
294        }
295    }
296
297    files
298}
299
300/// Remove all worktrees associated with a branch.
301/// This is idempotent and won't fail if no worktrees exist.
302pub fn remove_worktrees_for_branch(branch_name: &str) -> Result<()> {
303    // List all worktrees
304    let output = Command::new("git")
305        .args(["worktree", "list", "--porcelain"])
306        .output()
307        .context("Failed to list worktrees")?;
308
309    if !output.status.success() {
310        // If git worktree list fails, just continue (maybe not a worktree-enabled repo)
311        return Ok(());
312    }
313
314    let worktree_list = String::from_utf8_lossy(&output.stdout);
315    let mut current_path: Option<String> = None;
316    let mut worktrees_to_remove = Vec::new();
317
318    // Parse the porcelain output to find worktrees for this branch
319    for line in worktree_list.lines() {
320        if line.starts_with("worktree ") {
321            current_path = Some(line.trim_start_matches("worktree ").to_string());
322        } else if line.starts_with("branch ") {
323            let branch = line
324                .trim_start_matches("branch ")
325                .trim_start_matches("refs/heads/");
326            if branch == branch_name {
327                if let Some(path) = current_path.take() {
328                    worktrees_to_remove.push(path);
329                }
330            }
331        }
332    }
333
334    // Remove each worktree associated with this branch
335    for path in worktrees_to_remove {
336        // Try with --force to handle any uncommitted changes
337        let _ = Command::new("git")
338            .args(["worktree", "remove", &path, "--force"])
339            .output();
340
341        // Also try to remove the directory if it still exists (in case git worktree remove failed)
342        let _ = std::fs::remove_dir_all(&path);
343    }
344
345    Ok(())
346}
347
348/// Delete a branch, removing associated worktrees first.
349/// Returns Ok(()) on success, or an error if deletion fails.
350pub fn delete_branch(branch_name: &str, dry_run: bool) -> Result<()> {
351    if dry_run {
352        return Ok(());
353    }
354
355    // Remove any worktrees associated with this branch before deleting it
356    remove_worktrees_for_branch(branch_name)?;
357
358    let output = Command::new("git")
359        .args(["branch", "-d", branch_name])
360        .output()
361        .context("Failed to run git branch -d")?;
362
363    if !output.status.success() {
364        let stderr = String::from_utf8_lossy(&output.stderr);
365        anyhow::bail!("Failed to delete branch {}: {}", branch_name, stderr);
366    }
367
368    Ok(())
369}
370
371/// Result of a rebase operation
372#[derive(Debug)]
373pub struct RebaseResult {
374    /// Whether rebase succeeded
375    pub success: bool,
376    /// Files with conflicts (if any)
377    pub conflicting_files: Vec<String>,
378}
379
380/// Rebase a branch onto another branch.
381/// Returns RebaseResult with success status and any conflicting files.
382pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
383    // First checkout the spec branch
384    checkout_branch(spec_branch, false)?;
385
386    // Attempt rebase
387    let output = Command::new("git")
388        .args(["rebase", onto_branch])
389        .output()
390        .context("Failed to run git rebase")?;
391
392    if output.status.success() {
393        return Ok(RebaseResult {
394            success: true,
395            conflicting_files: vec![],
396        });
397    }
398
399    // Rebase failed - check for conflicts
400    let stderr = String::from_utf8_lossy(&output.stderr);
401    if stderr.contains("CONFLICT") || stderr.contains("conflict") {
402        // Get list of conflicting files
403        let conflicting_files = get_conflicting_files()?;
404
405        // Abort rebase to restore clean state
406        let _ = Command::new("git").args(["rebase", "--abort"]).output();
407
408        return Ok(RebaseResult {
409            success: false,
410            conflicting_files,
411        });
412    }
413
414    // Other rebase error
415    let _ = Command::new("git").args(["rebase", "--abort"]).output();
416    anyhow::bail!("Rebase failed: {}", stderr);
417}
418
419/// Get list of files with conflicts from git status
420pub fn get_conflicting_files() -> Result<Vec<String>> {
421    let output = Command::new("git")
422        .args(["status", "--porcelain"])
423        .output()
424        .context("Failed to run git status")?;
425
426    let stdout = String::from_utf8_lossy(&output.stdout);
427    let mut files = Vec::new();
428
429    for line in stdout.lines() {
430        // Conflict markers: UU, AA, DD, AU, UD, UA, DU
431        if line.len() >= 3 {
432            let status = &line[0..2];
433            if status.contains('U') || status == "AA" || status == "DD" {
434                let file = line[3..].trim();
435                files.push(file.to_string());
436            }
437        }
438    }
439
440    Ok(files)
441}
442
443/// Continue a rebase after conflicts have been resolved
444pub fn rebase_continue() -> Result<bool> {
445    let output = Command::new("git")
446        .args(["rebase", "--continue"])
447        .env("GIT_EDITOR", "true") // Skip editor for commit message
448        .output()
449        .context("Failed to run git rebase --continue")?;
450
451    Ok(output.status.success())
452}
453
454/// Abort an in-progress rebase
455pub fn rebase_abort() -> Result<()> {
456    let _ = Command::new("git").args(["rebase", "--abort"]).output();
457    Ok(())
458}
459
460/// Stage a file for commit
461pub fn stage_file(file_path: &str) -> Result<()> {
462    let output = Command::new("git")
463        .args(["add", file_path])
464        .output()
465        .context("Failed to run git add")?;
466
467    if !output.status.success() {
468        let stderr = String::from_utf8_lossy(&output.stderr);
469        anyhow::bail!("Failed to stage file {}: {}", file_path, stderr);
470    }
471
472    Ok(())
473}
474
475/// Check if branch can be fast-forward merged into target branch.
476/// Returns true if the merge can be done as a fast-forward (no divergence).
477pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
478    // Get merge base between branch and target
479    let output = Command::new("git")
480        .args(["merge-base", target, branch])
481        .output()
482        .context("Failed to find merge base")?;
483
484    if !output.status.success() {
485        return Ok(false);
486    }
487
488    let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
489
490    // Get the commit hash of target
491    let output = Command::new("git")
492        .args(["rev-parse", target])
493        .output()
494        .context("Failed to get target commit")?;
495
496    if !output.status.success() {
497        return Ok(false);
498    }
499
500    let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
501
502    // If merge base equals target, then branch is ahead and can ff-merge
503    Ok(merge_base == target_commit)
504}
505
506/// Check if branch is behind target branch.
507/// Returns true if target has commits that branch doesn't have.
508pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
509    // Get merge base
510    let output = Command::new("git")
511        .args(["merge-base", branch, target])
512        .output()
513        .context("Failed to find merge base")?;
514
515    if !output.status.success() {
516        return Ok(false);
517    }
518
519    let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
520
521    // Get branch commit
522    let output = Command::new("git")
523        .args(["rev-parse", branch])
524        .output()
525        .context("Failed to get branch commit")?;
526
527    if !output.status.success() {
528        return Ok(false);
529    }
530
531    let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
532
533    // If merge base equals branch commit, then branch is behind target
534    Ok(merge_base == branch_commit)
535}
536
537/// Count number of commits in branch.
538pub fn count_commits(branch: &str) -> Result<usize> {
539    let output = Command::new("git")
540        .args(["rev-list", "--count", branch])
541        .output()
542        .context("Failed to count commits")?;
543
544    if !output.status.success() {
545        return Ok(0);
546    }
547
548    let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
549    Ok(count_str.parse().unwrap_or(0))
550}
551
552/// Information about a single git commit.
553#[derive(Debug, Clone)]
554pub struct CommitInfo {
555    pub hash: String,
556    pub message: String,
557    pub author: String,
558    pub timestamp: i64,
559}
560
561/// Get commits in a range between two refs.
562///
563/// Returns commits between `from_ref` and `to_ref` (inclusive of `to_ref`, exclusive of `from_ref`).
564/// Uses `git log from_ref..to_ref` format.
565///
566/// # Errors
567/// Returns error if refs are invalid or git command fails.
568pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
569    let range = format!("{}..{}", from_ref, to_ref);
570
571    let output = Command::new("git")
572        .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
573        .output()
574        .context("Failed to execute git log")?;
575
576    if !output.status.success() {
577        let stderr = String::from_utf8_lossy(&output.stderr);
578        anyhow::bail!("Invalid git refs {}: {}", range, stderr);
579    }
580
581    let stdout = String::from_utf8_lossy(&output.stdout);
582    let mut commits = Vec::new();
583
584    for line in stdout.lines() {
585        if line.is_empty() {
586            continue;
587        }
588
589        let parts: Vec<&str> = line.splitn(4, '|').collect();
590        if parts.len() != 4 {
591            continue;
592        }
593
594        commits.push(CommitInfo {
595            hash: parts[0].to_string(),
596            author: parts[1].to_string(),
597            timestamp: parts[2].parse().unwrap_or(0),
598            message: parts[3].to_string(),
599        });
600    }
601
602    Ok(commits)
603}
604
605/// Get files changed in a specific commit.
606///
607/// Returns a list of file paths that were modified in the commit.
608///
609/// # Errors
610/// Returns error if commit hash is invalid or git command fails.
611pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
612    let output = Command::new("git")
613        .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
614        .output()
615        .context("Failed to execute git diff-tree")?;
616
617    if !output.status.success() {
618        let stderr = String::from_utf8_lossy(&output.stderr);
619        anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
620    }
621
622    let stdout = String::from_utf8_lossy(&output.stdout);
623    let files: Vec<String> = stdout
624        .lines()
625        .filter(|line| !line.is_empty())
626        .map(|line| line.to_string())
627        .collect();
628
629    Ok(files)
630}
631
632/// Get files changed in a commit with their status (A/M/D).
633///
634/// Returns a list of strings in the format "STATUS:filename" (e.g., "A:file.txt", "M:file.txt").
635///
636/// # Errors
637/// Returns error if commit hash is invalid or git command fails.
638pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
639    let output = Command::new("git")
640        .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
641        .output()
642        .context("Failed to execute git diff-tree")?;
643
644    if !output.status.success() {
645        return Ok(Vec::new());
646    }
647
648    let stdout = String::from_utf8_lossy(&output.stdout);
649    let mut files = Vec::new();
650
651    for line in stdout.lines() {
652        let parts: Vec<&str> = line.split('\t').collect();
653        if parts.len() >= 2 {
654            // parts[0] is status (A, M, D), parts[1] is filename
655            files.push(format!("{}:{}", parts[0], parts[1]));
656        }
657    }
658
659    Ok(files)
660}
661
662/// Get file content at a specific commit.
663///
664/// Returns the file content as a string, or an empty string if the file doesn't exist at that commit.
665///
666/// # Errors
667/// Returns error if git command fails.
668pub fn get_file_at_commit(commit: &str, file: &str) -> Result<String> {
669    let output = Command::new("git")
670        .args(["show", &format!("{}:{}", commit, file)])
671        .output()
672        .context("Failed to get file at commit")?;
673
674    if !output.status.success() {
675        return Ok(String::new());
676    }
677
678    Ok(String::from_utf8_lossy(&output.stdout).to_string())
679}
680
681/// Get file content at parent commit.
682///
683/// Returns the file content as a string, or an empty string if the file doesn't exist at parent commit.
684///
685/// # Errors
686/// Returns error if git command fails.
687pub fn get_file_at_parent(commit: &str, file: &str) -> Result<String> {
688    let output = Command::new("git")
689        .args(["show", &format!("{}^:{}", commit, file)])
690        .output()
691        .context("Failed to get file at parent")?;
692
693    if !output.status.success() {
694        return Ok(String::new());
695    }
696
697    Ok(String::from_utf8_lossy(&output.stdout).to_string())
698}
699
700/// Get the N most recent commits.
701///
702/// # Errors
703/// Returns error if git command fails.
704pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
705    let count_str = count.to_string();
706
707    let output = Command::new("git")
708        .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
709        .output()
710        .context("Failed to execute git log")?;
711
712    if !output.status.success() {
713        let stderr = String::from_utf8_lossy(&output.stderr);
714        anyhow::bail!("Failed to get recent commits: {}", stderr);
715    }
716
717    let stdout = String::from_utf8_lossy(&output.stdout);
718    let mut commits = Vec::new();
719
720    for line in stdout.lines() {
721        if line.is_empty() {
722            continue;
723        }
724
725        let parts: Vec<&str> = line.splitn(4, '|').collect();
726        if parts.len() != 4 {
727            continue;
728        }
729
730        commits.push(CommitInfo {
731            hash: parts[0].to_string(),
732            author: parts[1].to_string(),
733            timestamp: parts[2].parse().unwrap_or(0),
734            message: parts[3].to_string(),
735        });
736    }
737
738    Ok(commits)
739}
740
741/// Get commits that modified a specific path.
742///
743/// # Arguments
744/// * `path` - File or directory path to filter by
745///
746/// # Errors
747/// Returns error if git command fails.
748pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
749    let output = Command::new("git")
750        .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
751        .output()
752        .context("Failed to execute git log")?;
753
754    if !output.status.success() {
755        let stderr = String::from_utf8_lossy(&output.stderr);
756        anyhow::bail!("git log failed: {}", stderr);
757    }
758
759    let stdout = String::from_utf8_lossy(&output.stdout);
760    let mut commits = Vec::new();
761
762    for line in stdout.lines() {
763        if line.is_empty() {
764            continue;
765        }
766
767        let parts: Vec<&str> = line.splitn(4, '|').collect();
768        if parts.len() != 4 {
769            continue;
770        }
771
772        commits.push(CommitInfo {
773            hash: parts[0].to_string(),
774            author: parts[1].to_string(),
775            timestamp: parts[2].parse().unwrap_or(0),
776            message: parts[3].to_string(),
777        });
778    }
779
780    Ok(commits)
781}