aether-wisp 0.6.0

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use crate::command::GitCommand;
use crate::git_review::{DiffDocument, DiffScope, EMPTY_TREE, FileStatus, GitDiffError, GitDiffEvent};
use crate::session::workspace_status::{WorkspaceStatus, home_relative_path};
use std::path::{Path, PathBuf};
use std::process::Output;

pub async fn execute(command: GitCommand) -> GitDiffEvent {
    match command {
        GitCommand::Load { request_id, working_dir, repo_root, scope } => {
            GitDiffEvent::Loaded { request_id, result: load_diff(&working_dir, repo_root.as_deref(), scope).await }
        }
        GitCommand::StageFiles { request_id, repo_root, paths } => {
            let mut args = vec!["--"];
            args.extend(paths.iter().map(String::as_str));
            GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, "add", args).await }
        }
        GitCommand::UnstageFiles { request_id, repo_root, paths } => {
            let mut args = vec!["--quiet", "--"];
            args.extend(paths.iter().map(String::as_str));
            GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, "reset", args).await }
        }
        GitCommand::StageAll { request_id, repo_root } => {
            GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, "add", vec!["-A"]).await }
        }
        GitCommand::UnstageAll { request_id, repo_root } => GitDiffEvent::ActionFinished {
            request_id,
            result: run_action(&repo_root, "reset", vec!["--quiet"]).await,
        },
        GitCommand::Commit { request_id, repo_root, message } => {
            let result = if message.trim().is_empty() {
                Err(GitDiffError::CommandFailed { stderr: "empty commit message".to_string() })
            } else {
                run_action(&repo_root, "commit", vec!["-m", message.as_str()]).await
            };
            GitDiffEvent::ActionFinished { request_id, result }
        }
        GitCommand::DiscardFile { request_id, repo_root, path, status } => {
            let (command, args) = match status {
                FileStatus::Untracked => ("clean", vec!["-f", "--", path.as_str()]),
                _ => ("restore", vec!["--source=HEAD", "--staged", "--worktree", "--", path.as_str()]),
            };
            GitDiffEvent::ActionFinished { request_id, result: run_action(&repo_root, command, args).await }
        }
        GitCommand::LoadFullFile { request_id, repo_root, path } => {
            let result = tokio::fs::read_to_string(repo_root.join(&path))
                .await
                .map_err(|error| GitDiffError::CommandFailed { stderr: format!("Cannot read {path}: {error}") });
            GitDiffEvent::FullFileLoaded { request_id, path, result }
        }
    }
}

pub async fn resolve_workspace_status(cwd: &Path) -> WorkspaceStatus {
    let git_ref = match run_output(cwd, &["branch", "--show-current"]).await.ok().and_then(|output| non_empty(&output)) {
        Some(reference) => Some(reference),
        None => run_output(cwd, &["rev-parse", "--short", "HEAD"])
            .await
            .ok()
            .and_then(|output| non_empty(&output)),
    };
    WorkspaceStatus::new(home_relative_path(cwd), git_ref)
}

async fn run_output(repo_root: &Path, args: &[&str]) -> Result<Output, GitDiffError> {
    let output = tokio::process::Command::new("git")
        .args(args)
        .current_dir(repo_root)
        .output()
        .await
        .map_err(|error| GitDiffError::CommandFailed { stderr: error.to_string() })?;
    if output.status.success() {
        Ok(output)
    } else {
        Err(GitDiffError::CommandFailed { stderr: String::from_utf8_lossy(&output.stderr).into_owned() })
    }
}

async fn load_diff(
    working_dir: &Path,
    cached_repo_root: Option<&Path>,
    scope: DiffScope,
) -> Result<DiffDocument, GitDiffError> {
    let repo_root = match cached_repo_root {
        Some(root) => root.to_path_buf(),
        None => resolve_repo_root(working_dir).await?,
    };
    let mut diff_args = match scope {
        DiffScope::Staged => vec!["diff", "--cached", "--no-ext-diff", "--find-renames"],
        DiffScope::Unstaged | DiffScope::Both => vec!["diff", "--no-ext-diff", "--find-renames"],
    };
    if scope == DiffScope::Both {
        diff_args.push(if succeeds(&repo_root, &["rev-parse", "--verify", "--quiet", "HEAD"]).await {
            "HEAD"
        } else {
            EMPTY_TREE
        });
    }
    let diff_output = run_output(&repo_root, &diff_args).await?;
    let status_output = run_output(&repo_root, &["status", "--porcelain=v1", "-z"]).await?;
    let mut untracked = Vec::new();
    if scope != DiffScope::Staged {
        let paths = run_output(&repo_root, &["ls-files", "--others", "--exclude-standard"]).await?;
        for path in String::from_utf8_lossy(&paths.stdout).lines().filter(|path| !path.is_empty()) {
            let bytes = tokio::fs::read(repo_root.join(path)).await.unwrap_or_default();
            untracked.push((path.to_string(), bytes));
        }
    }
    DiffDocument::from_git_output(
        repo_root,
        &String::from_utf8_lossy(&diff_output.stdout),
        &String::from_utf8_lossy(&status_output.stdout),
        untracked,
        scope,
    )
}

async fn run_action(repo_root: &Path, command: &str, args: Vec<&str>) -> Result<(), GitDiffError> {
    run_output(repo_root, &std::iter::once(command).chain(args).collect::<Vec<_>>()).await.map(drop)
}

async fn resolve_repo_root(working_dir: &Path) -> Result<PathBuf, GitDiffError> {
    match run_output(working_dir, &["rev-parse", "--show-toplevel"]).await {
        Ok(output) => Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim())),
        Err(error) if !succeeds(working_dir, &["rev-parse", "--is-inside-work-tree"]).await => {
            let _ = error;
            Err(GitDiffError::NotARepository)
        }
        Err(error) => Err(error),
    }
}

async fn succeeds(repo_root: &Path, args: &[&str]) -> bool {
    run_output(repo_root, args).await.is_ok()
}

fn non_empty(output: &Output) -> Option<String> {
    let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
    (!text.is_empty()).then_some(text)
}