补齐Agent Git工作树审阅与聊天状态交互

This commit is contained in:
AIGameCreator App
2026-07-13 11:21:29 +08:00
parent 5c3b8ec267
commit 5e1d4ddeee
10 changed files with 720 additions and 22 deletions
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(&current) {
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::*;
+27 -7
View File
@@ -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 () => {