补齐Agent Git工作树审阅与聊天状态交互
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,491 @@
|
||||
use crate::project::{normalize_relative_path, reject_sensitive_project_file_read};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const GIT_INSPECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const GIT_INSPECT_OUTPUT_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct LocalGitWorktreeInspect {
|
||||
pub(crate) head: String,
|
||||
pub(crate) branch: Option<String>,
|
||||
pub(crate) staged: Vec<String>,
|
||||
pub(crate) unstaged: Vec<String>,
|
||||
pub(crate) untracked: Vec<String>,
|
||||
pub(crate) file_count: usize,
|
||||
pub(crate) truncated: bool,
|
||||
pub(crate) content: String,
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_local_git_worktree_at(
|
||||
root: &Path,
|
||||
include_diff: bool,
|
||||
max_files: usize,
|
||||
max_chars: usize,
|
||||
) -> Result<LocalGitWorktreeInspect, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("读取项目目录失败:{error}"))?;
|
||||
ensure_git_top_level(&root)?;
|
||||
|
||||
let head = read_git_head(&root);
|
||||
let branch = read_git_branch(&root);
|
||||
|
||||
let status = run_git(
|
||||
&root,
|
||||
&[
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--untracked-files=all",
|
||||
"--ignore-submodules=all",
|
||||
"--no-renames",
|
||||
],
|
||||
)?;
|
||||
let (mut staged, mut unstaged, mut untracked) = parse_status(&root, &status);
|
||||
staged.sort();
|
||||
staged.dedup();
|
||||
unstaged.sort();
|
||||
unstaged.dedup();
|
||||
untracked.sort();
|
||||
untracked.dedup();
|
||||
|
||||
let all_paths = staged
|
||||
.iter()
|
||||
.chain(&unstaged)
|
||||
.chain(&untracked)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let file_count = all_paths.len();
|
||||
let selected_paths = all_paths
|
||||
.into_iter()
|
||||
.take(max_files)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut truncated = file_count > selected_paths.len();
|
||||
let mut content = String::new();
|
||||
append_path_section(&mut content, "staged files", &staged, &selected_paths);
|
||||
append_path_section(&mut content, "unstaged files", &unstaged, &selected_paths);
|
||||
append_path_section(&mut content, "untracked files", &untracked, &selected_paths);
|
||||
|
||||
let mut staged_diff = String::new();
|
||||
let mut unstaged_diff = String::new();
|
||||
if include_diff {
|
||||
let staged_paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| staged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let unstaged_paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| unstaged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
staged_diff = read_diff(&root, true, &staged_paths)?;
|
||||
unstaged_diff = read_diff(&root, false, &unstaged_paths)?;
|
||||
append_diff_section(&mut content, "staged diff", &staged_diff);
|
||||
append_diff_section(&mut content, "unstaged diff", &unstaged_diff);
|
||||
}
|
||||
|
||||
let status_after = run_git(
|
||||
&root,
|
||||
&[
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--untracked-files=all",
|
||||
"--ignore-submodules=all",
|
||||
"--no-renames",
|
||||
],
|
||||
)?;
|
||||
let staged_diff_after = if include_diff {
|
||||
let paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| staged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
read_diff(&root, true, &paths)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let unstaged_diff_after = if include_diff {
|
||||
let paths = selected_paths
|
||||
.iter()
|
||||
.filter(|path| unstaged.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
read_diff(&root, false, &paths)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if status_after != status
|
||||
|| read_git_head(&root) != head
|
||||
|| read_git_branch(&root) != branch
|
||||
|| staged_diff_after != staged_diff
|
||||
|| unstaged_diff_after != unstaged_diff
|
||||
{
|
||||
return Err("Git 工作树在审阅过程中发生变化,请重试".to_string());
|
||||
}
|
||||
|
||||
if content.chars().count() > max_chars {
|
||||
content = content.chars().take(max_chars).collect();
|
||||
content.push_str("\n[git inspect output truncated]\n");
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
Ok(LocalGitWorktreeInspect {
|
||||
head,
|
||||
branch,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
file_count,
|
||||
truncated,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_git_head(root: &Path) -> String {
|
||||
run_git(root, &["rev-parse", "--verify", "HEAD"])
|
||||
.map(|output| output.trim().to_string())
|
||||
.unwrap_or_else(|_| "(unborn)".to_string())
|
||||
}
|
||||
|
||||
fn read_git_branch(root: &Path) -> Option<String> {
|
||||
run_git(root, &["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
.ok()
|
||||
.map(|output| output.trim().to_string())
|
||||
.filter(|output| !output.is_empty())
|
||||
}
|
||||
|
||||
fn ensure_git_top_level(root: &Path) -> Result<(), String> {
|
||||
let top_level = run_git(root, &["rev-parse", "--show-toplevel"])
|
||||
.map_err(|_| "项目目录必须是 Git 仓库根目录".to_string())?;
|
||||
let top_level = Path::new(top_level.trim())
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("读取 Git 仓库根目录失败:{error}"))?;
|
||||
if top_level != root {
|
||||
return Err("项目目录必须是 Git 仓库根目录".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_status(root: &Path, output: &str) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut staged = Vec::new();
|
||||
let mut unstaged = Vec::new();
|
||||
let mut untracked = Vec::new();
|
||||
for record in output.split('\0').filter(|record| !record.is_empty()) {
|
||||
let bytes = record.as_bytes();
|
||||
if bytes.len() < 4 || bytes[2] != b' ' {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = safe_git_path(root, &record[3..]) else {
|
||||
continue;
|
||||
};
|
||||
if bytes[0] == b'?' && bytes[1] == b'?' {
|
||||
untracked.push(path);
|
||||
continue;
|
||||
}
|
||||
if bytes[0] != b' ' && bytes[0] != b'?' {
|
||||
staged.push(path.clone());
|
||||
}
|
||||
if bytes[1] != b' ' && bytes[1] != b'?' {
|
||||
unstaged.push(path);
|
||||
}
|
||||
}
|
||||
(staged, unstaged, untracked)
|
||||
}
|
||||
|
||||
fn safe_git_path(root: &Path, path: &str) -> Option<String> {
|
||||
let normalized = normalize_relative_path(path).ok()?;
|
||||
reject_sensitive_project_file_read(&normalized).ok()?;
|
||||
let parts = normalized
|
||||
.split('/')
|
||||
.take(2)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect::<Vec<_>>();
|
||||
if parts.first().is_some_and(|part| part == ".git") {
|
||||
return None;
|
||||
}
|
||||
if parts.first().is_some_and(|part| part == ".agent")
|
||||
&& parts
|
||||
.get(1)
|
||||
.is_some_and(|part| matches!(part.as_str(), "runtime" | "checkpoints"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !git_worktree_path_is_safe(root, &normalized) {
|
||||
return None;
|
||||
}
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn git_worktree_path_is_safe(root: &Path, relative_path: &str) -> bool {
|
||||
let mut current = root.to_path_buf();
|
||||
let components = relative_path.split('/').collect::<Vec<_>>();
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
current.push(component);
|
||||
match std::fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return false;
|
||||
}
|
||||
if index + 1 < components.len() && !metadata.is_dir() {
|
||||
return false;
|
||||
}
|
||||
if index + 1 == components.len()
|
||||
&& (!metadata.is_file() || metadata_has_multiple_hard_links(&metadata))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return index + 1 == components.len();
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn metadata_has_multiple_hard_links(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
metadata.nlink() > 1
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn metadata_has_multiple_hard_links(_metadata: &std::fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn append_path_section(
|
||||
output: &mut String,
|
||||
title: &str,
|
||||
paths: &[String],
|
||||
selected_paths: &BTreeSet<String>,
|
||||
) {
|
||||
let selected = paths
|
||||
.iter()
|
||||
.filter(|path| selected_paths.contains(*path))
|
||||
.collect::<Vec<_>>();
|
||||
if selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(output, "\n## {title}");
|
||||
for path in selected {
|
||||
let _ = writeln!(output, "- {path}");
|
||||
}
|
||||
}
|
||||
|
||||
fn read_diff(root: &Path, cached: bool, paths: &[String]) -> Result<String, String> {
|
||||
if paths.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let mut args = vec![
|
||||
"diff".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
"--no-color".to_string(),
|
||||
"--no-renames".to_string(),
|
||||
"--unified=3".to_string(),
|
||||
];
|
||||
if cached {
|
||||
args.push("--cached".to_string());
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(paths.iter().cloned());
|
||||
run_git_owned(root, &args)
|
||||
}
|
||||
|
||||
fn append_diff_section(output: &mut String, title: &str, diff: &str) {
|
||||
if !diff.is_empty() {
|
||||
let _ = write!(output, "\n## {title}\n{diff}");
|
||||
if !diff.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git(root: &Path, args: &[&str]) -> Result<String, String> {
|
||||
run_git_owned(
|
||||
root,
|
||||
&args
|
||||
.iter()
|
||||
.map(|arg| (*arg).to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_git_owned(root: &Path, args: &[String]) -> Result<String, String> {
|
||||
let inherited_environment = ["PATH", "SystemRoot", "WINDIR", "PATHEXT"]
|
||||
.into_iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(|value| (key, value)))
|
||||
.collect::<Vec<_>>();
|
||||
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
||||
let mut command = Command::new("git");
|
||||
command.env_clear();
|
||||
for (key, value) in inherited_environment {
|
||||
command.env(key, value);
|
||||
}
|
||||
command
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_SYSTEM", null_device)
|
||||
.env("GIT_CONFIG_GLOBAL", null_device)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_PAGER", "cat")
|
||||
.env("PAGER", "cat")
|
||||
.env("TERM", "dumb")
|
||||
.current_dir(root)
|
||||
.arg("--no-pager")
|
||||
.arg("--no-optional-locks")
|
||||
.arg("-c")
|
||||
.arg("core.fsmonitor=false")
|
||||
.arg("-c")
|
||||
.arg(format!("core.hooksPath={null_device}"))
|
||||
.arg("-c")
|
||||
.arg("core.pager=cat")
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(parent) = root.parent() {
|
||||
command.env("GIT_CEILING_DIRECTORIES", parent);
|
||||
}
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("启动 Git 检查失败:{error}"))?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let stdout_reader = thread::spawn(move || read_bounded(stdout));
|
||||
let stderr_reader = thread::spawn(move || read_bounded(stderr));
|
||||
let started = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if started.elapsed() < GIT_INSPECT_TIMEOUT => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err("Git 检查超时".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("等待 Git 检查失败:{error}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let stdout = stdout_reader.join().unwrap_or_default();
|
||||
let stderr = stderr_reader.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
let detail = String::from_utf8_lossy(&stderr).trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
"Git 检查失败".to_string()
|
||||
} else {
|
||||
format!("Git 检查失败:{detail}")
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&stdout).into_owned())
|
||||
}
|
||||
|
||||
fn read_bounded<R: Read>(stream: Option<R>) -> Vec<u8> {
|
||||
let Some(mut stream) = stream else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut collected = Vec::new();
|
||||
let mut buffer = [0_u8; 8 * 1024];
|
||||
while let Ok(read) = stream.read(&mut buffer) {
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let remaining = GIT_INSPECT_OUTPUT_MAX_BYTES.saturating_sub(collected.len());
|
||||
collected.extend_from_slice(&buffer[..read.min(remaining)]);
|
||||
}
|
||||
collected
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("run git fixture command");
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_bounded_changes_and_filters_sensitive_paths() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
let root = fixture.path();
|
||||
git(root, &["init", "-q"]);
|
||||
git(root, &["config", "user.name", "Test"]);
|
||||
git(root, &["config", "user.email", "test@example.invalid"]);
|
||||
fs::write(root.join("game.txt"), "first\n").expect("write tracked file");
|
||||
git(root, &["add", "game.txt"]);
|
||||
git(root, &["commit", "-qm", "initial"]);
|
||||
fs::write(root.join("game.txt"), "first\nsecond\n").expect("update tracked file");
|
||||
fs::write(root.join("new.txt"), "new\n").expect("write untracked file");
|
||||
fs::write(root.join(".env"), "SECRET=hidden\n").expect("write secret file");
|
||||
|
||||
let result =
|
||||
inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect git worktree");
|
||||
|
||||
assert_eq!(result.unstaged, vec!["game.txt"]);
|
||||
assert_eq!(result.untracked, vec!["new.txt"]);
|
||||
assert!(result.content.contains("+second"));
|
||||
assert!(!result.content.contains("SECRET"));
|
||||
assert!(!result.content.contains(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_project_nested_inside_another_repository() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
git(fixture.path(), &["init", "-q"]);
|
||||
let nested = fixture.path().join("nested");
|
||||
fs::create_dir(&nested).expect("create nested directory");
|
||||
|
||||
let error = inspect_local_git_worktree_at(&nested, false, 20, 24_000)
|
||||
.expect_err("nested project must not inspect parent repository");
|
||||
|
||||
assert!(error.contains("仓库根目录"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn omits_hard_linked_and_symlinked_worktree_paths() {
|
||||
let fixture = tempfile::tempdir().expect("create fixture");
|
||||
let outside = tempfile::tempdir().expect("create outside fixture");
|
||||
let root = fixture.path();
|
||||
git(root, &["init", "-q"]);
|
||||
fs::write(outside.path().join("shared.txt"), "outside hard link\n")
|
||||
.expect("write hard link source");
|
||||
fs::hard_link(
|
||||
outside.path().join("shared.txt"),
|
||||
root.join("hard-linked.txt"),
|
||||
)
|
||||
.expect("create hard link");
|
||||
std::os::unix::fs::symlink(
|
||||
outside.path().join("shared.txt"),
|
||||
root.join("symlinked.txt"),
|
||||
)
|
||||
.expect("create symlink");
|
||||
|
||||
let result =
|
||||
inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect worktree");
|
||||
|
||||
assert!(!result.untracked.contains(&"hard-linked.txt".to_string()));
|
||||
assert!(!result.untracked.contains(&"symlinked.txt".to_string()));
|
||||
assert!(!result.content.contains("outside hard link"));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ mod commands;
|
||||
mod config;
|
||||
#[cfg(all(debug_assertions, not(test)))]
|
||||
mod debug;
|
||||
mod git_inspect;
|
||||
mod isolated_agent;
|
||||
mod patchset;
|
||||
mod preview;
|
||||
@@ -64,6 +65,7 @@ use cli::*;
|
||||
use command_exec::*;
|
||||
use commands::*;
|
||||
use config::*;
|
||||
use git_inspect::*;
|
||||
use isolated_agent::*;
|
||||
use patchset::*;
|
||||
use preview::*;
|
||||
|
||||
@@ -944,17 +944,37 @@ function AgentRuntimeStatusPanel({
|
||||
<section className="agent-runtime-status" aria-label="Agent Runtime 状态">
|
||||
<header>
|
||||
<strong>Runtime 状态读取失败</strong>
|
||||
</header>
|
||||
<p>{error}</p>
|
||||
{onRefreshRuntime ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={controlBusy}
|
||||
onClick={onRefreshRuntime}
|
||||
className="agent-runtime-collapse-toggle"
|
||||
aria-label={collapsed ? '展开 Runtime 详情' : '折叠 Runtime 详情'}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? '展开 Runtime 详情' : '折叠 Runtime 详情'}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
刷新状态
|
||||
{collapsed ? (
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronUp size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
{collapsed ? null : (
|
||||
<div className="agent-runtime-status-details">
|
||||
<p>{error}</p>
|
||||
{onRefreshRuntime ? (
|
||||
<div className="agent-runtime-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={controlBusy}
|
||||
onClick={onRefreshRuntime}
|
||||
>
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3599,6 +3599,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
|
||||
expect(await screen.findByText('Runtime 状态读取失败')).not.toBeNull();
|
||||
expect(screen.getByText('runtime json broken')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '折叠 Runtime 详情' }),
|
||||
);
|
||||
expect(screen.queryByText('runtime json broken')).toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '展开 Runtime 详情' }),
|
||||
);
|
||||
expect(screen.getByText('runtime json broken')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('persists developer agent chat reply failures after saving the user message', async () => {
|
||||
|
||||
Reference in New Issue
Block a user