From eef24ea78577c491a9301c42016ad4d7808f8308 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:55:10 +0800 Subject: [PATCH] fix(agents): Leave changes made before a local checkout task out of its changes (#123) * fix(agents): Leave changes made before a local checkout task out of its changes A task in the project's own checkout listed every uncommitted and untracked file as its own. The files already changed when the task starts are now recorded with their size and modification time, and drop out of the task's changes until they are edited again. * feat(agents): Show the current branch for a local checkout task --- crates/ide-agent/src/lib.rs | 26 +++++++++++++++-------- crates/ide-agent/src/worktree.rs | 32 +++++++++++++++++++++++++++++ src/features/agents/Home.module.css | 9 ++++++++ src/features/agents/HomeEditor.tsx | 13 +++++++++++- src/platform/locales/text/zh.json | 1 + 5 files changed, 72 insertions(+), 9 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 437dac00..72f61e73 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -21,7 +21,7 @@ use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes}; use parking_lot::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Weak}; @@ -152,6 +152,9 @@ struct TaskFile { /// Settings the user chose, re-applied to every new or reloaded session. #[serde(default)] wanted: Vec, + /// In the project's own checkout: files already changed when the task started. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + before: BTreeMap, } struct TaskState { @@ -159,6 +162,7 @@ struct TaskState { session_id: Option, timeline: Timeline, wanted: Vec, + before: Arc>, /// A turn is running or about to (prompts arriving now are queued). busy: bool, } @@ -310,18 +314,20 @@ impl AgentManager { let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?; let title = title_of(&prompt); let root = self.shared.paths.project_root.clone(); - let (cwd, branch, base, base_commit) = match req.isolation { + let (cwd, branch, base, base_commit, before) = match req.isolation { Isolation::Worktree => { 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?; - (c.path, Some(c.branch), Some(c.base), Some(c.base_commit)) + (c.path, Some(c.branch), Some(c.base), Some(c.base_commit), BTreeMap::new()) } Isolation::Local => { let r = root.clone(); - let head = blocking(move || worktree::head_of(&r)).await.ok(); + let (head, before) = + blocking(move || Ok((worktree::head_of(&r).ok(), worktree::snapshot(&r).unwrap_or_default()))) + .await?; let (base, commit) = head.unzip(); - (root, None, base, commit) + (root, None, base, commit, before) } }; // Until the session reports its own, show what the agent offered last time with the choices applied. @@ -351,7 +357,7 @@ impl AgentManager { usage: None, queue: Vec::new(), }; - let file = TaskFile { task: task.clone(), session_id: None, entries: Vec::new(), wanted: req.config }; + let file = TaskFile { task: task.clone(), session_id: None, entries: Vec::new(), wanted: req.config, before }; let handle = Task::restore(file, self.shared.clone()); handle.state.lock().busy = true; self.tasks.write().insert(task.id.clone(), handle.clone()); @@ -529,8 +535,10 @@ impl AgentManager { /// Files the task changed since it started. pub async fn changes(&self, id: &str) -> IdeResult> { - let (cwd, base) = self.task(id)?.cwd_and_base(); - blocking(move || worktree::changes(&cwd, &base)).await + let task = self.task(id)?; + let (cwd, base) = task.cwd_and_base(); + let before = task.state.lock().before.clone(); + blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await } /// Content of `path` (in the task's directory) when the task started. @@ -560,6 +568,7 @@ impl Task { session_id: file.session_id, timeline, wanted: file.wanted, + before: Arc::new(file.before), busy: false, }), live: Mutex::new(None), @@ -616,6 +625,7 @@ impl Task { session_id: s.session_id.clone(), entries: s.timeline.entries.clone(), wanted: s.wanted.clone(), + before: (*s.before).clone(), }) }; let path = self.shared.paths.store_dir.join(format!("{}.json", self.id)); diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index 5563cf04..7f385afc 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -3,7 +3,9 @@ use crate::model::AgentChange; use ide_core::{IdeError, IdeResult, IpcPath}; use ide_vcs::{ChangeKind, Repo}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; pub(crate) struct Created { pub path: PathBuf, @@ -74,6 +76,25 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult> { Ok(out) } +/// Size and modification time of a file, `None` once it is gone. +pub(crate) type Stamp = Option<(u64, u128)>; + +fn stamp(path: &Path) -> Stamp { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos(); + Some((meta.len(), modified)) +} + +/// The files already changed in `cwd` against `HEAD`, stamped so later edits to them show. +pub(crate) fn snapshot(cwd: &Path) -> IdeResult> { + Ok(changes(cwd, "HEAD")?.into_iter().map(|c| (c.rel_path, stamp(&c.path))).collect()) +} + +/// Drops the changes that were there before the task and haven't been touched since. +pub(crate) fn without_untouched(changes: Vec, before: &BTreeMap) -> Vec { + changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect() +} + pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult> { repo_at(cwd)?.file_at(path, Some(rev)) } @@ -148,6 +169,17 @@ mod tests { let base_text = file_at(&created.path, &created.path.join("a.txt"), &created.base_commit).unwrap(); assert_eq!(base_text.as_deref(), Some("one\ntwo\n")); + // Changes already in the checkout drop out until they are edited again. + let before = snapshot(&created.path).unwrap(); + std::fs::write(created.path.join("b.txt"), "b\n").unwrap(); + let rels = |c: Vec| c.into_iter().map(|c| c.rel_path).collect::>(); + let after = changes(&created.path, "HEAD").unwrap(); + assert_eq!(rels(without_untouched(after, &before)), ["b.txt"]); + std::thread::sleep(std::time::Duration::from_millis(10)); + std::fs::write(created.path.join("new.txt"), "x\ny\nz\n").unwrap(); + let after = changes(&created.path, "HEAD").unwrap(); + assert_eq!(rels(without_untouched(after, &before)), ["b.txt", "new.txt"]); + // A second task with the same title gets its own worktree. assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); remove(&repo, &created.path).unwrap(); diff --git a/src/features/agents/Home.module.css b/src/features/agents/Home.module.css index 24d8d173..f8622115 100644 --- a/src/features/agents/Home.module.css +++ b/src/features/agents/Home.module.css @@ -34,6 +34,15 @@ padding: 0 2px; font-size: var(--fs-small); } +/* The current branch, read-only: same height and colour as the pickers beside it. */ +.branch { + display: inline-flex; + align-items: center; + gap: 4px; + height: var(--h-control); + padding: 0 6px; + color: var(--fg-secondary); +} .project { margin-right: 6px; color: var(--fg-muted); diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index 598e4210..ab65f9ce 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -4,7 +4,7 @@ import type { Branch } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { useTx, useTxTemplate } from "@/platform/i18n"; import { useProject } from "@/platform/project"; -import { Button, Select, T, type SelectOption } from "@/ui"; +import { Button, Icon, Select, T, type SelectOption } from "@/ui"; import { Composer } from "./Composer"; import { canSend, emptyDraft } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; @@ -107,6 +107,17 @@ export function HomeEditor() { ]} onChange={(isolation) => updateDraft({ isolation })} /> + {draft.isolation === "local" && current && ( + <> + + on + + + + {current} + + + )} {draft.isolation === "worktree" && branches.length > 0 && ( <> diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e4adbc1a..5a97da4a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3216,6 +3216,7 @@ "away from your changes": "不影响你的改动", "edits your files directly": "直接修改你的文件", "from": "基于", + "on": "位于", "Models and modes appear after this agent's first task.": "这个 agent 跑完第一个任务后会显示模型和模式。", "Queue a message for when the agent is done (⌘↵ sends it now)": "排队一条消息,agent 做完后发送(⌘↵ 立即发送)", "Couldn't send the message": "消息发送失败",