Skip to main content

taskers_core/
vcs.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    process::Command,
5};
6
7use anyhow::{Context, Result, anyhow, bail};
8use taskers_control::{
9    VcsCommand, VcsCommandResult, VcsCommitEntry, VcsFileEntry, VcsFileStatus, VcsMode,
10    VcsPullRequestInfo, VcsRefEntry, VcsSnapshot,
11};
12use taskers_domain::{AppModel, PaneKind, SurfaceId};
13
14const GIT_RECENT_COMMIT_LIMIT: &str = "50";
15
16#[derive(Clone, Default)]
17pub struct VcsService;
18
19#[derive(Debug, Clone)]
20struct RepoTarget {
21    surface_id: SurfaceId,
22    cwd: PathBuf,
23    repo_root: PathBuf,
24    mode: VcsMode,
25}
26
27impl VcsService {
28    pub fn execute(&self, model: &AppModel, command: VcsCommand) -> Result<VcsCommandResult> {
29        match command {
30            VcsCommand::Refresh {
31                surface_id,
32                diff_path,
33            } => Ok(VcsCommandResult {
34                snapshot: Some(self.snapshot_for_surface(model, surface_id, diff_path)?),
35                message: None,
36            }),
37            VcsCommand::GitCommit {
38                surface_id,
39                message,
40            } => {
41                let target = self.resolve_target(model, surface_id)?;
42                ensure_mode(target.mode, VcsMode::Git, "git commit")?;
43                ensure_non_empty(&message, "commit message")?;
44                let _output =
45                    run_command(&target.repo_root, "git", &["commit", "-m", message.trim()])?;
46                Ok(VcsCommandResult {
47                    snapshot: Some(self.snapshot_from_target(&target, None)?),
48                    message: None,
49                })
50            }
51            VcsCommand::GitCreateBranch { surface_id, name } => {
52                let target = self.resolve_target(model, surface_id)?;
53                ensure_mode(target.mode, VcsMode::Git, "git branch create")?;
54                ensure_non_empty(&name, "branch name")?;
55                let _output =
56                    run_command(&target.repo_root, "git", &["switch", "-c", name.trim()])?;
57                Ok(VcsCommandResult {
58                    snapshot: Some(self.snapshot_from_target(&target, None)?),
59                    message: None,
60                })
61            }
62            VcsCommand::GitSwitchBranch { surface_id, name } => {
63                let target = self.resolve_target(model, surface_id)?;
64                ensure_mode(target.mode, VcsMode::Git, "git branch switch")?;
65                ensure_non_empty(&name, "branch name")?;
66                let _output = run_command(&target.repo_root, "git", &["switch", name.trim()])?;
67                Ok(VcsCommandResult {
68                    snapshot: Some(self.snapshot_from_target(&target, None)?),
69                    message: None,
70                })
71            }
72            VcsCommand::GitFetch { surface_id } => {
73                let target = self.resolve_target(model, surface_id)?;
74                ensure_mode(target.mode, VcsMode::Git, "git fetch")?;
75                let _output =
76                    run_command(&target.repo_root, "git", &["fetch", "--all", "--prune"])?;
77                Ok(VcsCommandResult {
78                    snapshot: Some(self.snapshot_from_target(&target, None)?),
79                    message: None,
80                })
81            }
82            VcsCommand::GitPull { surface_id } => {
83                let target = self.resolve_target(model, surface_id)?;
84                ensure_mode(target.mode, VcsMode::Git, "git pull")?;
85                let _output = run_command(&target.repo_root, "git", &["pull", "--ff-only"])?;
86                Ok(VcsCommandResult {
87                    snapshot: Some(self.snapshot_from_target(&target, None)?),
88                    message: None,
89                })
90            }
91            VcsCommand::GitPush { surface_id } => {
92                let target = self.resolve_target(model, surface_id)?;
93                ensure_mode(target.mode, VcsMode::Git, "git push")?;
94                let _output = run_command(&target.repo_root, "git", &["push"])?;
95                Ok(VcsCommandResult {
96                    snapshot: Some(self.snapshot_from_target(&target, None)?),
97                    message: None,
98                })
99            }
100            VcsCommand::JjDescribe {
101                surface_id,
102                message,
103            } => {
104                let target = self.resolve_target(model, surface_id)?;
105                ensure_mode(target.mode, VcsMode::Jj, "jj describe")?;
106                ensure_non_empty(&message, "change description")?;
107                let _output =
108                    run_command(&target.repo_root, "jj", &["describe", "-m", message.trim()])?;
109                Ok(VcsCommandResult {
110                    snapshot: Some(self.snapshot_from_target(&target, None)?),
111                    message: None,
112                })
113            }
114            VcsCommand::JjNew {
115                surface_id,
116                message,
117            } => {
118                let target = self.resolve_target(model, surface_id)?;
119                ensure_mode(target.mode, VcsMode::Jj, "jj new")?;
120                let _output = if let Some(message) =
121                    message.as_deref().map(str::trim).filter(|s| !s.is_empty())
122                {
123                    run_command(&target.repo_root, "jj", &["new", "-m", message])?
124                } else {
125                    run_command(&target.repo_root, "jj", &["new"])?
126                };
127                Ok(VcsCommandResult {
128                    snapshot: Some(self.snapshot_from_target(&target, None)?),
129                    message: None,
130                })
131            }
132            VcsCommand::JjCreateBookmark { surface_id, name } => {
133                let target = self.resolve_target(model, surface_id)?;
134                ensure_mode(target.mode, VcsMode::Jj, "jj bookmark create")?;
135                ensure_non_empty(&name, "bookmark name")?;
136                let _output = run_command(
137                    &target.repo_root,
138                    "jj",
139                    &["bookmark", "create", name.trim()],
140                )?;
141                Ok(VcsCommandResult {
142                    snapshot: Some(self.snapshot_from_target(&target, None)?),
143                    message: None,
144                })
145            }
146            VcsCommand::JjSwitchBookmark { surface_id, name } => {
147                let target = self.resolve_target(model, surface_id)?;
148                ensure_mode(target.mode, VcsMode::Jj, "jj edit")?;
149                ensure_non_empty(&name, "bookmark name")?;
150                let _output = run_command(&target.repo_root, "jj", &["edit", name.trim()])?;
151                Ok(VcsCommandResult {
152                    snapshot: Some(self.snapshot_from_target(&target, None)?),
153                    message: None,
154                })
155            }
156            VcsCommand::JjFetch { surface_id } => {
157                let target = self.resolve_target(model, surface_id)?;
158                ensure_mode(target.mode, VcsMode::Jj, "jj git fetch")?;
159                let _output =
160                    run_command(&target.repo_root, "jj", &["git", "fetch", "--all-remotes"])?;
161                Ok(VcsCommandResult {
162                    snapshot: Some(self.snapshot_from_target(&target, None)?),
163                    message: None,
164                })
165            }
166            VcsCommand::JjPush { surface_id } => {
167                let target = self.resolve_target(model, surface_id)?;
168                ensure_mode(target.mode, VcsMode::Jj, "jj git push")?;
169                let _output = run_command(&target.repo_root, "jj", &["git", "push"])?;
170                Ok(VcsCommandResult {
171                    snapshot: Some(self.snapshot_from_target(&target, None)?),
172                    message: None,
173                })
174            }
175        }
176    }
177
178    fn snapshot_for_surface(
179        &self,
180        model: &AppModel,
181        surface_id: SurfaceId,
182        diff_path: Option<String>,
183    ) -> Result<VcsSnapshot> {
184        let target = self.resolve_target(model, surface_id)?;
185        self.snapshot_from_target(&target, diff_path)
186    }
187
188    fn snapshot_from_target(
189        &self,
190        target: &RepoTarget,
191        diff_path: Option<String>,
192    ) -> Result<VcsSnapshot> {
193        match target.mode {
194            VcsMode::Git => self.git_snapshot(target, diff_path),
195            VcsMode::Jj => self.jj_snapshot(target, diff_path),
196        }
197    }
198
199    fn resolve_target(&self, model: &AppModel, surface_id: SurfaceId) -> Result<RepoTarget> {
200        let surface = model
201            .workspaces
202            .values()
203            .flat_map(|workspace| workspace.panes.values())
204            .flat_map(|pane| pane.surfaces.values())
205            .find(|surface| surface.id == surface_id)
206            .ok_or_else(|| anyhow!("surface {surface_id} not found"))?;
207        if surface.kind != PaneKind::Terminal {
208            bail!("surface {surface_id} is not a terminal");
209        }
210        let cwd = surface
211            .metadata
212            .cwd
213            .as_deref()
214            .filter(|cwd| !cwd.trim().is_empty())
215            .ok_or_else(|| anyhow!("terminal has no current working directory"))?;
216        let cwd = PathBuf::from(cwd);
217        let (repo_root, mode) = resolve_repo_root(&cwd)?;
218        Ok(RepoTarget {
219            surface_id,
220            cwd,
221            repo_root,
222            mode,
223        })
224    }
225
226    fn git_snapshot(&self, target: &RepoTarget, diff_path: Option<String>) -> Result<VcsSnapshot> {
227        let status = run_command(
228            &target.repo_root,
229            "git",
230            &["status", "--porcelain=v2", "--branch"],
231        )?;
232        let git_status = parse_git_status(&status.stdout);
233        let refs = parse_git_branches(
234            &run_command(
235                &target.repo_root,
236                "git",
237                &["branch", "--format=%(refname:short)|%(HEAD)"],
238            )?
239            .stdout,
240        );
241        let pull_request = github_pull_request(&target.repo_root, git_status.branch.as_deref())?;
242        let diff_text = diff_path
243            .as_deref()
244            .map(|path| git_diff_preview(&target.repo_root, path))
245            .transpose()?;
246        let summary_text = git_summary_text(&git_status);
247        let unstaged_stats = parse_git_numstat(
248            &run_command(&target.repo_root, "git", &["diff", "--numstat", "-z"])
249                .map(|o| o.stdout)
250                .unwrap_or_default(),
251        );
252        let staged_stats = parse_git_numstat(
253            &run_command(
254                &target.repo_root,
255                "git",
256                &["diff", "--numstat", "--cached", "-z"],
257            )
258            .map(|o| o.stdout)
259            .unwrap_or_default(),
260        );
261        let mut files = git_status.files;
262        let (total_insertions, total_deletions) =
263            enrich_git_files_with_stats(&mut files, &staged_stats, &unstaged_stats);
264        // Try upstream..HEAD first, fall back to origin/HEAD..HEAD, then empty
265        let commits_raw = run_command(
266            &target.repo_root,
267            "git",
268            &git_recent_commit_log_args("@{upstream}..HEAD"),
269        )
270        .or_else(|_| {
271            run_command(
272                &target.repo_root,
273                "git",
274                &git_recent_commit_log_args("origin/HEAD..HEAD"),
275            )
276        })
277        .map(|o| o.stdout)
278        .unwrap_or_default();
279        let recent_commits = parse_git_log_shortstat(&commits_raw);
280        Ok(VcsSnapshot {
281            surface_id: target.surface_id,
282            mode: VcsMode::Git,
283            repo_root: target.repo_root.display().to_string(),
284            repo_name: repo_name(&target.repo_root),
285            cwd: target.cwd.display().to_string(),
286            headline: git_status
287                .branch
288                .clone()
289                .unwrap_or_else(|| "detached HEAD".into()),
290            detail: if git_status.detached {
291                git_status.head_oid.clone()
292            } else {
293                None
294            },
295            summary_text,
296            files,
297            refs,
298            diff_path,
299            diff_text,
300            pull_request,
301            total_insertions,
302            total_deletions,
303            recent_commits,
304        })
305    }
306
307    fn jj_snapshot(&self, target: &RepoTarget, diff_path: Option<String>) -> Result<VcsSnapshot> {
308        let status = run_command(&target.repo_root, "jj", &["status", "--color=never"])?;
309        let current = load_jj_current(&target.repo_root)?;
310        let refs = parse_jj_bookmarks(
311            &run_command(
312                &target.repo_root,
313                "jj",
314                &["bookmark", "list", "--color=never"],
315            )?
316            .stdout,
317            current.bookmarks.as_slice(),
318        );
319        let mut files = parse_jj_diff_summary(
320            &run_command(
321                &target.repo_root,
322                "jj",
323                &["diff", "--summary", "--color=never"],
324            )?
325            .stdout,
326        );
327        let stat_output = run_command(
328            &target.repo_root,
329            "jj",
330            &["diff", "--stat", "--color=never"],
331        )
332        .map(|o| o.stdout)
333        .unwrap_or_default();
334        let stat_map = parse_jj_diff_stat(&stat_output);
335        let (total_insertions, total_deletions) = enrich_files_with_stats(&mut files, &stat_map);
336        let diff_text = diff_path
337            .as_deref()
338            .map(|path| jj_diff_preview(&target.repo_root, path));
339        let current_bookmark = current.bookmarks.first().cloned();
340        let pull_request = github_pull_request(&target.repo_root, current_bookmark.as_deref())?;
341        let recent_commits = parse_jj_log_stat(
342            &run_command(
343                &target.repo_root,
344                "jj",
345                &[
346                    "log", "--no-graph", "--color=never", "--stat",
347                    "-T", r#"change_id.short(8) ++ "\t" ++ if(description, description.first_line(), "(no description)") ++ "\n""#,
348                    "-r", "remote_bookmarks()..@",
349                ],
350            )
351            .map(|o| o.stdout)
352            .unwrap_or_default(),
353        );
354        Ok(VcsSnapshot {
355            surface_id: target.surface_id,
356            mode: VcsMode::Jj,
357            repo_root: target.repo_root.display().to_string(),
358            repo_name: repo_name(&target.repo_root),
359            cwd: target.cwd.display().to_string(),
360            headline: current
361                .bookmarks
362                .first()
363                .cloned()
364                .unwrap_or_else(|| current.change_id.clone()),
365            detail: Some(format!("{} ยท {}", current.change_id, current.description)),
366            summary_text: trim_output(&status.stdout, &status.stderr),
367            files,
368            refs,
369            diff_path,
370            diff_text,
371            pull_request,
372            total_insertions,
373            total_deletions,
374            recent_commits,
375        })
376    }
377}
378
379#[derive(Debug, Default)]
380struct ParsedGitStatus {
381    branch: Option<String>,
382    head_oid: Option<String>,
383    detached: bool,
384    files: Vec<VcsFileEntry>,
385}
386
387#[derive(Debug, Default)]
388struct ParsedJjCurrent {
389    change_id: String,
390    description: String,
391    bookmarks: Vec<String>,
392}
393
394fn ensure_mode(actual: VcsMode, expected: VcsMode, action: &str) -> Result<()> {
395    if actual == expected {
396        return Ok(());
397    }
398    bail!("{action} is unavailable in {:?} mode", actual);
399}
400
401fn ensure_non_empty(value: &str, label: &str) -> Result<()> {
402    if value.trim().is_empty() {
403        bail!("{label} cannot be empty");
404    }
405    Ok(())
406}
407
408fn resolve_repo_root(cwd: &Path) -> Result<(PathBuf, VcsMode)> {
409    for ancestor in cwd.ancestors() {
410        let has_jj = ancestor.join(".jj").is_dir();
411        let git_marker = ancestor.join(".git");
412        let has_git = git_marker.is_dir() || git_marker.is_file();
413
414        if has_jj {
415            return Ok((ancestor.to_path_buf(), VcsMode::Jj));
416        }
417
418        if has_git {
419            return Ok((ancestor.to_path_buf(), VcsMode::Git));
420        }
421    }
422
423    bail!("no git or jj repository found from {}", cwd.display())
424}
425
426fn repo_name(repo_root: &Path) -> String {
427    repo_root
428        .file_name()
429        .and_then(|name| name.to_str())
430        .unwrap_or("repo")
431        .to_string()
432}
433
434struct CommandOutput {
435    stdout: String,
436    stderr: String,
437}
438
439fn run_command(cwd: &Path, program: &str, args: &[&str]) -> Result<CommandOutput> {
440    let output = Command::new(program)
441        .args(args)
442        .current_dir(cwd)
443        .output()
444        .with_context(|| format!("failed to run {program} {}", args.join(" ")))?;
445    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
446    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
447    if !output.status.success() {
448        let detail = trim_output(&stdout, &stderr);
449        bail!(
450            "{} {} failed{}",
451            program,
452            args.join(" "),
453            if detail.is_empty() {
454                String::new()
455            } else {
456                format!(": {detail}")
457            }
458        );
459    }
460    Ok(CommandOutput { stdout, stderr })
461}
462
463fn trim_output(stdout: &str, stderr: &str) -> String {
464    let stdout = stdout.trim();
465    let stderr = stderr.trim();
466    match (stdout.is_empty(), stderr.is_empty()) {
467        (false, true) => stdout.to_string(),
468        (true, false) => stderr.to_string(),
469        (false, false) if stdout == stderr => stdout.to_string(),
470        (false, false) => format!("{stdout}\n{stderr}"),
471        (true, true) => String::new(),
472    }
473}
474
475fn load_jj_current(repo_root: &Path) -> Result<ParsedJjCurrent> {
476    let change_id = run_command(
477        repo_root,
478        "jj",
479        &[
480            "log",
481            "-r",
482            "@",
483            "--no-graph",
484            "-T",
485            "change_id.short(8)",
486            "--color=never",
487        ],
488    )?;
489    let description = run_command(
490        repo_root,
491        "jj",
492        &[
493            "log",
494            "-r",
495            "@",
496            "--no-graph",
497            "-T",
498            "description.first_line()",
499            "--color=never",
500        ],
501    )?;
502    let bookmarks = run_command(
503        repo_root,
504        "jj",
505        &[
506            "log",
507            "-r",
508            "@",
509            "--no-graph",
510            "-T",
511            r#"bookmarks.join("\n")"#,
512            "--color=never",
513        ],
514    )?;
515    Ok(parse_jj_current(
516        &change_id.stdout,
517        &description.stdout,
518        &bookmarks.stdout,
519    ))
520}
521
522fn parse_git_status(raw: &str) -> ParsedGitStatus {
523    let mut parsed = ParsedGitStatus::default();
524    for line in raw.lines() {
525        if let Some(value) = line.strip_prefix("# branch.head ") {
526            if value == "(detached)" {
527                parsed.detached = true;
528            } else {
529                parsed.branch = Some(value.to_string());
530            }
531            continue;
532        }
533        if let Some(value) = line.strip_prefix("# branch.oid ") {
534            if value != "(initial)" {
535                parsed.head_oid = Some(value.chars().take(8).collect());
536            }
537            continue;
538        }
539        if let Some(path) = line.strip_prefix("? ") {
540            parsed.files.push(VcsFileEntry {
541                path: path.to_string(),
542                status: VcsFileStatus::Untracked,
543                staged: false,
544                insertions: None,
545                deletions: None,
546            });
547            continue;
548        }
549        if let Some(rest) = line.strip_prefix("1 ") {
550            let mut parts = rest.splitn(8, ' ');
551            let xy = parts.next().unwrap_or("..");
552            let path = parts.nth(6).unwrap_or_default().to_string();
553            if !path.is_empty() {
554                parsed.files.extend(git_file_entries(path, xy, None));
555            }
556            continue;
557        }
558        if let Some(rest) = line.strip_prefix("2 ") {
559            let mut parts = rest.splitn(9, ' ');
560            let xy = parts.next().unwrap_or("..");
561            let paths = parts.nth(7).unwrap_or_default();
562            let mut names = paths.split('\t');
563            let path = names.next().unwrap_or_default().to_string();
564            if !path.is_empty() {
565                parsed.files.extend(git_file_entries(path, xy, None));
566            }
567            continue;
568        }
569        if let Some(rest) = line.strip_prefix("u ") {
570            let mut parts = rest.splitn(10, ' ');
571            let _ = parts.next();
572            let path = parts.nth(8).unwrap_or_default().to_string();
573            if !path.is_empty() {
574                parsed.files.push(VcsFileEntry {
575                    path,
576                    status: VcsFileStatus::Conflicted,
577                    staged: false,
578                    insertions: None,
579                    deletions: None,
580                });
581            }
582        }
583    }
584    parsed
585}
586
587fn git_file_entries(
588    path: String,
589    xy: &str,
590    override_status: Option<VcsFileStatus>,
591) -> Vec<VcsFileEntry> {
592    let chars: Vec<char> = xy.chars().collect();
593    let index = chars.first().copied().unwrap_or('.');
594    let worktree = chars.get(1).copied().unwrap_or('.');
595    let mut entries = Vec::new();
596    if index != '.' {
597        entries.push(VcsFileEntry {
598            path: path.clone(),
599            status: override_status.unwrap_or_else(|| map_git_status(index)),
600            staged: true,
601            insertions: None,
602            deletions: None,
603        });
604    }
605    if worktree != '.' {
606        entries.push(VcsFileEntry {
607            path,
608            status: override_status.unwrap_or_else(|| map_git_status(worktree)),
609            staged: false,
610            insertions: None,
611            deletions: None,
612        });
613    }
614    entries
615}
616
617fn map_git_status(value: char) -> VcsFileStatus {
618    match value {
619        'A' => VcsFileStatus::Added,
620        'D' => VcsFileStatus::Deleted,
621        'R' => VcsFileStatus::Renamed,
622        'C' => VcsFileStatus::Copied,
623        'U' => VcsFileStatus::Conflicted,
624        'M' | 'T' => VcsFileStatus::Modified,
625        _ => VcsFileStatus::Changed,
626    }
627}
628
629fn parse_git_branches(raw: &str) -> Vec<VcsRefEntry> {
630    raw.lines()
631        .filter_map(|line| {
632            let (name, head) = line.split_once('|')?;
633            Some(VcsRefEntry {
634                name: name.trim().to_string(),
635                active: head.trim() == "*",
636            })
637        })
638        .collect()
639}
640
641fn git_summary_text(status: &ParsedGitStatus) -> String {
642    if status.files.is_empty() {
643        match (&status.branch, status.detached) {
644            (Some(branch), false) => format!("{branch} ยท working tree clean"),
645            _ => "Working tree clean".into(),
646        }
647    } else {
648        format!("{} file changes", status.files.len())
649    }
650}
651
652fn git_diff_preview(repo_root: &Path, path: &str) -> Result<String> {
653    let unstaged = run_command(repo_root, "git", &["diff", "--no-ext-diff", "--", path])
654        .map(|output| output.stdout)
655        .unwrap_or_default();
656    let staged = run_command(
657        repo_root,
658        "git",
659        &["diff", "--no-ext-diff", "--cached", "--", path],
660    )
661    .map(|output| output.stdout)
662    .unwrap_or_default();
663    let combined = [staged.trim(), unstaged.trim()]
664        .into_iter()
665        .filter(|value| !value.is_empty())
666        .collect::<Vec<_>>()
667        .join("\n\n");
668    Ok(combined)
669}
670
671fn jj_diff_preview(repo_root: &Path, path: &str) -> String {
672    run_command(repo_root, "jj", &["diff", "--color=never", "--", path])
673        .map(|output| trim_output(&output.stdout, &output.stderr))
674        .unwrap_or_default()
675}
676
677fn parse_jj_current(
678    change_id_raw: &str,
679    description_raw: &str,
680    bookmarks_raw: &str,
681) -> ParsedJjCurrent {
682    let change_id = change_id_raw.trim().to_string();
683    let description = description_raw.trim().to_string();
684    let bookmarks = bookmarks_raw
685        .lines()
686        .map(str::trim)
687        .filter(|value| !value.is_empty())
688        .map(str::to_string)
689        .collect::<Vec<_>>();
690    ParsedJjCurrent {
691        change_id,
692        description,
693        bookmarks,
694    }
695}
696
697fn parse_jj_bookmarks(raw: &str, active: &[String]) -> Vec<VcsRefEntry> {
698    raw.lines()
699        .filter_map(|line| {
700            let (name, _) = line.split_once(':')?;
701            let name = name.trim();
702            (!name.is_empty()).then(|| VcsRefEntry {
703                name: name.to_string(),
704                active: active.iter().any(|bookmark| bookmark == name),
705            })
706        })
707        .collect()
708}
709
710fn parse_jj_diff_summary(raw: &str) -> Vec<VcsFileEntry> {
711    raw.lines()
712        .filter_map(|line| {
713            if line.trim().is_empty() {
714                return None;
715            }
716            let separator = line.char_indices().find(|(_, ch)| ch.is_whitespace())?.0;
717            let status = line[..separator].trim();
718            let path = line[separator..]
719                .trim_start_matches(char::is_whitespace)
720                .to_string();
721            if path.is_empty() {
722                return None;
723            }
724            Some(VcsFileEntry {
725                path,
726                status: match status {
727                    "A" => VcsFileStatus::Added,
728                    "D" => VcsFileStatus::Deleted,
729                    "M" => VcsFileStatus::Modified,
730                    "R" => VcsFileStatus::Renamed,
731                    "C" => VcsFileStatus::Copied,
732                    _ => VcsFileStatus::Changed,
733                },
734                staged: false,
735                insertions: None,
736                deletions: None,
737            })
738        })
739        .collect()
740}
741
742fn parse_jj_diff_stat(raw: &str) -> HashMap<String, (u32, u32)> {
743    let mut stats = HashMap::new();
744    for line in raw.lines() {
745        let Some((left, right)) = line.split_once('|') else {
746            continue;
747        };
748        let path = left.trim().to_string();
749        if path.is_empty() {
750            continue;
751        }
752        let right = right.trim();
753        // First token after | is the total change count
754        let count: u32 = match right.split_whitespace().next().and_then(|s| s.parse().ok()) {
755            Some(n) => n,
756            None => continue, // summary line or unparseable
757        };
758        let plus_count = right.chars().filter(|&c| c == '+').count() as u32;
759        let minus_count = right.chars().filter(|&c| c == '-').count() as u32;
760        let total_chars = plus_count + minus_count;
761        if total_chars == 0 {
762            continue;
763        }
764        let insertions = count * plus_count / total_chars;
765        let deletions = count - insertions;
766        stats.insert(path, (insertions, deletions));
767    }
768    stats
769}
770
771fn parse_git_numstat(raw: &str) -> HashMap<String, (u32, u32)> {
772    if raw.contains('\0') {
773        return parse_git_numstat_z(raw);
774    }
775
776    raw.lines()
777        .filter_map(|line| {
778            let mut parts = line.splitn(3, '\t');
779            let ins: u32 = parts.next()?.parse().ok()?;
780            let del: u32 = parts.next()?.parse().ok()?;
781            let path = normalize_git_numstat_path(parts.next()?);
782            Some((path, (ins, del)))
783        })
784        .collect()
785}
786
787fn parse_git_numstat_z(raw: &str) -> HashMap<String, (u32, u32)> {
788    let mut stats = HashMap::new();
789    let mut fields = raw.split('\0');
790
791    while let Some(header) = fields.next() {
792        if header.is_empty() {
793            continue;
794        }
795        let mut parts = header.splitn(3, '\t');
796        let ins: u32 = match parts.next().and_then(|value| value.parse().ok()) {
797            Some(value) => value,
798            None => continue,
799        };
800        let del: u32 = match parts.next().and_then(|value| value.parse().ok()) {
801            Some(value) => value,
802            None => continue,
803        };
804        let Some(path_field) = parts.next() else {
805            continue;
806        };
807        let path = if path_field.is_empty() {
808            let Some(_old) = fields.next() else {
809                break;
810            };
811            let Some(destination) = fields.next() else {
812                break;
813            };
814            normalize_git_numstat_path(destination)
815        } else {
816            normalize_git_numstat_path(path_field)
817        };
818        stats.insert(path, (ins, del));
819    }
820
821    stats
822}
823
824fn normalize_git_numstat_path(path: &str) -> String {
825    if !path.contains(" => ") {
826        return path.to_string();
827    }
828
829    if path.contains('{') && path.contains('}') {
830        let mut normalized = String::new();
831        let mut rest = path;
832        while let Some(start) = rest.find('{') {
833            normalized.push_str(&rest[..start]);
834            let after_start = &rest[start + 1..];
835            let Some(end) = after_start.find('}') else {
836                return path.to_string();
837            };
838            let inner = &after_start[..end];
839            if let Some((_, destination)) = inner.split_once(" => ") {
840                normalized.push_str(destination.trim());
841            } else {
842                normalized.push_str(inner);
843            }
844            rest = &after_start[end + 1..];
845        }
846        normalized.push_str(rest);
847        return normalized;
848    }
849
850    path.rsplit_once(" => ")
851        .map(|(_, destination)| destination.trim().to_string())
852        .unwrap_or_else(|| path.to_string())
853}
854
855fn git_recent_commit_log_args(range: &str) -> [&str; 6] {
856    [
857        "log",
858        "-n",
859        GIT_RECENT_COMMIT_LIMIT,
860        "--format=%h\t%s",
861        "--shortstat",
862        range,
863    ]
864}
865
866fn enrich_files_with_stats(
867    files: &mut [VcsFileEntry],
868    stats: &HashMap<String, (u32, u32)>,
869) -> (u32, u32) {
870    let mut total_ins = 0u32;
871    let mut total_del = 0u32;
872    for file in files.iter_mut() {
873        if let Some(&(ins, del)) = stats.get(&file.path) {
874            file.insertions = Some(ins);
875            file.deletions = Some(del);
876            total_ins += ins;
877            total_del += del;
878        }
879    }
880    (total_ins, total_del)
881}
882
883fn enrich_git_files_with_stats(
884    files: &mut [VcsFileEntry],
885    staged_stats: &HashMap<String, (u32, u32)>,
886    unstaged_stats: &HashMap<String, (u32, u32)>,
887) -> (u32, u32) {
888    for file in files.iter_mut() {
889        let stats = if file.staged {
890            staged_stats.get(&file.path)
891        } else {
892            unstaged_stats.get(&file.path)
893        };
894        if let Some(&(ins, del)) = stats {
895            file.insertions = Some(ins);
896            file.deletions = Some(del);
897        }
898    }
899    let total_ins = staged_stats
900        .values()
901        .chain(unstaged_stats.values())
902        .map(|(ins, _)| ins)
903        .copied()
904        .sum();
905    let total_del = staged_stats
906        .values()
907        .chain(unstaged_stats.values())
908        .map(|(_, del)| del)
909        .copied()
910        .sum();
911    (total_ins, total_del)
912}
913
914/// Parse `git log --format="%h%x09%s" --shortstat -n N` output.
915/// Lines alternate between "hash\tdescription" and "N files changed, X insertions(+), Y deletions(-)".
916fn parse_git_log_shortstat(raw: &str) -> Vec<VcsCommitEntry> {
917    let mut commits = Vec::new();
918    let mut current_id = String::new();
919    let mut current_desc = String::new();
920    for line in raw.lines() {
921        let trimmed = line.trim();
922        if trimmed.is_empty() {
923            continue;
924        }
925        if let Some((hash, desc)) = trimmed.split_once('\t') {
926            // Flush previous commit if pending
927            if !current_id.is_empty() {
928                commits.push(VcsCommitEntry {
929                    id: std::mem::take(&mut current_id),
930                    description: std::mem::take(&mut current_desc),
931                    insertions: 0,
932                    deletions: 0,
933                });
934            }
935            current_id = hash.to_string();
936            current_desc = desc.to_string();
937        } else if trimmed.contains("changed") {
938            // Stat summary line: "N file(s) changed, X insertion(s)(+), Y deletion(s)(-)"
939            let (ins, del) = parse_shortstat_line(trimmed);
940            commits.push(VcsCommitEntry {
941                id: std::mem::take(&mut current_id),
942                description: std::mem::take(&mut current_desc),
943                insertions: ins,
944                deletions: del,
945            });
946        }
947    }
948    // Flush last commit without stats
949    if !current_id.is_empty() {
950        commits.push(VcsCommitEntry {
951            id: current_id,
952            description: current_desc,
953            insertions: 0,
954            deletions: 0,
955        });
956    }
957    commits
958}
959
960/// Parse `jj log --no-graph --color=never --stat -T 'template'` output.
961/// Template produces "change_id\tdescription" lines interleaved with stat output.
962fn parse_jj_log_stat(raw: &str) -> Vec<VcsCommitEntry> {
963    let mut commits = Vec::new();
964    let mut current_id = String::new();
965    let mut current_desc = String::new();
966    for line in raw.lines() {
967        let trimmed = line.trim();
968        if trimmed.is_empty() {
969            continue;
970        }
971        if let Some((id, desc)) = trimmed.split_once('\t') {
972            // If the "id" part looks like a short change ID (alphanumeric, no spaces, no |)
973            if !id.is_empty() && !id.contains('|') && !id.contains("changed") && id.len() <= 16 {
974                // Flush previous
975                if !current_id.is_empty() {
976                    commits.push(VcsCommitEntry {
977                        id: std::mem::take(&mut current_id),
978                        description: std::mem::take(&mut current_desc),
979                        insertions: 0,
980                        deletions: 0,
981                    });
982                }
983                current_id = id.to_string();
984                current_desc = desc.to_string();
985                continue;
986            }
987        }
988        if trimmed.contains("changed")
989            && (trimmed.contains("insertion") || trimmed.contains("deletion"))
990        {
991            let (ins, del) = parse_shortstat_line(trimmed);
992            commits.push(VcsCommitEntry {
993                id: std::mem::take(&mut current_id),
994                description: std::mem::take(&mut current_desc),
995                insertions: ins,
996                deletions: del,
997            });
998        }
999        // Skip per-file stat lines (contain |)
1000    }
1001    if !current_id.is_empty() {
1002        commits.push(VcsCommitEntry {
1003            id: current_id,
1004            description: current_desc,
1005            insertions: 0,
1006            deletions: 0,
1007        });
1008    }
1009    commits
1010}
1011
1012/// Extract insertions/deletions from a shortstat summary line like
1013/// "3 files changed, 10 insertions(+), 5 deletions(-)"
1014fn parse_shortstat_line(line: &str) -> (u32, u32) {
1015    let mut ins = 0u32;
1016    let mut del = 0u32;
1017    let words: Vec<&str> = line.split_whitespace().collect();
1018    for window in words.windows(2) {
1019        if window[1].starts_with("insertion") {
1020            ins = window[0].parse().unwrap_or(0);
1021        } else if window[1].starts_with("deletion") {
1022            del = window[0].parse().unwrap_or(0);
1023        }
1024    }
1025    (ins, del)
1026}
1027
1028fn github_pull_request(repo_root: &Path, head: Option<&str>) -> Result<Option<VcsPullRequestInfo>> {
1029    let Some(head) = head.filter(|value| !value.trim().is_empty()) else {
1030        return Ok(None);
1031    };
1032    if !command_exists("gh") {
1033        return Ok(None);
1034    }
1035    let origin = run_command(repo_root, "git", &["remote", "get-url", "origin"])
1036        .map(|output| output.stdout)
1037        .unwrap_or_default();
1038    if !origin.contains("github.com") {
1039        return Ok(None);
1040    }
1041    let output = Command::new("gh")
1042        .args(["pr", "view", head, "--json", "number,title,url,state"])
1043        .current_dir(repo_root)
1044        .output()
1045        .context("failed to run gh pr view")?;
1046    if !output.status.success() {
1047        return Ok(None);
1048    }
1049    let value: serde_json::Value =
1050        serde_json::from_slice(&output.stdout).context("failed to parse gh pr view json")?;
1051    Ok(Some(VcsPullRequestInfo {
1052        number: value
1053            .get("number")
1054            .and_then(|value| value.as_u64())
1055            .map(|value| value as u32),
1056        title: value
1057            .get("title")
1058            .and_then(|value| value.as_str())
1059            .map(str::to_string),
1060        url: value
1061            .get("url")
1062            .and_then(|value| value.as_str())
1063            .unwrap_or_default()
1064            .to_string(),
1065        state: value
1066            .get("state")
1067            .and_then(|value| value.as_str())
1068            .map(str::to_string),
1069    }))
1070}
1071
1072fn command_exists(program: &str) -> bool {
1073    std::env::var_os("PATH")
1074        .is_some_and(|paths| std::env::split_paths(&paths).any(|path| path.join(program).is_file()))
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use std::{collections::HashMap, fs};
1080
1081    use super::{
1082        GIT_RECENT_COMMIT_LIMIT, enrich_files_with_stats, enrich_git_files_with_stats,
1083        git_recent_commit_log_args, jj_diff_preview, parse_git_log_shortstat, parse_git_numstat,
1084        parse_git_status, parse_jj_bookmarks, parse_jj_current, parse_jj_diff_stat,
1085        parse_jj_diff_summary, parse_jj_log_stat, resolve_repo_root,
1086    };
1087    use taskers_control::{VcsFileEntry, VcsFileStatus, VcsMode};
1088    use tempfile::TempDir;
1089
1090    #[test]
1091    fn parses_git_porcelain_v2_changes() {
1092        let parsed = parse_git_status(
1093            "# branch.oid 1234567890\n# branch.head main\n1 M. N... 100644 100644 100644 abc abc file with spaces.txt\n2 RM N... 100644 100644 100644 abc def R100 renamed file.txt\toriginal file.txt\n? new.rs\nu UU N... 100644 100644 100644 100644 abc abc abc conflict file.rs\n",
1094        );
1095        assert_eq!(parsed.branch.as_deref(), Some("main"));
1096        assert_eq!(parsed.files.len(), 5);
1097        assert_eq!(parsed.files[0].path, "file with spaces.txt");
1098        assert_eq!(parsed.files[0].status, VcsFileStatus::Modified);
1099        assert!(parsed.files[0].staged);
1100        assert_eq!(parsed.files[1].path, "renamed file.txt");
1101        assert_eq!(parsed.files[1].status, VcsFileStatus::Renamed);
1102        assert!(parsed.files[1].staged);
1103        assert_eq!(parsed.files[2].path, "renamed file.txt");
1104        assert_eq!(parsed.files[2].status, VcsFileStatus::Modified);
1105        assert!(!parsed.files[2].staged);
1106        assert_eq!(parsed.files[3].status, VcsFileStatus::Untracked);
1107        assert_eq!(parsed.files[4].path, "conflict file.rs");
1108        assert_eq!(parsed.files[4].status, VcsFileStatus::Conflicted);
1109    }
1110
1111    #[test]
1112    fn parses_jj_current_and_bookmarks() {
1113        let current = parse_jj_current(
1114            "abcd1234\n",
1115            "feat: title | with pipe\n",
1116            "main\nfeature-x\n",
1117        );
1118        assert_eq!(current.change_id, "abcd1234");
1119        assert_eq!(current.description, "feat: title | with pipe");
1120        assert_eq!(current.bookmarks, vec!["main", "feature-x"]);
1121
1122        let bookmarks = parse_jj_bookmarks(
1123            "main: xyz 123 base\nfeature-x: xyz 456 work\n",
1124            &current.bookmarks,
1125        );
1126        assert_eq!(bookmarks.len(), 2);
1127        assert!(bookmarks[0].active);
1128        assert!(bookmarks[1].active);
1129    }
1130
1131    #[test]
1132    fn parses_jj_diff_summary_without_normalizing_whitespace() {
1133        let parsed = parse_jj_diff_summary("M a  b.txt\nA tab\tname.txt\n");
1134        assert_eq!(parsed.len(), 2);
1135        assert_eq!(parsed[0].path, "a  b.txt");
1136        assert_eq!(parsed[0].status, VcsFileStatus::Modified);
1137        assert_eq!(parsed[1].path, "tab\tname.txt");
1138        assert_eq!(parsed[1].status, VcsFileStatus::Added);
1139    }
1140
1141    #[test]
1142    fn resolves_jj_repo_root_without_git_metadata() {
1143        let temp = TempDir::new().expect("tempdir");
1144        let repo_root = temp.path().join("repo");
1145        let cwd = repo_root.join("nested/work");
1146        fs::create_dir_all(repo_root.join(".jj")).expect("jj dir");
1147        fs::create_dir_all(&cwd).expect("cwd");
1148
1149        let (resolved_root, mode) = resolve_repo_root(&cwd).expect("resolve repo root");
1150        assert_eq!(resolved_root, repo_root);
1151        assert_eq!(mode, VcsMode::Jj);
1152    }
1153
1154    #[test]
1155    fn prefers_same_root_jj_marker_over_git_marker() {
1156        let temp = TempDir::new().expect("tempdir");
1157        let repo_root = temp.path().join("repo");
1158        let cwd = repo_root.join("nested/work");
1159        fs::create_dir_all(repo_root.join(".jj")).expect("jj dir");
1160        fs::create_dir_all(repo_root.join(".git")).expect("git dir");
1161        fs::create_dir_all(&cwd).expect("cwd");
1162
1163        let (resolved_root, mode) = resolve_repo_root(&cwd).expect("resolve repo root");
1164        assert_eq!(resolved_root, repo_root);
1165        assert_eq!(mode, VcsMode::Jj);
1166    }
1167
1168    #[test]
1169    fn prefers_nearest_git_marker_over_parent_jj_repo() {
1170        let temp = TempDir::new().expect("tempdir");
1171        let outer_root = temp.path().join("outer");
1172        let git_root = outer_root.join("nested-git");
1173        let cwd = git_root.join("src");
1174        fs::create_dir_all(outer_root.join(".jj")).expect("outer jj dir");
1175        fs::create_dir_all(git_root.join(".git")).expect("git dir");
1176        fs::create_dir_all(&cwd).expect("cwd");
1177
1178        let (resolved_root, mode) = resolve_repo_root(&cwd).expect("resolve repo root");
1179        assert_eq!(resolved_root, git_root);
1180        assert_eq!(mode, VcsMode::Git);
1181    }
1182
1183    #[test]
1184    fn jj_diff_preview_returns_empty_when_preview_fails() {
1185        let temp = TempDir::new().expect("tempdir");
1186        assert_eq!(jj_diff_preview(temp.path(), "missing.txt"), "");
1187    }
1188
1189    #[test]
1190    fn parses_jj_diff_stat_output() {
1191        let raw = "src/main.rs  | 12 ++++++------\nsrc/lib.rs   |  4 ++++\n2 files changed, 10 insertions(+), 6 deletions(-)\n";
1192        let stats = parse_jj_diff_stat(raw);
1193        assert_eq!(stats.len(), 2);
1194        let (ins, del) = stats["src/main.rs"];
1195        assert_eq!(ins, 6);
1196        assert_eq!(del, 6);
1197        let (ins, del) = stats["src/lib.rs"];
1198        assert_eq!(ins, 4);
1199        assert_eq!(del, 0);
1200    }
1201
1202    #[test]
1203    fn parses_git_numstat_output() {
1204        let raw = "10\t5\tsrc/main.rs\n3\t0\tREADME.md\n-\t-\tbinary.png\n";
1205        let stats = parse_git_numstat(raw);
1206        assert_eq!(stats.len(), 2);
1207        assert_eq!(stats["src/main.rs"], (10, 5));
1208        assert_eq!(stats["README.md"], (3, 0));
1209        assert!(!stats.contains_key("binary.png"));
1210    }
1211
1212    #[test]
1213    fn parses_git_numstat_rename_paths_to_destination_names() {
1214        let raw = concat!(
1215            "0\t0\told.txt => new.txt\n",
1216            "5\t2\tsrc/{before => after}.rs\n",
1217        );
1218        let stats = parse_git_numstat(raw);
1219        assert_eq!(stats["new.txt"], (0, 0));
1220        assert_eq!(stats["src/after.rs"], (5, 2));
1221    }
1222
1223    #[test]
1224    fn parses_git_numstat_z_rename_records() {
1225        let raw = "0\t0\t\0old.txt\0new.txt\05\t2\tsrc/main.rs\0";
1226        let stats = parse_git_numstat(raw);
1227        assert_eq!(stats["new.txt"], (0, 0));
1228        assert_eq!(stats["src/main.rs"], (5, 2));
1229    }
1230
1231    #[test]
1232    fn preserves_literal_filenames_containing_arrow_text() {
1233        let plain = "1\t0\ta=>b.txt\n";
1234        let plain_stats = parse_git_numstat(plain);
1235        assert_eq!(plain_stats["a=>b.txt"], (1, 0));
1236
1237        let nul = "2\t1\ta=>b.txt\0";
1238        let nul_stats = parse_git_numstat(nul);
1239        assert_eq!(nul_stats["a=>b.txt"], (2, 1));
1240    }
1241
1242    #[test]
1243    fn git_recent_commit_log_args_include_a_limit() {
1244        assert_eq!(
1245            git_recent_commit_log_args("@{upstream}..HEAD"),
1246            [
1247                "log",
1248                "-n",
1249                GIT_RECENT_COMMIT_LIMIT,
1250                "--format=%h\t%s",
1251                "--shortstat",
1252                "@{upstream}..HEAD",
1253            ]
1254        );
1255    }
1256
1257    #[test]
1258    fn parses_git_log_shortstat() {
1259        let raw = "abc1234\tfix: handle edge case\n\n 3 files changed, 10 insertions(+), 5 deletions(-)\n\ndef5678\tfeat: add new feature\n\n 1 file changed, 20 insertions(+)\n";
1260        let commits = parse_git_log_shortstat(raw);
1261        assert_eq!(commits.len(), 2);
1262        assert_eq!(commits[0].id, "abc1234");
1263        assert_eq!(commits[0].description, "fix: handle edge case");
1264        assert_eq!(commits[0].insertions, 10);
1265        assert_eq!(commits[0].deletions, 5);
1266        assert_eq!(commits[1].id, "def5678");
1267        assert_eq!(commits[1].insertions, 20);
1268        assert_eq!(commits[1].deletions, 0);
1269    }
1270
1271    #[test]
1272    fn parses_git_log_shortstat_with_statless_commit_before_next_entry() {
1273        let raw = "abc1234\tchore: empty change\n\ndef5678\tfeat: add widget\n\n 1 file changed, 2 insertions(+), 1 deletion(-)\n";
1274        let commits = parse_git_log_shortstat(raw);
1275        assert_eq!(commits.len(), 2);
1276        assert_eq!(commits[0].id, "abc1234");
1277        assert_eq!(commits[0].insertions, 0);
1278        assert_eq!(commits[0].deletions, 0);
1279        assert_eq!(commits[1].id, "def5678");
1280        assert_eq!(commits[1].insertions, 2);
1281        assert_eq!(commits[1].deletions, 1);
1282    }
1283
1284    #[test]
1285    fn parses_jj_log_stat() {
1286        let raw = "abcd1234\tfix: centralize state\nsrc/main.rs | 12 ++++++------\nsrc/lib.rs  |  4 ++++\n2 files changed, 10 insertions(+), 6 deletions(-)\nefgh5678\tfeat: add widget\nwidget.rs | 30 ++++++++++++++++++++++++++++++\n1 file changed, 30 insertions(+), 0 deletions(-)\n";
1287        let commits = parse_jj_log_stat(raw);
1288        assert_eq!(commits.len(), 2);
1289        assert_eq!(commits[0].id, "abcd1234");
1290        assert_eq!(commits[0].insertions, 10);
1291        assert_eq!(commits[0].deletions, 6);
1292        assert_eq!(commits[1].id, "efgh5678");
1293        assert_eq!(commits[1].insertions, 30);
1294        assert_eq!(commits[1].deletions, 0);
1295    }
1296
1297    #[test]
1298    fn enriches_matching_files_with_stats_and_totals() {
1299        let mut files = vec![
1300            VcsFileEntry {
1301                path: "src/main.rs".into(),
1302                status: VcsFileStatus::Modified,
1303                staged: false,
1304                insertions: None,
1305                deletions: None,
1306            },
1307            VcsFileEntry {
1308                path: "README.md".into(),
1309                status: VcsFileStatus::Added,
1310                staged: true,
1311                insertions: None,
1312                deletions: None,
1313            },
1314        ];
1315        let stats = HashMap::from([
1316            ("src/main.rs".to_string(), (5, 2)),
1317            ("ignored.rs".to_string(), (9, 9)),
1318        ]);
1319
1320        let (total_ins, total_del) = enrich_files_with_stats(&mut files, &stats);
1321
1322        assert_eq!((total_ins, total_del), (5, 2));
1323        assert_eq!(files[0].insertions, Some(5));
1324        assert_eq!(files[0].deletions, Some(2));
1325        assert_eq!(files[1].insertions, None);
1326        assert_eq!(files[1].deletions, None);
1327    }
1328
1329    #[test]
1330    fn enriches_git_stats_without_double_counting_split_entries() {
1331        let mut files = vec![
1332            VcsFileEntry {
1333                path: "src/main.rs".into(),
1334                status: VcsFileStatus::Modified,
1335                staged: true,
1336                insertions: None,
1337                deletions: None,
1338            },
1339            VcsFileEntry {
1340                path: "src/main.rs".into(),
1341                status: VcsFileStatus::Modified,
1342                staged: false,
1343                insertions: None,
1344                deletions: None,
1345            },
1346            VcsFileEntry {
1347                path: "README.md".into(),
1348                status: VcsFileStatus::Added,
1349                staged: false,
1350                insertions: None,
1351                deletions: None,
1352            },
1353        ];
1354        let staged_stats = HashMap::from([("src/main.rs".to_string(), (2, 1))]);
1355        let unstaged_stats = HashMap::from([
1356            ("src/main.rs".to_string(), (3, 0)),
1357            ("README.md".to_string(), (4, 0)),
1358        ]);
1359
1360        let (total_ins, total_del) =
1361            enrich_git_files_with_stats(&mut files, &staged_stats, &unstaged_stats);
1362
1363        assert_eq!((total_ins, total_del), (9, 1));
1364        assert_eq!(files[0].insertions, Some(2));
1365        assert_eq!(files[0].deletions, Some(1));
1366        assert_eq!(files[1].insertions, Some(3));
1367        assert_eq!(files[1].deletions, Some(0));
1368        assert_eq!(files[2].insertions, Some(4));
1369        assert_eq!(files[2].deletions, Some(0));
1370    }
1371}