diff --git a/Cargo.lock b/Cargo.lock index 453a0da..da72abf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1979,6 +1979,7 @@ dependencies = [ "goat-tool-browser", "goat-tool-computer", "goat-tools", + "goat-worktree", "rand 0.10.1", "serde", "serde_json", diff --git a/crates/goat-agent/Cargo.toml b/crates/goat-agent/Cargo.toml index 2d2d771..3f5271d 100644 --- a/crates/goat-agent/Cargo.toml +++ b/crates/goat-agent/Cargo.toml @@ -19,6 +19,7 @@ goat-mcp = { workspace = true } goat-tool = { workspace = true } goat-sandbox = { workspace = true } goat-tools = { workspace = true } +goat-worktree = { workspace = true } rand = { workspace = true } goat-tool-computer = { workspace = true } goat-tool-browser = { workspace = true } diff --git a/crates/goat-agent/src/accounts.rs b/crates/goat-agent/src/accounts.rs index b6d136f..06480e7 100644 --- a/crates/goat-agent/src/accounts.rs +++ b/crates/goat-agent/src/accounts.rs @@ -14,7 +14,7 @@ use goat_protocol::{ }; use goat_provider::{ModelListSource, Provider}; use goat_providers::{DEFAULT_ACCOUNT, Registry}; -use goat_store::Store; +use goat_store::{Store, Thread}; use tokio::sync::mpsc; use crate::Ctx; @@ -26,8 +26,7 @@ pub(crate) async fn restore_target( credentials: &CredentialStore, cwd: &std::path::Path, ) -> Option { - let cwd = cwd.display().to_string(); - let thread = store.latest_thread_in(cwd).await.ok().flatten()?; + let thread = latest_thread_or_seed(store, cwd).await?; let provider = Registry::load(credentials, &thread.account) .get(&goat_provider::ProviderId::from(thread.provider.as_str()))?; if !provider.authenticated() { @@ -41,6 +40,27 @@ pub(crate) async fn restore_target( }) } +async fn latest_thread_or_seed(store: &Store, cwd: &std::path::Path) -> Option { + let key = cwd.display().to_string(); + if let Some(thread) = store.latest_thread_in(key).await.ok().flatten() { + return Some(thread); + } + let owner = worktree_owner_root(cwd)?; + store + .latest_thread_in(owner.display().to_string()) + .await + .ok() + .flatten() +} + +fn worktree_owner_root(cwd: &std::path::Path) -> Option { + let workspace = goat_worktree::workspace(cwd).ok()?; + if !matches!(workspace.kind, goat_worktree::WorkspaceKind::Managed { .. }) { + return None; + } + Some(workspace.owner_root) +} + pub(crate) async fn emit_accounts_changed( events: &mpsc::Sender, registry: &Registry, @@ -565,8 +585,9 @@ mod tests { use goat_auth::{Credential, CredentialStore, SecretString}; use goat_provider::{ModelListSource, Provider, ProviderId}; - use super::{catalog_only, models_for_provider}; + use super::{catalog_only, latest_thread_or_seed, models_for_provider}; use goat_providers::Registry; + use goat_store::{NewThread, Store}; fn store(name: &str) -> CredentialStore { let path = std::env::temp_dir().join(name); @@ -655,4 +676,143 @@ mod tests { assert!(api_models.iter().any(|id| id == "grok-4")); assert!(!api_models.iter().any(|id| id == "grok-composer-2.5-fast")); } + + fn git_available() -> bool { + std::process::Command::new("git") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } + + fn git(repo: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(repo) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); + } + + fn init_repo(repo: &std::path::Path) { + std::fs::create_dir(repo).unwrap(); + git(repo, &["init", "-b", "main"]); + git(repo, &["config", "user.email", "t@example.invalid"]); + git(repo, &["config", "user.name", "Test"]); + std::fs::write(repo.join("README.md"), "hello\n").unwrap(); + git(repo, &["add", "README.md"]); + git(repo, &["commit", "-m", "init"]); + } + + fn add_worktree(repo: &std::path::Path) -> std::path::PathBuf { + let worktree = repo.join(".goat").join("worktrees").join("test"); + std::fs::create_dir_all(worktree.parent().unwrap()).unwrap(); + git( + repo, + &[ + "worktree", + "add", + "-b", + "worktree-test", + worktree.to_str().unwrap(), + "HEAD", + ], + ); + worktree + } + + fn owner_key(worktree: &std::path::Path) -> String { + goat_worktree::workspace(worktree) + .unwrap() + .owner_root + .display() + .to_string() + } + + fn seeded_thread(cwd: &str, model: &str) -> NewThread { + NewThread { + cwd: cwd.to_owned(), + title: None, + provider: "anthropic".to_owned(), + model: model.to_owned(), + account: "default".to_owned(), + effort: Some("xhigh".to_owned()), + created_at: 1, + updated_at: 1, + } + } + + #[tokio::test] + async fn worktree_seeds_model_from_owner_repo() { + if !git_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path().join("repo"); + init_repo(&repo); + let worktree = add_worktree(&repo); + + let store = Store::open(&dir.path().join("db.sqlite")).unwrap(); + store + .create_thread(seeded_thread(&owner_key(&worktree), "claude-opus-4-8")) + .await + .unwrap(); + + let seeded = latest_thread_or_seed(&store, &worktree).await.unwrap(); + assert_eq!(seeded.model, "claude-opus-4-8"); + assert_eq!(seeded.effort.as_deref(), Some("xhigh")); + } + + #[tokio::test] + async fn worktree_own_thread_wins_over_owner_seed() { + if !git_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path().join("repo"); + init_repo(&repo); + let worktree = add_worktree(&repo); + + let store = Store::open(&dir.path().join("db.sqlite")).unwrap(); + store + .create_thread(seeded_thread(&owner_key(&worktree), "claude-opus-4-8")) + .await + .unwrap(); + store + .create_thread(seeded_thread( + &worktree.display().to_string(), + "claude-haiku-4-8", + )) + .await + .unwrap(); + + let resolved = latest_thread_or_seed(&store, &worktree).await.unwrap(); + assert_eq!(resolved.model, "claude-haiku-4-8"); + } + + #[tokio::test] + async fn plain_repo_does_not_seed() { + if !git_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path().join("repo"); + init_repo(&repo); + + let store = Store::open(&dir.path().join("db.sqlite")).unwrap(); + store + .create_thread(seeded_thread( + &repo.display().to_string(), + "claude-opus-4-8", + )) + .await + .unwrap(); + + let fresh = repo.join("sub"); + std::fs::create_dir(&fresh).unwrap(); + assert!(latest_thread_or_seed(&store, &fresh).await.is_none()); + } }