From 6356e8567116359b02f42fe284a1768db7e6765a Mon Sep 17 00:00:00 2001 From: arookieofc Date: Fri, 30 Oct 2026 19:17:02 +0800 Subject: [PATCH] fix(agent): run tasks in folders that are not git repositories (#402) Agent tasks failed in projects without git because change tracking assumed a repository. Outside a repository there are no changes to compare; worktree isolation is hidden and the task runs in the local checkout, and asking for a worktree there returns a clear error. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 7 ++++++ crates/ide-agent/src/worktree.rs | 19 ++++++++++++++-- src/features/agents/HomeEditor.tsx | 36 +++++++++++++++++++----------- src/features/agents/store.ts | 7 +++--- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index e9b91f523..233c461ba 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -23,6 +23,7 @@ pub use model::*; use conversation::{Conversation, SavedSubagent, Session}; use ide_core::{IdeError, IdeResult, IpcPath}; use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes}; +use ide_vcs::Repo; use parking_lot::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -364,6 +365,12 @@ impl AgentManager { let root = self.shared.paths.project_root.clone(); let (cwd, branch, base, base_commit, before) = match req.isolation { Isolation::Worktree => { + // The UI offers worktrees for git projects only; state it when it isn't one. + if Repo::discover(&self.shared.paths.project_root)?.is_none() { + return Err(IdeError::invalid( + "worktrees need a git repository: run the task in the local checkout", + )); + } let dir = self.shared.paths.worktrees_dir.clone(); let (t, b) = (title.clone(), req.base.clone()); let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?; diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index b372604c3..6b261b3ac 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -53,8 +53,9 @@ pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> { } /// Files under `cwd` that differ from `base` (untracked files included), with line counts. +/// Outside a repository there is nothing to compare against: no changes. pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult> { - let repo = repo_at(cwd)?; + let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) }; let root = repo.root().to_path_buf(); let stats = repo.numstat_with_worktree(base)?; let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r)); @@ -96,7 +97,7 @@ pub(crate) fn without_untouched(changes: Vec, before: &BTreeMap IdeResult> { - let repo = repo_at(cwd)?; + let Some(repo) = Repo::discover(cwd)? else { return Ok(None) }; let encoding = match ide_vfs::load_text(path) { Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8), Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None), @@ -141,6 +142,20 @@ mod tests { assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3"); } + #[test] + fn outside_a_repository_there_is_nothing_to_compare() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("plain"); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("a.txt"), "one\n").unwrap(); + + assert!(changes(&dir, "HEAD").unwrap().is_empty()); + assert!(snapshot(&dir).unwrap().is_empty()); + assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None); + assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err()); + assert!(remove(&dir, &dir).is_err()); + } + fn git(dir: &Path, args: &[&str]) { let ok = std::process::Command::new("git") .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]) diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index 83280b636..c4d6a4b43 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -48,6 +48,9 @@ export function HomeEditor() { const probe = useProbe(provider?.installed ? provider.id : null); const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS); const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon); + // Without git under the project there are no worktrees: force the local checkout. + const isGit = project?.isGit ?? false; + const isolation = isGit ? draft.isolation : "local"; const base = draft.base ?? current; const canStart = canSend(composer) && !!provider?.installed && !starting; const root = project?.root ?? ""; @@ -56,7 +59,7 @@ export function HomeEditor() { if (!canStart) return; setStarting(true); setError(null); - startTask() + startTask(isolation) .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setStarting(false)); }; @@ -102,17 +105,24 @@ export function HomeEditor() { />
{project?.name} - updateDraft({ isolation })} + /> + )} + {isolation === "local" && current && ( <> on @@ -123,7 +133,7 @@ export function HomeEditor() { )} - {draft.isolation === "worktree" && branches.length > 0 && ( + {isolation === "worktree" && branches.length > 0 && ( <> from diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index e082506d1..6ceba2b87 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -346,7 +346,8 @@ export function openHome(prompt?: string) { void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true }); } -export async function startTask(): Promise { +/** `isolation` overrides the draft's (a project without git can only use the local checkout). */ +export async function startTask(isolation: Isolation = useAgents.getState().draft.isolation): Promise { const { draft } = useAgents.getState(); const composer = composerDraft(HOME); if (!canSend(composer)) return; @@ -355,8 +356,8 @@ export async function startTask(): Promise { prompt: toPrompt(composer), provider: draft.provider, config: Object.entries(draft.config).map(([id, value]) => ({ id, value })), - isolation: draft.isolation, - base: draft.isolation === "worktree" ? draft.base : null, + isolation, + base: isolation === "worktree" ? draft.base : null, }), ); // Its events can arrive before this reply: keep the newer state they carried.