Skip to main content

gitgraph_core/
service.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use regex::Regex;
6
7use crate::actions::{ActionCatalog, ActionContext, ActionRequest, ResolvedAction};
8use crate::error::{GitLgError, Result};
9use crate::git::{GitOutput, GitRunner};
10use crate::log_parser::{FIELD_SEP, build_graph_rows, parse_git_log_records};
11use crate::models::{BlameInfo, BranchInfo, CommitSearchQuery, FileChange, GraphData, GraphQuery};
12use crate::search::filter_commits;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ActionExecutionResult {
16    pub action_id: String,
17    pub command_line: String,
18    pub args: Vec<String>,
19    pub output: GitOutput,
20}
21
22#[derive(Debug, Clone)]
23pub struct GitLgService {
24    git: GitRunner,
25    actions: ActionCatalog,
26}
27
28impl GitLgService {
29    pub fn new(git: GitRunner, actions: ActionCatalog) -> Self {
30        Self { git, actions }
31    }
32
33    pub fn with_default_actions(git: GitRunner) -> Self {
34        Self::new(git, ActionCatalog::with_defaults())
35    }
36
37    pub fn actions(&self) -> &ActionCatalog {
38        &self.actions
39    }
40
41    pub fn graph(&self, repo_path: &Path, query: &GraphQuery) -> Result<GraphData> {
42        self.git.validate_repo(repo_path)?;
43        let log_output = self.run_log(repo_path, query)?;
44        let commits = build_graph_rows(parse_git_log_records(&log_output.stdout)?);
45        let branches = self.read_branches(repo_path)?;
46        Ok(GraphData {
47            repository: normalize_repo_path(repo_path),
48            generated_at_unix: current_unix_timestamp(),
49            query: query.clone(),
50            commits,
51            branches,
52        })
53    }
54
55    pub fn graph_filtered(
56        &self,
57        repo_path: &Path,
58        query: &GraphQuery,
59        search_query: &CommitSearchQuery,
60    ) -> Result<GraphData> {
61        let mut data = self.graph(repo_path, query)?;
62        let has_search_text = !search_query.text.trim().is_empty();
63        if let Some(file_path) = search_query
64            .file_path
65            .as_deref()
66            .map(str::trim)
67            .filter(|p| !p.is_empty())
68        {
69            data.commits = self.filter_commits_by_file_contents(
70                repo_path,
71                data.commits,
72                search_query,
73                file_path,
74            )?;
75        } else if has_search_text {
76            data.commits = filter_commits(&data.commits, search_query)?;
77        }
78        Ok(data)
79    }
80
81    pub fn execute_action(
82        &self,
83        repo_path: &Path,
84        request: ActionRequest,
85        default_remote_name: &str,
86    ) -> Result<ActionExecutionResult> {
87        let request = normalize_action_request(request, &self.actions, default_remote_name);
88        let resolved = self.actions.resolve_with_lookup(request, |placeholder| {
89            self.lookup_dynamic_placeholder(repo_path, placeholder)
90        })?;
91        let output = if let Some(script) = &resolved.shell_script {
92            let script = format!("{} {}", self.git.git_binary(), script);
93            self.git
94                .exec_shell(repo_path, &script, resolved.allow_non_zero_exit)?
95        } else {
96            self.git
97                .exec(repo_path, &resolved.args, resolved.allow_non_zero_exit)?
98        };
99        Ok(ActionExecutionResult {
100            action_id: resolved.id,
101            command_line: resolved.command_line,
102            args: resolved.args,
103            output,
104        })
105    }
106
107    pub fn resolve_action_preview(
108        &self,
109        request: ActionRequest,
110        default_remote_name: &str,
111        repo_path: Option<&Path>,
112    ) -> Result<ResolvedAction> {
113        let request = normalize_action_request(request, &self.actions, default_remote_name);
114        self.actions.resolve_with_lookup(request, |placeholder| {
115            if let Some(repo_path) = repo_path {
116                return self.lookup_dynamic_placeholder(repo_path, placeholder);
117            }
118            Ok(None)
119        })
120    }
121
122    pub fn blame_line(&self, repo_path: &Path, file: &Path, line: usize) -> Result<BlameInfo> {
123        let line_no = line.max(1);
124        let repo = normalize_repo_path(repo_path);
125        let repo_file = normalize_repo_file_input(&repo, file);
126        let out = self.git.exec(
127            &repo,
128            &[
129                "blame".to_string(),
130                format!("-L{line_no},{line_no}"),
131                "--porcelain".to_string(),
132                "--".to_string(),
133                repo_file.to_string_lossy().to_string(),
134            ],
135            false,
136        )?;
137        let mut commit_hash = String::new();
138        let mut author_name = String::new();
139        let mut author_email = String::new();
140        let mut author_time_unix: i64 = 0;
141        let mut summary = String::new();
142        for (idx, line) in out.stdout.lines().enumerate() {
143            if idx == 0 {
144                commit_hash = line
145                    .split_whitespace()
146                    .next()
147                    .unwrap_or_default()
148                    .to_string();
149                continue;
150            }
151            if let Some(v) = line.strip_prefix("author ") {
152                author_name = v.to_string();
153                continue;
154            }
155            if let Some(v) = line.strip_prefix("author-mail ") {
156                author_email = v.trim_matches(['<', '>']).to_string();
157                continue;
158            }
159            if let Some(v) = line.strip_prefix("author-time ") {
160                author_time_unix = v.parse::<i64>().unwrap_or(0);
161                continue;
162            }
163            if let Some(v) = line.strip_prefix("summary ") {
164                summary = v.to_string();
165                continue;
166            }
167        }
168
169        Ok(BlameInfo {
170            file: repo.join(repo_file),
171            line: line_no,
172            commit_hash,
173            author_name,
174            author_email,
175            author_time_unix,
176            summary,
177        })
178    }
179
180    pub fn commit_file_changes(
181        &self,
182        repo_path: &Path,
183        commit_hash: &str,
184    ) -> Result<Vec<FileChange>> {
185        self.git.validate_repo(repo_path)?;
186        let out = self.git.exec(
187            repo_path,
188            &[
189                "-c".to_string(),
190                "color.ui=never".to_string(),
191                "-c".to_string(),
192                "core.quotePath=false".to_string(),
193                "show".to_string(),
194                "--numstat".to_string(),
195                "--no-color".to_string(),
196                "--no-ext-diff".to_string(),
197                "--format=".to_string(),
198                "--find-renames".to_string(),
199                "--find-copies".to_string(),
200                commit_hash.to_string(),
201            ],
202            false,
203        )?;
204        let mut files = Vec::new();
205        for line in out.stdout.lines() {
206            let trimmed = line.trim();
207            if trimmed.is_empty() {
208                continue;
209            }
210            let mut parts = trimmed.splitn(3, '\t');
211            let Some(added_raw) = parts.next() else {
212                continue;
213            };
214            let Some(removed_raw) = parts.next() else {
215                continue;
216            };
217            let Some(path) = parts.next() else {
218                continue;
219            };
220
221            files.push(FileChange {
222                path: path.to_string(),
223                added: parse_numstat_value(added_raw),
224                removed: parse_numstat_value(removed_raw),
225            });
226        }
227        Ok(files)
228    }
229
230    pub fn commit_file_patch(
231        &self,
232        repo_path: &Path,
233        commit_hash: &str,
234        file_path: &str,
235        context_lines: usize,
236    ) -> Result<String> {
237        self.git.validate_repo(repo_path)?;
238        let normalized_path = normalize_numstat_path(file_path);
239        let mut candidate_paths = vec![file_path.trim().to_string()];
240        if normalized_path != file_path.trim() {
241            candidate_paths.push(normalized_path);
242        }
243
244        for candidate in candidate_paths {
245            if candidate.is_empty() {
246                continue;
247            }
248            if let Some(patch) = self.try_commit_file_patch(
249                repo_path,
250                commit_hash,
251                &candidate,
252                context_lines,
253                false,
254            )? {
255                return Ok(patch);
256            }
257            if let Some(patch) =
258                self.try_commit_file_patch(repo_path, commit_hash, &candidate, context_lines, true)?
259            {
260                return Ok(patch);
261            }
262        }
263
264        Ok(String::new())
265    }
266
267    fn try_commit_file_patch(
268        &self,
269        repo_path: &Path,
270        commit_hash: &str,
271        file_path: &str,
272        context_lines: usize,
273        split_merge_parents: bool,
274    ) -> Result<Option<String>> {
275        let mut args = vec![
276            "-c".to_string(),
277            "color.ui=never".to_string(),
278            "-c".to_string(),
279            "core.quotePath=false".to_string(),
280            "show".to_string(),
281            "--patch".to_string(),
282            "--no-color".to_string(),
283            "--no-ext-diff".to_string(),
284            "--format=".to_string(),
285            "--find-renames".to_string(),
286            "--find-copies".to_string(),
287            format!("--unified={context_lines}"),
288        ];
289        if split_merge_parents {
290            args.push("-m".to_string());
291        }
292        args.push(commit_hash.to_string());
293        args.push("--".to_string());
294        args.push(file_path.to_string());
295
296        let out = self.git.exec(repo_path, &args, false)?;
297        if out.stdout.trim().is_empty() {
298            return Ok(None);
299        }
300        Ok(Some(out.stdout))
301    }
302
303    fn run_log(&self, repo_path: &Path, query: &GraphQuery) -> Result<GitOutput> {
304        let mut args = vec![
305            "-c".to_string(),
306            "color.ui=never".to_string(),
307            "log".to_string(),
308            "--date-order".to_string(),
309            "--topo-order".to_string(),
310            "--decorate=full".to_string(),
311            "--color=never".to_string(),
312            format!(
313                "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%ct%x1f%D%x1f%s%x1f%b%x1e"
314            ),
315            "--no-show-signature".to_string(),
316            "--no-notes".to_string(),
317            "-n".to_string(),
318            query.limit.to_string(),
319            "--skip".to_string(),
320            query.skip.to_string(),
321        ];
322
323        if query.all_refs {
324            args.push("--all".to_string());
325        }
326        if query.include_stash_ref && self.has_stash_ref(repo_path)? {
327            args.push("refs/stash".to_string());
328        }
329        args.extend(query.additional_args.clone());
330        self.git.exec(repo_path, &args, false)
331    }
332
333    fn has_stash_ref(&self, repo_path: &Path) -> Result<bool> {
334        let out = self.git.exec(
335            repo_path,
336            &[
337                "show-ref".to_string(),
338                "--verify".to_string(),
339                "--quiet".to_string(),
340                "refs/stash".to_string(),
341            ],
342            true,
343        )?;
344        Ok(out.exit_code == Some(0))
345    }
346
347    fn read_branches(&self, repo_path: &Path) -> Result<Vec<BranchInfo>> {
348        let out = self.git.exec(
349            repo_path,
350            &[
351                "branch".to_string(),
352                "--list".to_string(),
353                "--all".to_string(),
354                "--sort=-committerdate".to_string(),
355                format!("--format=%(upstream:remotename){FIELD_SEP}%(refname)"),
356            ],
357            false,
358        )?;
359        let branches = out
360            .stdout
361            .lines()
362            .filter_map(|line| {
363                let trimmed = line.trim();
364                if trimmed.is_empty() {
365                    return None;
366                }
367                let mut parts = trimmed.splitn(2, FIELD_SEP);
368                let remote_name = parts.next().map(str::trim).unwrap_or_default();
369                let full_ref = parts.next().map(str::trim).unwrap_or_default();
370                if full_ref.is_empty() {
371                    return None;
372                }
373                let is_remote = full_ref.starts_with("refs/remotes/");
374                let name = full_ref
375                    .strip_prefix("refs/heads/")
376                    .or_else(|| full_ref.strip_prefix("refs/remotes/"))
377                    .unwrap_or(full_ref)
378                    .to_string();
379                Some(BranchInfo {
380                    name,
381                    full_ref: full_ref.to_string(),
382                    is_remote,
383                    remote_name: if remote_name.is_empty() {
384                        None
385                    } else {
386                        Some(remote_name.to_string())
387                    },
388                })
389            })
390            .collect();
391        Ok(branches)
392    }
393
394    fn lookup_dynamic_placeholder(
395        &self,
396        repo_path: &Path,
397        placeholder: &str,
398    ) -> Result<Option<String>> {
399        if let Some(key) = placeholder.strip_prefix("GIT_CONFIG:") {
400            let out = self.git.exec(
401                repo_path,
402                &["config".to_string(), "--get".to_string(), key.to_string()],
403                true,
404            )?;
405            return Ok(Some(out.stdout.trim().to_string()));
406        }
407        if let Some(raw_args) = placeholder.strip_prefix("GIT_EXEC:") {
408            let args = shlex::split(raw_args).unwrap_or_else(|| {
409                raw_args
410                    .split_whitespace()
411                    .map(ToString::to_string)
412                    .collect()
413            });
414            if args.is_empty() {
415                return Ok(Some(String::new()));
416            }
417            let out = self.git.exec(repo_path, &args, true)?;
418            return Ok(Some(out.stdout.trim().to_string()));
419        }
420        Ok(None)
421    }
422
423    fn filter_commits_by_file_contents(
424        &self,
425        repo_path: &Path,
426        rows: Vec<crate::models::GraphRow>,
427        search_query: &CommitSearchQuery,
428        file_path: &str,
429    ) -> Result<Vec<crate::models::GraphRow>> {
430        if rows.is_empty() || search_query.text.trim().is_empty() {
431            return Ok(rows);
432        }
433
434        let normalized_path = file_path.replace('\\', "/");
435        let mut matched_hashes = HashSet::new();
436        for chunk in rows.chunks(200) {
437            let mut args = vec!["grep".to_string()];
438            if search_query.use_regex {
439                args.push("-E".to_string());
440            } else {
441                args.push("-F".to_string());
442            }
443            if !search_query.case_sensitive {
444                args.push("-i".to_string());
445            }
446            args.push("-n".to_string());
447            args.push("-e".to_string());
448            args.push(search_query.text.clone());
449            args.extend(chunk.iter().map(|row| row.hash.clone()));
450            args.push("--".to_string());
451            args.push(normalized_path.clone());
452
453            let out = self.git.exec(repo_path, &args, true)?;
454            if !matches!(out.exit_code, Some(0) | Some(1)) {
455                return Err(GitLgError::GitCommandFailed {
456                    program: self.git.git_binary().to_string(),
457                    args,
458                    exit_code: out.exit_code,
459                    stderr: out.stderr,
460                    stdout: out.stdout,
461                });
462            }
463            for line in out.stdout.lines() {
464                if let Some((hash, _)) = line.split_once(':') {
465                    matched_hashes.insert(hash.to_string());
466                }
467            }
468        }
469
470        if matched_hashes.is_empty() {
471            return Ok(Vec::new());
472        }
473        Ok(rows
474            .into_iter()
475            .filter(|row| matched_hashes.contains(&row.hash))
476            .collect())
477    }
478}
479
480fn normalize_repo_path(repo_path: &Path) -> PathBuf {
481    repo_path
482        .canonicalize()
483        .unwrap_or_else(|_| repo_path.to_path_buf())
484}
485
486fn current_unix_timestamp() -> i64 {
487    SystemTime::now()
488        .duration_since(UNIX_EPOCH)
489        .map(|d| d.as_secs() as i64)
490        .unwrap_or(0)
491}
492
493fn merge_default_context(mut context: ActionContext, default_remote_name: &str) -> ActionContext {
494    if context.remote_name.is_none() {
495        context.remote_name = Some(default_remote_name.to_string());
496    }
497    if context.default_remote_name.is_none() {
498        context.default_remote_name = Some(default_remote_name.to_string());
499    }
500    context
501}
502
503fn normalize_action_request(
504    mut request: ActionRequest,
505    catalog: &ActionCatalog,
506    default_remote_name: &str,
507) -> ActionRequest {
508    request.context = merge_default_context(request.context, default_remote_name);
509    if request.template_id.contains(':') {
510        return request;
511    }
512    if let Some(best_id) = choose_template_for_short_id(
513        catalog,
514        &request.template_id,
515        &request.context,
516        &request.params,
517    ) {
518        request.template_id = best_id;
519    }
520    request
521}
522
523fn choose_template_for_short_id(
524    catalog: &ActionCatalog,
525    short_id: &str,
526    context: &ActionContext,
527    params: &std::collections::HashMap<String, String>,
528) -> Option<String> {
529    let mut candidates: Vec<_> = catalog
530        .templates
531        .iter()
532        .filter(|t| t.id.ends_with(&format!(":{short_id}")))
533        .collect();
534    if candidates.is_empty() {
535        candidates = catalog
536            .templates
537            .iter()
538            .filter(|t| {
539                let title = sanitize_id_fragment(&t.title);
540                title == short_id || title.starts_with(&format!("{short_id}-"))
541            })
542            .collect();
543    }
544    if candidates.is_empty() {
545        candidates = catalog
546            .templates
547            .iter()
548            .filter(|t| {
549                t.args
550                    .first()
551                    .is_some_and(|cmd| cmd.eq_ignore_ascii_case(short_id))
552            })
553            .collect();
554    }
555    if candidates.is_empty() {
556        return None;
557    }
558    let mut available = context.to_placeholder_map();
559    available.extend(params.clone());
560    let placeholder_regex = Regex::new(r"\{([^}]+)\}").expect("regex compiles");
561
562    candidates
563        .into_iter()
564        .min_by_key(|t| {
565            let mut missing = 0usize;
566            let mut check_text = t.args.join(" ");
567            check_text.push(' ');
568            check_text.push_str(&t.raw_args);
569            for param in &t.params {
570                check_text.push(' ');
571                check_text.push_str(&param.default_value);
572            }
573            for cap in placeholder_regex.captures_iter(&check_text) {
574                let Some(name_match) = cap.get(1) else {
575                    continue;
576                };
577                let name = name_match.as_str();
578                if name.starts_with("GIT_CONFIG:") || name.starts_with("GIT_EXEC:") {
579                    continue;
580                }
581                if !available.contains_key(name) {
582                    missing += 1;
583                }
584            }
585            (missing, t.shell_script, t.params.len(), t.args.len())
586        })
587        .map(|t| t.id.clone())
588}
589
590fn sanitize_id_fragment(text: &str) -> String {
591    let lowered = text.to_lowercase();
592    let mut out = String::with_capacity(lowered.len());
593    let mut prev_dash = false;
594    for ch in lowered.chars() {
595        if ch.is_ascii_alphanumeric() {
596            out.push(ch);
597            prev_dash = false;
598        } else if !prev_dash {
599            out.push('-');
600            prev_dash = true;
601        }
602    }
603    out.trim_matches('-').to_string()
604}
605
606fn normalize_repo_file_input(repo_root: &Path, file: &Path) -> PathBuf {
607    if file.is_absolute() {
608        return file
609            .strip_prefix(repo_root)
610            .map(ToOwned::to_owned)
611            .unwrap_or_else(|_| file.to_path_buf());
612    }
613    file.to_path_buf()
614}
615
616fn parse_numstat_value(raw: &str) -> Option<u32> {
617    if raw == "-" {
618        return None;
619    }
620    raw.parse::<u32>().ok()
621}
622
623fn normalize_numstat_path(raw: &str) -> String {
624    let mut path = raw.trim().trim_matches('"').to_string();
625    if path.is_empty() {
626        return path;
627    }
628
629    if path.contains('{') && path.contains(" => ") {
630        let chars = path.chars().collect::<Vec<_>>();
631        let mut out = String::with_capacity(path.len());
632        let mut i = 0;
633        while i < chars.len() {
634            if chars[i] == '{'
635                && let Some(close) = chars[i + 1..].iter().position(|c| *c == '}')
636            {
637                let end = i + 1 + close;
638                let inner = chars[i + 1..end].iter().collect::<String>();
639                if let Some((_, rhs)) = inner.split_once(" => ") {
640                    out.push_str(rhs.trim());
641                    i = end + 1;
642                    continue;
643                }
644            }
645            out.push(chars[i]);
646            i += 1;
647        }
648        path = out;
649    }
650
651    if let Some((_, rhs)) = path.rsplit_once(" => ") {
652        path = rhs.trim().to_string();
653    }
654
655    path
656}
657
658#[cfg(test)]
659mod tests {
660    use std::collections::{HashMap, HashSet};
661    use std::fs;
662    use std::process::Command;
663
664    use tempfile::TempDir;
665
666    use crate::actions::{ActionContext, ActionRequest};
667    use crate::models::{CommitSearchQuery, GraphQuery};
668
669    use super::GitLgService;
670    use super::GitRunner;
671
672    fn has_git() -> bool {
673        Command::new("git")
674            .arg("--version")
675            .output()
676            .map(|o| o.status.success())
677            .unwrap_or(false)
678    }
679
680    fn init_repo(tmp: &TempDir) {
681        Command::new("git")
682            .args(["init"])
683            .current_dir(tmp.path())
684            .output()
685            .expect("git init");
686        Command::new("git")
687            .args(["config", "user.name", "Test"])
688            .current_dir(tmp.path())
689            .output()
690            .expect("config user.name");
691        Command::new("git")
692            .args(["config", "user.email", "test@example.com"])
693            .current_dir(tmp.path())
694            .output()
695            .expect("config user.email");
696        fs::write(tmp.path().join("a.txt"), "a\n").expect("write a");
697        Command::new("git")
698            .args(["add", "."])
699            .current_dir(tmp.path())
700            .output()
701            .expect("git add");
702        Command::new("git")
703            .args(["commit", "-m", "init"])
704            .current_dir(tmp.path())
705            .output()
706            .expect("git commit");
707    }
708
709    fn commit_file(tmp: &TempDir, path: &str, content: &str, message: &str) {
710        fs::write(tmp.path().join(path), content).expect("write file");
711        Command::new("git")
712            .args(["add", path])
713            .current_dir(tmp.path())
714            .output()
715            .expect("git add file");
716        Command::new("git")
717            .args(["commit", "-m", message])
718            .current_dir(tmp.path())
719            .output()
720            .expect("git commit file");
721    }
722
723    #[test]
724    fn can_load_graph() {
725        if !has_git() {
726            return;
727        }
728        let tmp = TempDir::new().expect("tempdir");
729        init_repo(&tmp);
730
731        let service = GitLgService::with_default_actions(GitRunner::default());
732        let graph = service
733            .graph(tmp.path(), &GraphQuery::default())
734            .expect("graph builds");
735        assert!(!graph.commits.is_empty());
736        assert_eq!(graph.commits[0].subject, "init");
737    }
738
739    #[test]
740    fn can_execute_checkout_action_preview() {
741        if !has_git() {
742            return;
743        }
744        let service = GitLgService::with_default_actions(GitRunner::default());
745        let request = ActionRequest {
746            template_id: "checkout".to_string(),
747            params: HashMap::new(),
748            enabled_options: HashSet::new(),
749            context: ActionContext {
750                branch_name: Some("main".to_string()),
751                ..ActionContext::default()
752            },
753        };
754        let preview = service
755            .resolve_action_preview(request, "origin", None)
756            .expect("resolves");
757        assert_eq!(preview.args, vec!["checkout", "main"]);
758    }
759
760    #[test]
761    fn can_blame_line() {
762        if !has_git() {
763            return;
764        }
765        let tmp = TempDir::new().expect("tempdir");
766        init_repo(&tmp);
767        let service = GitLgService::with_default_actions(GitRunner::default());
768        let blame = service
769            .blame_line(tmp.path(), &tmp.path().join("a.txt"), 1)
770            .expect("blame line");
771        assert!(!blame.commit_hash.is_empty());
772        assert_eq!(blame.author_name, "Test");
773    }
774
775    #[test]
776    fn can_search_file_contents_in_history() {
777        if !has_git() {
778            return;
779        }
780        let tmp = TempDir::new().expect("tempdir");
781        init_repo(&tmp);
782        commit_file(&tmp, "notes.txt", "needle in a stack\n", "add notes");
783        commit_file(&tmp, "notes.txt", "clean line\n", "remove needle");
784
785        let service = GitLgService::with_default_actions(GitRunner::default());
786        let graph = service
787            .graph_filtered(
788                tmp.path(),
789                &GraphQuery::default(),
790                &CommitSearchQuery {
791                    text: "needle".to_string(),
792                    file_path: Some("notes.txt".to_string()),
793                    ..CommitSearchQuery::default()
794                },
795            )
796            .expect("graph filtered");
797        assert_eq!(graph.commits.len(), 1);
798        assert_eq!(graph.commits[0].subject, "add notes");
799    }
800
801    #[test]
802    fn short_id_merge_prefers_merge_template() {
803        let service = GitLgService::with_default_actions(GitRunner::default());
804        let preview = service
805            .resolve_action_preview(
806                ActionRequest {
807                    template_id: "merge".to_string(),
808                    params: HashMap::new(),
809                    enabled_options: HashSet::new(),
810                    context: ActionContext {
811                        branch_display_name: Some("feature/my-work".to_string()),
812                        ..ActionContext::default()
813                    },
814                },
815                "origin",
816                None,
817            )
818            .expect("resolve merge");
819        assert_eq!(preview.args.first().map(String::as_str), Some("merge"));
820        assert!(preview.command_line.contains("feature/my-work"));
821    }
822
823    #[test]
824    fn can_read_commit_file_changes_and_patch() {
825        if !has_git() {
826            return;
827        }
828        let tmp = TempDir::new().expect("tempdir");
829        init_repo(&tmp);
830        commit_file(&tmp, "notes.txt", "line one\nline two\n", "add notes");
831
832        let service = GitLgService::with_default_actions(GitRunner::default());
833        let graph = service
834            .graph(
835                tmp.path(),
836                &GraphQuery {
837                    limit: 1,
838                    ..GraphQuery::default()
839                },
840            )
841            .expect("graph");
842        let commit_hash = graph.commits[0].hash.clone();
843        let files = service
844            .commit_file_changes(tmp.path(), &commit_hash)
845            .expect("file changes");
846        assert!(files.iter().any(|f| f.path.ends_with("notes.txt")));
847
848        let patch = service
849            .commit_file_patch(tmp.path(), &commit_hash, "notes.txt", 3)
850            .expect("patch");
851        assert!(patch.contains("+line one"));
852    }
853
854    #[test]
855    fn normalize_numstat_paths_for_renames() {
856        assert_eq!(super::normalize_numstat_path("README.md"), "README.md");
857        assert_eq!(
858            super::normalize_numstat_path("old.txt => new.txt"),
859            "new.txt"
860        );
861        assert_eq!(
862            super::normalize_numstat_path("src/{old => new}/mod.rs"),
863            "src/new/mod.rs"
864        );
865        assert_eq!(
866            super::normalize_numstat_path("\"src/{old => new}/mod.rs\""),
867            "src/new/mod.rs"
868        );
869    }
870}