use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use std::collections::VecDeque;
use crate::agent::{AgentClient, AgentKind, AgentOptions, AgentRun, AgentVersion, DetectedAgent};
use crate::cx::{Cx, Env, Input, Stream};
use crate::error::Error;
use crate::gh::{GhClient, OpenPr, PrSummary, PrView, RealGh};
use crate::git::cli::{GitCli, RealGit};
#[derive(Default)]
pub(crate) struct FakeGh {
list: Vec<PrSummary>,
view: Option<PrView>,
available: bool,
default_branch: Option<String>,
existing_pr: Option<OpenPr>,
create_stdout: String,
edit_stdout: String,
create_args: Arc<Mutex<Vec<Vec<String>>>>,
edit_args: Arc<Mutex<Vec<Vec<String>>>>,
}
#[allow(dead_code)]
impl FakeGh {
pub(crate) fn with_view(view: PrView) -> Self {
FakeGh {
view: Some(view),
available: true,
..Default::default()
}
}
pub(crate) fn with_list(list: Vec<PrSummary>) -> Self {
FakeGh {
list,
available: true,
..Default::default()
}
}
pub(crate) fn sender(stdout: &str) -> Self {
FakeGh {
available: true,
create_stdout: stdout.to_string(),
edit_stdout: stdout.to_string(),
..Default::default()
}
}
pub(crate) fn unavailable() -> Self {
FakeGh::default()
}
pub(crate) fn with_default_branch(mut self, name: &str) -> Self {
self.default_branch = Some(name.to_string());
self
}
pub(crate) fn with_existing_pr(mut self, pr: OpenPr) -> Self {
self.existing_pr = Some(pr);
self
}
pub(crate) fn created_args(&self) -> Vec<Vec<String>> {
self.create_args.lock().expect("lock poisoned").clone()
}
pub(crate) fn edited_args(&self) -> Vec<Vec<String>> {
self.edit_args.lock().expect("lock poisoned").clone()
}
}
impl GhClient for FakeGh {
fn list_open_prs(&self, _dir: &std::path::Path) -> crate::error::Result<Vec<PrSummary>> {
if self.available {
Ok(self.list.clone())
} else {
Err(Error::GhUnavailable("gh unavailable".into()))
}
}
fn view_pr(&self, _dir: &std::path::Path, _target: &str) -> crate::error::Result<PrView> {
if !self.available {
return Err(Error::GhUnavailable("gh unavailable".into()));
}
self.view
.clone()
.ok_or_else(|| Error::operation("no PR configured"))
}
fn default_branch(&self, _dir: &std::path::Path) -> crate::error::Result<Option<String>> {
Ok(self.default_branch.clone())
}
fn find_pr_for_branch(
&self,
_dir: &std::path::Path,
_branch: &str,
) -> crate::error::Result<Option<OpenPr>> {
if !self.available {
return Err(Error::GhUnavailable("gh unavailable".into()));
}
Ok(self.existing_pr.clone())
}
fn create_pr(&self, _dir: &std::path::Path, args: &[String]) -> crate::error::Result<String> {
if !self.available {
return Err(Error::GhUnavailable("gh unavailable".into()));
}
self.create_args
.lock()
.expect("lock poisoned")
.push(args.to_vec());
Ok(self.create_stdout.clone())
}
fn edit_pr(&self, _dir: &std::path::Path, args: &[String]) -> crate::error::Result<String> {
if !self.available {
return Err(Error::GhUnavailable("gh unavailable".into()));
}
self.edit_args
.lock()
.expect("lock poisoned")
.push(args.to_vec());
Ok(self.edit_stdout.clone())
}
}
pub(crate) enum AgentBehavior {
Draft(String),
Erroring(String),
Unavailable,
}
pub(crate) struct FakeAgent {
behavior: AgentBehavior,
last_opts: Mutex<Option<AgentOptions>>,
}
#[allow(dead_code)]
impl FakeAgent {
fn new(behavior: AgentBehavior) -> Self {
FakeAgent {
behavior,
last_opts: Mutex::new(None),
}
}
pub(crate) fn drafting(result: &str) -> Self {
FakeAgent::new(AgentBehavior::Draft(result.to_string()))
}
pub(crate) fn erroring(result: &str) -> Self {
FakeAgent::new(AgentBehavior::Erroring(result.to_string()))
}
pub(crate) fn unavailable() -> Self {
FakeAgent::new(AgentBehavior::Unavailable)
}
pub(crate) fn last_opts(&self) -> Option<AgentOptions> {
*self.last_opts.lock().expect("lock")
}
}
impl AgentClient for FakeAgent {
fn detect(&self, kind: AgentKind) -> crate::error::Result<Option<DetectedAgent>> {
match self.behavior {
AgentBehavior::Unavailable => Ok(None),
_ => Ok(Some(DetectedAgent {
kind,
binary: kind.as_str().to_string(),
version: AgentVersion {
version: None,
raw: String::new(),
},
})),
}
}
fn run(
&self,
kind: AgentKind,
_prompt: &str,
_dir: &Path,
opts: &AgentOptions,
) -> crate::error::Result<AgentRun> {
*self.last_opts.lock().expect("lock") = Some(*opts);
match &self.behavior {
AgentBehavior::Draft(result) => Ok(AgentRun {
kind,
is_error: false,
result: result.clone(),
raw: serde_json::Value::Null,
}),
AgentBehavior::Erroring(result) => Ok(AgentRun {
kind,
is_error: true,
result: result.clone(),
raw: serde_json::Value::Null,
}),
AgentBehavior::Unavailable => Err(Error::AgentUnavailable("claude unavailable".into())),
}
}
}
#[derive(Default)]
pub(crate) struct CannedInput(VecDeque<String>);
impl CannedInput {
pub(crate) fn new(lines: &[&str]) -> Self {
CannedInput(lines.iter().map(|l| format!("{l}\n")).collect())
}
}
impl Input for CannedInput {
fn read_line(&mut self) -> crate::error::Result<String> {
Ok(self.0.pop_front().unwrap_or_default())
}
}
#[derive(Clone, Default)]
pub(crate) struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl SharedBuf {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn contents(&self) -> String {
let guard = self.0.lock().expect("buffer lock poisoned");
String::from_utf8_lossy(&guard).into_owned()
}
}
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.expect("buffer lock poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(crate) struct TestCx {
pub cx: Cx,
pub out: SharedBuf,
pub err: SharedBuf,
}
pub(crate) fn test_cx(env: &[(&str, &str)], cwd: &str) -> TestCx {
test_cx_with_git(env, cwd, Arc::new(RealGit))
}
pub(crate) fn test_cx_with_git(
env: &[(&str, &str)],
cwd: &str,
git: Arc<dyn GitCli + Send + Sync>,
) -> TestCx {
let out = SharedBuf::new();
let err = SharedBuf::new();
let env_map: HashMap<String, String> = env
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
let cx = Cx::new(
Stream::new(Box::new(out.clone()), false),
Stream::new(Box::new(err.clone()), false),
Env::from_map(env_map),
PathBuf::from(cwd),
git,
Arc::new(RealGh),
Arc::new(FakeAgent::unavailable()),
Box::new(CannedInput::default()),
);
TestCx { cx, out, err }
}
pub(crate) struct TestRepo {
_dir: TempDir,
root: PathBuf,
}
#[allow(dead_code)]
impl TestRepo {
pub(crate) fn init() -> TestRepo {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("repo");
std::fs::create_dir_all(&root).expect("mkdir repo");
run_git(&root, &["init", "-q", "-b", "main"]);
std::fs::write(root.join("README.md"), "init\n").expect("write readme");
run_git(&root, &["add", "-A"]);
run_git(&root, &["commit", "-q", "-m", "init"]);
TestRepo { _dir: dir, root }
}
pub(crate) fn init_bare() -> TestRepo {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("bare.git");
std::fs::create_dir_all(&root).expect("mkdir bare");
run_git(&root, &["init", "-q", "--bare", "-b", "main"]);
TestRepo { _dir: dir, root }
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn git(&self, args: &[&str]) -> String {
run_git(&self.root, args)
}
pub(crate) fn add_worktree(&self, branch: &str, rel_path: &str) {
run_git(
&self.root,
&["worktree", "add", "-q", "-b", branch, rel_path],
);
}
pub(crate) fn write(&self, rel: &str, content: &str) {
let path = self.root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir");
}
std::fs::write(path, content).expect("write file");
}
pub(crate) fn commit_all(&self, message: &str) {
run_git(&self.root, &["add", "-A"]);
run_git(&self.root, &["commit", "-q", "-m", message]);
}
pub(crate) fn add_submodule(&self, path: &str) -> PathBuf {
let src = self
.root
.parent()
.expect("temp dir")
.join(format!("{}-src", path.replace('/', "-")));
std::fs::create_dir_all(&src).expect("mkdir submodule src");
run_git(&src, &["init", "-q", "-b", "main"]);
std::fs::write(src.join("sub.txt"), "submodule\n").expect("write sub file");
run_git(&src, &["add", "-A"]);
run_git(&src, &["commit", "-q", "-m", "submodule init"]);
let src_str = src.to_string_lossy().into_owned();
run_git(
&self.root,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
&src_str,
path,
],
);
self.commit_all("add submodule");
src
}
pub(crate) fn deinit_submodule(&self, path: &str) {
run_git(&self.root, &["submodule", "deinit", "-q", "-f", path]);
}
}
pub(crate) fn make_wt(repo: &TestRepo, branch: &str) {
let mut t = test_cx(&[], repo.root().to_str().unwrap());
crate::commands::new::run(
&mut t.cx,
&crate::hooks::RealHookRunner,
&crate::cli::NewArgs {
branch: branch.to_string(),
from: None,
track: None,
no_track: false,
no_switch: true,
no_hooks: true,
copy_from: None,
init_submodules: false,
no_init_submodules: false,
},
false,
)
.unwrap();
}
pub(crate) fn wt_dir(repo: &TestRepo, branch: &str) -> PathBuf {
let repo_name = repo.root().file_name().unwrap().to_string_lossy();
repo.root()
.parent()
.unwrap()
.join(format!("{repo_name}.worktrees/{repo_name}-{branch}"))
}
pub(crate) fn give_upstream(repo: &TestRepo, branch: &str) {
repo.git(&[
"update-ref",
&format!("refs/remotes/origin/{branch}"),
&format!("refs/heads/{branch}"),
]);
repo.git(&["config", &format!("branch.{branch}.remote"), "origin"]);
repo.git(&[
"config",
&format!("branch.{branch}.merge"),
&format!("refs/heads/{branch}"),
]);
}
fn run_git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_OBJECT_DIRECTORY")
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_NAMESPACE")
.env_remove("GIT_CEILING_DIRECTORIES")
.env_remove("GIT_PREFIX")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "wt Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "wt Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}