1use crate::command::GitCommand;
2use crate::git_review::{DiffDocument, DiffScope, EMPTY_TREE, FileStatus, GitDiffError, GitDiffEvent};
3use crate::session::workspace_status::{WorkspaceStatus, home_relative_path};
4use std::path::{Path, PathBuf};
5use std::process::Output;
6
7pub async fn execute(command: GitCommand) -> GitDiffEvent {
8 match command {
9 GitCommand::Load { request_id, working_dir, repo_root, scope } => {
10 GitDiffEvent::Loaded { request_id, result: load_diff(&working_dir, repo_root.as_deref(), scope).await }
11 }
12 GitCommand::StageFiles { request_id, repo_root, paths } => {
13 let mut args = vec!["--"];
14 args.extend(paths.iter().map(String::as_str));
15 GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, "add", args).await }
16 }
17 GitCommand::UnstageFiles { request_id, repo_root, paths } => {
18 let mut args = vec!["--quiet", "--"];
19 args.extend(paths.iter().map(String::as_str));
20 GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, "reset", args).await }
21 }
22 GitCommand::StageAll { request_id, repo_root } => {
23 GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, "add", vec!["-A"]).await }
24 }
25 GitCommand::UnstageAll { request_id, repo_root } => GitDiffEvent::ActionFinished {
26 request_id,
27 result: run_action(&repo_root, "reset", vec!["--quiet"]).await,
28 },
29 GitCommand::Commit { request_id, repo_root, message } => {
30 let result = if message.trim().is_empty() {
31 Err(GitDiffError::CommandFailed { stderr: "empty commit message".to_string() })
32 } else {
33 run_action(&repo_root, "commit", vec!["-m", message.as_str()]).await
34 };
35 GitDiffEvent::ActionFinished { request_id, result }
36 }
37 GitCommand::DiscardFile { request_id, repo_root, path, status } => {
38 let (command, args) = match status {
39 FileStatus::Untracked => ("clean", vec!["-f", "--", path.as_str()]),
40 _ => ("restore", vec!["--source=HEAD", "--staged", "--worktree", "--", path.as_str()]),
41 };
42 GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, command, args).await }
43 }
44 GitCommand::LoadFullFile { request_id, repo_root, path } => {
45 let result = tokio::fs::read_to_string(repo_root.join(&path))
46 .await
47 .map_err(|error| GitDiffError::CommandFailed { stderr: format!("Cannot read {path}: {error}") });
48 GitDiffEvent::FullFileLoaded { request_id, path, result }
49 }
50 }
51}
52
53pub async fn resolve_workspace_status(cwd: &Path) -> WorkspaceStatus {
54 let git_ref = match run_output(cwd, &["branch", "--show-current"]).await.ok().and_then(|output| non_empty(&output)) {
55 Some(reference) => Some(reference),
56 None => run_output(cwd, &["rev-parse", "--short", "HEAD"])
57 .await
58 .ok()
59 .and_then(|output| non_empty(&output)),
60 };
61 WorkspaceStatus::new(home_relative_path(cwd), git_ref)
62}
63
64async fn run_output(repo_root: &Path, args: &[&str]) -> Result<Output, GitDiffError> {
65 let output = tokio::process::Command::new("git")
66 .args(args)
67 .current_dir(repo_root)
68 .output()
69 .await
70 .map_err(|error| GitDiffError::CommandFailed { stderr: error.to_string() })?;
71 if output.status.success() {
72 Ok(output)
73 } else {
74 Err(GitDiffError::CommandFailed { stderr: String::from_utf8_lossy(&output.stderr).into_owned() })
75 }
76}
77
78async fn load_diff(
79 working_dir: &Path,
80 cached_repo_root: Option<&Path>,
81 scope: DiffScope,
82) -> Result<DiffDocument, GitDiffError> {
83 let repo_root = match cached_repo_root {
84 Some(root) => root.to_path_buf(),
85 None => resolve_repo_root(working_dir).await?,
86 };
87 let mut diff_args = match scope {
88 DiffScope::Staged => vec!["diff", "--cached", "--no-ext-diff", "--find-renames"],
89 DiffScope::Unstaged | DiffScope::Both => vec!["diff", "--no-ext-diff", "--find-renames"],
90 };
91 if scope == DiffScope::Both {
92 diff_args.push(if succeeds(&repo_root, &["rev-parse", "--verify", "--quiet", "HEAD"]).await {
93 "HEAD"
94 } else {
95 EMPTY_TREE
96 });
97 }
98 let diff_output = run_output(&repo_root, &diff_args).await?;
99 let status_output = run_output(&repo_root, &["status", "--porcelain=v1", "-z"]).await?;
100 let mut untracked = Vec::new();
101 if scope != DiffScope::Staged {
102 let paths = run_output(&repo_root, &["ls-files", "--others", "--exclude-standard"]).await?;
103 for path in String::from_utf8_lossy(&paths.stdout).lines().filter(|path| !path.is_empty()) {
104 let bytes = tokio::fs::read(repo_root.join(path)).await.unwrap_or_default();
105 untracked.push((path.to_string(), bytes));
106 }
107 }
108 DiffDocument::from_git_output(
109 repo_root,
110 &String::from_utf8_lossy(&diff_output.stdout),
111 &String::from_utf8_lossy(&status_output.stdout),
112 untracked,
113 scope,
114 )
115}
116
117async fn run_action(repo_root: &Path, command: &str, args: Vec<&str>) -> Result<(), GitDiffError> {
118 run_output(repo_root, &std::iter::once(command).chain(args).collect::<Vec<_>>()).await.map(drop)
119}
120
121async fn resolve_repo_root(working_dir: &Path) -> Result<PathBuf, GitDiffError> {
122 match run_output(working_dir, &["rev-parse", "--show-toplevel"]).await {
123 Ok(output) => Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim())),
124 Err(error) if !succeeds(working_dir, &["rev-parse", "--is-inside-work-tree"]).await => {
125 let _ = error;
126 Err(GitDiffError::NotARepository)
127 }
128 Err(error) => Err(error),
129 }
130}
131
132async fn succeeds(repo_root: &Path, args: &[&str]) -> bool {
133 run_output(repo_root, args).await.is_ok()
134}
135
136fn non_empty(output: &Output) -> Option<String> {
137 let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
138 (!text.is_empty()).then_some(text)
139}