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 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 let count: u32 = match right.split_whitespace().next().and_then(|s| s.parse().ok()) {
755 Some(n) => n,
756 None => continue, };
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
914fn 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 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 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 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
960fn 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 !id.is_empty() && !id.contains('|') && !id.contains("changed") && id.len() <= 16 {
974 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 }
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
1012fn 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 ¤t.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}