Compare commits

..

2 Commits

Author SHA1 Message Date
d18df0ca88 Merge pull request 'fix(coexist): 新炼境接管项目文件后,旧炼境的模板分发、蓝图规则同步与 buff 轮询跳过该项目' (#47) from fix/files-managed-guard into master
All checks were successful
Push & PR Check / check (push) Successful in 1m7s
Reviewed-on: #47
2026-10-06 12:08:04 +08:00
lanrtop
ec672bb027 fix(coexist): 新炼境接管项目文件后,旧炼境的模板分发、蓝图规则同步与 buff 轮询跳过该项目
All checks were successful
Push & PR Check / check (pull_request) Successful in 1m2s
共存期两边会来回覆盖同一批托管文件(AGENTS.md、CLAUDE.md、lian-jing 技能、CONVENTIONS、任务卡)。
新炼境(lian-jing-next)接管某项目时在 .lian-jing/context.json 写入 files_managed_by="lian-jing-next";
旧炼境据此跳过:dispatch_templates_with、sync_blueprint_rules_with、buff check_project。
身份合并写(write_canonical_context_with)本就保留未知字段,测试锁定接管标记不会被自己抹掉。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:46:48 +09:00
4 changed files with 86 additions and 0 deletions

View File

@ -1323,6 +1323,15 @@ pub(crate) fn sync_blueprint_rules_with(
root: &Path,
sink: &dyn crate::WriteSink,
) -> Result<SyncResult, crate::WriteFailure> {
if crate::mcp_inject::files_managed_by_next(root) {
return Ok(SyncResult {
success: true,
conventions_written: false,
claude_md_action: "skipped".into(),
agents_md_action: "skipped".into(),
message: crate::mcp_inject::FILES_MANAGED_BY_NEXT_SKIP.into(),
});
}
let bp_dir = root.join(".blueprint");
let project_name = root
.file_name()
@ -2876,6 +2885,22 @@ pub fn archive_project_docs(project_path: String) -> Result<ArchiveResult, Strin
#[cfg(test)]
mod tests {
use super::*;
// 共存期防回写:项目文件已由新炼境接管时,蓝图规则同步零写入
#[test]
fn sync_skips_projects_managed_by_next() {
let base = std::env::temp_dir().join(format!("lianjing_sync_guard_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
std::fs::create_dir_all(base.join(".blueprint")).unwrap();
std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"lian-jing-next"}"#).unwrap();
let result = sync_blueprint_rules_with(&base, &crate::DiskSink).unwrap();
assert_eq!((result.agents_md_action.as_str(), result.claude_md_action.as_str()), ("skipped", "skipped"));
assert!(!result.conventions_written);
assert!(!base.join(".blueprint/CONVENTIONS.md").exists());
assert!(!base.join("AGENTS.md").exists() && !base.join("CLAUDE.md").exists());
let _ = std::fs::remove_dir_all(&base);
}
use rusqlite::Connection;
use std::fs;

View File

@ -125,6 +125,10 @@ fn poll_all_buffs(app: &AppHandle) {
fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
let root = Path::new(project_path);
// 共存期防回写:项目文件已由新炼境接管,不再改写其任务卡
if crate::mcp_inject::files_managed_by_next(root) {
return Ok(());
}
// 获取当前 HEAD commit
let current_commit = match get_head_commit(root) {

View File

@ -199,6 +199,14 @@ pub(crate) fn dispatch_templates_with(
root: &Path,
sink: &dyn crate::WriteSink,
) -> Vec<TemplateResult> {
if crate::mcp_inject::files_managed_by_next(root) {
return vec![TemplateResult {
name: ".lian-jing/context.json".into(),
action: "skipped".into(),
detail: crate::mcp_inject::FILES_MANAGED_BY_NEXT_SKIP.into(),
warning: None,
}];
}
vec![
dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS, sink),
dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK, sink),
@ -1243,6 +1251,21 @@ pub fn silent_projects(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>
#[cfg(test)]
mod tests {
// 共存期防回写:项目文件已由新炼境接管时,接入包模板分发零写入
#[test]
fn dispatch_skips_projects_managed_by_next() {
let base = std::env::temp_dir().join(format!("lianjing_dispatch_guard_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
std::fs::create_dir_all(base.join(".blueprint")).unwrap();
std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"lian-jing-next"}"#).unwrap();
let results = super::dispatch_templates(base.to_str().unwrap());
assert_eq!(results.len(), 1);
assert_eq!(results[0].action, "skipped");
assert!(!base.join("AGENTS.md").exists() && !base.join(".claude").exists() && !base.join("lefthook.yml").exists());
let _ = std::fs::remove_dir_all(&base);
}
use super::*;
#[test]

View File

@ -235,6 +235,20 @@ fn write_json_with(
.map_err(|error| format!("写入 {label} 失败: {error}"))
}
/// 新炼境(lian-jing-next)接管项目文件后写入 `.lian-jing/context.json` 的 `files_managed_by` 取值。
pub(crate) const FILES_MANAGED_BY_NEXT: &str = "lian-jing-next";
/// 共存期防回写守卫:项目文件已由新炼境接管时,旧炼境的模板分发、蓝图规则同步与 buff 轮询一律跳过,
/// 避免两边来回覆盖同一批托管文件。只读判断,文件缺失或不合法时视为未接管。
pub(crate) fn files_managed_by_next(root: &std::path::Path) -> bool {
std::fs::read_to_string(root.join(".lian-jing").join("context.json"))
.ok()
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.is_some_and(|ctx| ctx.get("files_managed_by").and_then(Value::as_str) == Some(FILES_MANAGED_BY_NEXT))
}
pub(crate) const FILES_MANAGED_BY_NEXT_SKIP: &str = "项目文件已由新炼境接管(.lian-jing/context.json 的 files_managed_by),旧炼境不再分发";
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
#[cfg(test)]
@ -3623,6 +3637,26 @@ trust_level = "trusted"
);
}
#[test]
fn files_managed_by_next_guard_reads_context_and_survives_identity_rewrite() {
let base = std::env::temp_dir().join(format!("lianjing_files_guard_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".lian-jing")).unwrap();
assert!(!files_managed_by_next(&base), "没有 context.json 视为未接管");
std::fs::write(
base.join(".lian-jing/context.json"),
r#"{"project_id":"p1","schema_version":1,"files_managed_by":"lian-jing-next"}"#,
)
.unwrap();
assert!(files_managed_by_next(&base));
// 旧炼境刷新身份时必须保留接管标记,否则守卫会被自己抹掉
write_canonical_context_at(&base, "p1").unwrap();
assert!(files_managed_by_next(&base), "身份合并写后接管标记仍在");
std::fs::write(base.join(".lian-jing/context.json"), r#"{"project_id":"p1","files_managed_by":"other"}"#).unwrap();
assert!(!files_managed_by_next(&base));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() {
let base = std::env::temp_dir().join(format!(