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
This commit is contained in:
l0ng-ai
2026-10-28 14:55:10 +08:00
committed by GitHub
parent f84ad80305
commit eef24ea785
5 changed files with 72 additions and 9 deletions
+18 -8
View File
@@ -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<AgentConfigChoice>,
/// In the project's own checkout: files already changed when the task started.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
before: BTreeMap<String, worktree::Stamp>,
}
struct TaskState {
@@ -159,6 +162,7 @@ struct TaskState {
session_id: Option<String>,
timeline: Timeline,
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
/// 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<Vec<AgentChange>> {
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));
+32
View File
@@ -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<Vec<AgentChange>> {
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<BTreeMap<String, Stamp>> {
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<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
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<Option<String>> {
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<AgentChange>| c.into_iter().map(|c| c.rel_path).collect::<Vec<_>>();
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();
+9
View File
@@ -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);
+12 -1
View File
@@ -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 && (
<>
<span className={styles.note}>
<T>on</T>
</span>
<span className={styles.branch}>
<Icon name="branch" size={14} />
{current}
</span>
</>
)}
{draft.isolation === "worktree" && branches.length > 0 && (
<>
<span className={styles.note}>
+1
View File
@@ -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": "消息发送失败",