use ostraka_runtime::worktree;
use std::path::Path;
use std::process::Command;
pub struct Step {
pub said: String,
pub warns: Option<String>,
pub commands: Vec<Vec<String>>,
}
pub struct Remedy {
pub problem: String,
pub steps: Vec<Step>,
pub at: usize,
pub said: Option<String>,
pub failed: bool,
}
impl Remedy {
pub fn nothing_cloned(repositories: &Path) -> Remedy {
Remedy {
problem: format!(
"Nothing has been cloned into {} yet, so there is nothing to work on.",
repositories.display()
),
steps: vec![Step {
said: format!(
"Clone what you want worked on into {}, or start one: ctrl-x w, then n.",
repositories.display()
),
warns: Some(
"Cloning is yours — nobody here knows which repository you meant. \
Starting one needs only a name."
.to_string(),
),
commands: Vec::new(),
}],
at: 0,
said: None,
failed: false,
}
}
pub fn diagnose(project: &Path) -> Option<Remedy> {
let repository = worktree::is_repository(project);
if repository && worktree::has_a_commit(project) {
return None;
}
let mut steps = Vec::new();
if !repository {
steps.push(Step {
said: "Make this directory a git repository.".to_string(),
warns: None,
commands: vec![words(&["git", "init", "-b", "main"])],
});
}
steps.push(Step {
said: "Commit what is here, so a run has something to branch from.".to_string(),
warns: Some(
"This commits everything currently in this directory, respecting \
.gitignore \u{2014} and makes an empty commit where there is nothing \
yet, because a worktree needs one either way."
.to_string(),
),
commands: vec![
words(&["git", "add", "-A"]),
words(&["git", "commit", "-m", "Initial commit", "--allow-empty"]),
],
});
Some(Remedy {
problem: if repository {
"This repository has no commits, so a run has nothing to branch from.".to_string()
} else {
"This directory is not a git repository, so a run has nowhere to work.".to_string()
},
steps,
at: 0,
said: None,
failed: false,
})
}
pub fn done(&self) -> bool {
self.at >= self.steps.len()
}
pub fn take_step(&mut self, project: &Path) {
if self.failed {
return;
}
let Some(step) = self.steps.get(self.at) else {
return;
};
for argv in &step.commands {
let (program, args) = argv.split_first().expect("a command has a program");
let out = Command::new(program)
.args(args)
.current_dir(project)
.output();
match out {
Ok(out) if out.status.success() => {}
Ok(out) => {
let said = String::from_utf8_lossy(&out.stderr);
let said = said.trim();
self.said = Some(if said.is_empty() {
format!("{} failed", argv.join(" "))
} else {
said.to_string()
});
self.failed = true;
return;
}
Err(e) => {
self.said = Some(format!("{} could not be run: {e}", argv.join(" ")));
self.failed = true;
return;
}
}
}
self.at += 1;
self.said = None;
}
}
fn words(argv: &[&str]) -> Vec<String> {
argv.iter().map(|w| (*w).to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn scratch(name: &str) -> std::path::PathBuf {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"ostraka-remedy-{}-{name}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("scratch");
dir
}
#[test]
fn a_directory_git_has_never_heard_of_needs_two_steps() {
let dir = scratch("bare");
let remedy = Remedy::diagnose(&dir).expect("a problem");
assert!(remedy.problem.contains("not a git repository"));
assert_eq!(remedy.steps.len(), 2, "git init alone is not enough");
assert!(
remedy.steps.iter().all(|s| !s.commands.is_empty()),
"both are the browser's to take"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_repository_nobody_has_committed_to_needs_one() {
let dir = scratch("empty-repo");
assert!(
Command::new("git")
.args(["init", "-q", "-b", "main"])
.current_dir(&dir)
.status()
.expect("git runs")
.success()
);
let remedy = Remedy::diagnose(&dir).expect("a problem");
assert!(remedy.problem.contains("no commits"), "{}", remedy.problem);
assert_eq!(remedy.steps.len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn taking_every_step_leaves_a_directory_with_nothing_wrong_with_it() {
let dir = scratch("fixed");
std::fs::write(dir.join("seed.txt"), "seed\n").expect("write");
let mut remedy = Remedy::diagnose(&dir).expect("a problem");
while !remedy.done() && !remedy.failed {
remedy.take_step(&dir);
if dir.join(".git").is_dir() {
for (key, value) in [
("user.email", "test@example.invalid"),
("user.name", "test"),
] {
let _ = std::process::Command::new("git")
.args(["config", key, value])
.current_dir(&dir)
.output();
}
}
}
assert!(
!remedy.failed,
"a step failed: {:?}",
remedy.said.as_deref()
);
assert!(remedy.done());
assert!(
Remedy::diagnose(&dir).is_none(),
"the steps did not fix what they were for"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_step_that_fails_keeps_the_reason_and_stops() {
let dir = scratch("fails");
let mut remedy = Remedy::diagnose(&dir).expect("a problem");
remedy.steps[0].commands = vec![words(&["git", "definitely-not-a-command"])];
remedy.take_step(&dir);
assert!(remedy.failed);
assert!(remedy.said.is_some(), "git's own words were discarded");
assert_eq!(remedy.at, 0, "a failed step counted as taken");
std::fs::remove_dir_all(&dir).ok();
}
}