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(¶m.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}