Skip to main content

wisp/runtime/
git.rs

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}