导出工作目录从索引、checkpoint 与 git 检查中排除

- filesystem 新增 should_skip_project_derived_path(快照口径 + .export),索引改用它
- checkpoint 恢复与清单过滤改用新口径,.export 不再进 checkpoint
- git_inspect 的提交路径校验与工作区路径过滤改用新口径,.export 不再算未跟踪/可提交
- 常量改名 PROJECT_DERIVED_EXCLUDED_COMPONENTS,project_snapshot 测试补双向断言
This commit is contained in:
2026-10-06 19:23:14 +08:00
parent 1897bf54d0
commit 065a193e4e
4 changed files with 65 additions and 17 deletions
@@ -1,5 +1,5 @@
use crate::command_exec::resolve_project_command_spec_at;
use crate::project::{normalize_relative_path, should_skip_project_snapshot_path};
use crate::project::{normalize_relative_path, should_skip_project_derived_path};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
@@ -1046,7 +1046,7 @@ fn validate_commit_paths(paths: &[String]) -> Result<Vec<String>, LocalGitCommit
let normalized = normalize_relative_path(path)
.map_err(|_| LocalGitCommitError::ordinary("Git 提交路径必须是规范化的项目相对路径"))?;
if normalized != *path
|| should_skip_project_snapshot_path(&normalized)
|| should_skip_project_derived_path(&normalized)
|| !unique.insert(normalized.clone())
{
return Err(LocalGitCommitError::ordinary(
@@ -1971,7 +1971,7 @@ fn parse_status(root: &Path, output: &str) -> (Vec<String>, Vec<String>, Vec<Str
fn safe_git_path(root: &Path, path: &str) -> Option<String> {
let normalized = normalize_relative_path(path).ok()?;
if should_skip_project_snapshot_path(&normalized) {
if should_skip_project_derived_path(&normalized) {
return None;
}
if !git_worktree_path_is_safe(root, &normalized) {
@@ -673,7 +673,7 @@ pub(crate) fn restore_local_project_checkpoint_at(
}
pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool {
should_skip_project_snapshot_path(relative_path)
should_skip_project_derived_path(relative_path)
|| relative_path == ".agent/agent.db"
|| relative_path == PROJECT_PERMISSION_POLICY_PATH
|| relative_path == PROJECT_WRITE_LOCK_PATH
@@ -787,7 +787,7 @@ pub(crate) fn read_checkpoint_files(
}
file.path = normalized;
}
files.retain(|file| !should_skip_project_snapshot_path(&file.path));
files.retain(|file| !should_skip_project_derived_path(&file.path));
Ok(files)
}
@@ -397,15 +397,37 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool {
|| relative_path == PROJECT_INDEX_PATH
|| relative_path.starts_with(".agent/checkpoints/")
|| relative_path.starts_with(".agent/runtime/")
|| should_skip_project_snapshot_path(relative_path)
|| should_skip_project_derived_path(relative_path)
}
/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的
/// 本机控制面,不参与其中。
/// Agent 视角的排除口径:`.agent` 是本机控制面,写入门禁(`agent/direct_patch.rs`)与
/// Agent 上下文(prompt / tool bridge)拿它判断「Agent 能不能碰、看不看得到」。
///
/// 它**不**包含 `.export`:那个目录必须让 code agent 能写。把项目内容当数据来读的消费者
/// (索引、checkpoint、git 检查)请改用 `should_skip_project_derived_path`。
pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
project_snapshot_path_is_excluded(relative_path, false)
}
/// 索引、checkpoint 与 git 检查共用的排除口径:在本机控制面之外,再排除本机派生的导出工作目录
/// (`.export`,见 `PROJECT_DERIVED_EXCLUDED_COMPONENTS`)。
///
/// 这些文件能从项目源码重跑出来,不该进索引、checkpoint 或版本库视角,也不该被当成项目变更;
/// 但 Agent 必须能写它们,所以不能并进 `should_skip_project_snapshot_path`——那份列表同时是
/// `agc_apply_patch` 的写入门禁,并进去会让首次适配直接卡死。
pub(crate) fn should_skip_project_derived_path(relative_path: &str) -> bool {
project_snapshot_path_is_excluded(relative_path, false)
|| relative_path
.split('/')
.filter(|component| !component.is_empty())
.any(|component| {
let component = component.to_ascii_lowercase();
PROJECT_DERIVED_EXCLUDED_COMPONENTS
.iter()
.any(|candidate| *candidate == component)
})
}
/**
* 项目快照同步(上传)的排除口径。
*
@@ -492,16 +514,17 @@ const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[
const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"];
/**
* 只对快照同步生效的排除组件。
* 本机派生的导出工作目录(`.export`):Agent 可见,其它消费者一律排除。
*
* 不能并进上面的通用列表:那份列表同时是 Agent 的写入门禁(`agent/direct_patch.rs` 直接拿它
* 拒绝路径),而 `.export` 恰恰必须让 code agent 能写——导出注册表、打包脚本拷贝都由它生成。
* 放进去会让 `agc_apply_patch` 回「不得修改受保护或排除路径」,首次适配直接卡死。
*
* `.export` 是本机派生的导出工作目录:注册表、脚本拷贝、icon 与 zip 产物都能从项目源码重跑
* 出来,zip 动辄数 MB,因此不进快照同步;换机或恢复后重新适配即可。
* 也不能只排快照同步:索引会把多 MB 的 zip 整份读进内存算 checksum,checkpoint 会把它复制进
* `.agent/checkpoints`,git 检查会把它当未跟踪文件放出去,允许提交。所以「把项目内容当数据
* 来读」的消费者(索引、checkpoint、git 检查)都走 `should_skip_project_derived_path`。
*/
const PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS: &[&str] = &[".export"];
const PROJECT_DERIVED_EXCLUDED_COMPONENTS: &[&str] = &[".export"];
fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool {
let components = relative_path
@@ -513,9 +536,9 @@ fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: b
PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str())
&& !(include_agent_state && component == ".agent")
}) || (include_agent_state
&& components.iter().any(|component| {
PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS.contains(&component.as_str())
}))
&& components
.iter()
.any(|component| PROJECT_DERIVED_EXCLUDED_COMPONENTS.contains(&component.as_str())))
{
return true;
}
@@ -232,14 +232,39 @@ fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials()
assert!(should_skip_project_index_path(".agent/manifest.json"));
assert!(should_skip_project_index_path(".agent/agent.db"));
// `.export/` 只排除在快照同步之外:通用口径同时是 Agent 的写入门禁
// (`agc_apply_patch` 直接拿它拒路径),一并拒掉会让 code agent 写不了适配脚本。
// `.export/` 不进 Agent 的写入门禁:通用口径同时是 `agc_apply_patch` 的拒路径依据,
// 一并拒掉会让 code agent 写不了适配脚本。
assert!(!should_skip_project_snapshot_path(
".export/xhs-minitool.pack.mjs"
));
assert!(!should_skip_project_snapshot_path(
".export/xhs-minitool.json"
));
// 但「把项目内容当数据来读」的消费者(索引 / checkpoint / git 检查)一律排除它:
// zip 能从项目源码重跑,不该进索引、checkpoint 清单或版本库视角。
for derived_path in [
".export/xhs-minitool.zip",
".export/xhs-minitool.json",
".export/xhs-minitool.pack.mjs",
".EXPORT/xhs-minitool.zip",
] {
assert!(
should_skip_project_derived_path(derived_path),
"数据视角共用口径应排除本机派生目录:{derived_path}"
);
assert!(
should_skip_project_index_path(derived_path),
"索引应排除本机派生目录:{derived_path}"
);
assert!(
should_skip_project_restore_path(derived_path),
"checkpoint 恢复应排除本机派生目录:{derived_path}"
);
}
// 普通源码目录不受影响,只有 `.export` 这类派生目录被额外排除。
assert!(!should_skip_project_derived_path("game/main.ts"));
assert!(!should_skip_project_index_path("game/main.ts"));
}
#[test]