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 <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-30 19:17:02 +08:00
1 джерело ed6092a4e9
коміт 6356e85671
4 files changed
+51 -18

No files matched your search

+7
Переглянути файл
@@ -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?;
+17 -2
Переглянути файл
@@ -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<Vec<AgentChange>> {
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<AgentChange>, before: &BTreeMap<Str
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
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"])
+23 -13
Переглянути файл
@@ -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() {
/>
<div className={styles.where}>
<span className={styles.project}>{project?.name}</span>
<Select
variant="ghost"
aria-label="Isolation"
value={draft.isolation}
options={[
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
{ value: "local", label: tx("Local checkout"), icon: "folder", detail: tx("edits your files directly") },
]}
onChange={(isolation) => updateDraft({ isolation })}
/>
{draft.isolation === "local" && current && (
{isGit && (
<Select
variant="ghost"
aria-label="Isolation"
value={isolation}
options={[
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
{
value: "local",
label: tx("Local checkout"),
icon: "folder",
detail: tx("edits your files directly"),
},
]}
onChange={(isolation) => updateDraft({ isolation })}
/>
)}
{isolation === "local" && current && (
<>
<span className={styles.note}>
<T>on</T>
@@ -123,7 +133,7 @@ export function HomeEditor() {
</span>
</>
)}
{draft.isolation === "worktree" && branches.length > 0 && (
{isolation === "worktree" && branches.length > 0 && (
<>
<span className={styles.note}>
<T>from</T>
+4 -3
Переглянути файл
@@ -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<void> {
/** `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<void> {
const { draft } = useAgents.getState();
const composer = composerDraft(HOME);
if (!canSend(composer)) return;
@@ -355,8 +356,8 @@ export async function startTask(): Promise<void> {
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.