use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use anyhow::{Result, anyhow};
use tokio::process::Command;
const ACTION_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VcsKind {
Jj,
Git,
}
impl VcsKind {
pub fn label(self) -> &'static str {
match self {
VcsKind::Jj => "jj",
VcsKind::Git => "git",
}
}
}
pub fn detect_vcs(path: &Path) -> Option<VcsKind> {
if path.join(".jj").is_dir() {
Some(VcsKind::Jj)
} else if path.join(".git").exists() {
Some(VcsKind::Git)
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionKind {
Fetch,
Push,
}
impl ActionKind {
pub fn label(self) -> &'static str {
match self {
ActionKind::Fetch => "fetch",
ActionKind::Push => "push",
}
}
}
#[derive(Debug, Clone)]
pub struct ActionOutcome {
pub success: bool,
pub stdout: String,
pub stderr: String,
pub vcs: VcsKind,
pub command: String,
}
pub fn command_for(vcs: VcsKind, kind: ActionKind) -> (&'static str, &'static [&'static str]) {
match (vcs, kind) {
(VcsKind::Jj, ActionKind::Fetch) => ("jj", &["git", "fetch"]),
(VcsKind::Jj, ActionKind::Push) => ("jj", &["git", "push"]),
(VcsKind::Git, ActionKind::Fetch) => ("git", &["fetch"]),
(VcsKind::Git, ActionKind::Push) => ("git", &["push"]),
}
}
pub async fn run_action(path: &Path, kind: ActionKind) -> Result<ActionOutcome> {
let vcs =
detect_vcs(path).ok_or_else(|| anyhow!("no .jj or .git found in {}", path.display()))?;
let (program, args) = command_for(vcs, kind);
let display_command = format!("{program} {}", args.join(" "));
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(windows)]
cmd.creation_flags(crate::silent_creation_flags());
let child = cmd
.spawn()
.map_err(|e| anyhow!("failed to spawn `{display_command}`: {e}"))?;
match tokio::time::timeout(ACTION_TIMEOUT, child.wait_with_output()).await {
Ok(Ok(output)) => Ok(ActionOutcome {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
vcs,
command: display_command,
}),
Ok(Err(e)) => Err(anyhow!(
"failed to capture output of `{display_command}`: {e}"
)),
Err(_elapsed) => Ok(ActionOutcome {
success: false,
stdout: String::new(),
stderr: format!(
"timed out after {}s — child killed",
ACTION_TIMEOUT.as_secs()
),
vcs,
command: display_command,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn detect_vcs_prefers_jj_when_colocated() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join(".jj")).unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
assert_eq!(detect_vcs(dir.path()), Some(VcsKind::Jj));
}
#[test]
fn detect_vcs_picks_git_when_only_git() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
assert_eq!(detect_vcs(dir.path()), Some(VcsKind::Git));
}
#[test]
fn detect_vcs_handles_git_worktree_file() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".git"), "gitdir: /tmp/foo").unwrap();
assert_eq!(detect_vcs(dir.path()), Some(VcsKind::Git));
}
#[test]
fn detect_vcs_none_when_neither_marker_present() {
let dir = tempdir().unwrap();
assert_eq!(detect_vcs(dir.path()), None);
}
#[test]
fn command_for_jj_uses_git_subcommand() {
assert_eq!(
command_for(VcsKind::Jj, ActionKind::Fetch),
("jj", &["git", "fetch"][..])
);
assert_eq!(
command_for(VcsKind::Jj, ActionKind::Push),
("jj", &["git", "push"][..])
);
}
#[test]
fn command_for_git_uses_plain_subcommand() {
assert_eq!(
command_for(VcsKind::Git, ActionKind::Fetch),
("git", &["fetch"][..])
);
assert_eq!(
command_for(VcsKind::Git, ActionKind::Push),
("git", &["push"][..])
);
}
#[test]
fn action_kind_label_is_human_readable() {
assert_eq!(ActionKind::Fetch.label(), "fetch");
assert_eq!(ActionKind::Push.label(), "push");
}
}