Make import existing side-effect free (#2429)

Co-authored-by: Xvvln <3369759202@qq.com>
This commit is contained in:
ayxwi
2026-04-30 21:59:47 +08:00
committed by GitHub
parent 1c69269405
commit 7965862e66
4 changed files with 161 additions and 23 deletions

View File

@@ -268,8 +268,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
@@ -306,8 +306,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
@@ -344,8 +344,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
@@ -382,8 +382,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
@@ -420,8 +420,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}

View File

@@ -1536,19 +1536,6 @@ impl SkillService {
// 保存到数据库
db.save_skill(&skill)?;
// 同步到已启用的应用目录(创建 symlink 或复制文件)
for app in AppType::all() {
if skill.apps.is_enabled_for(&app) {
if let Err(e) = Self::sync_to_app_dir(&skill.directory, &app) {
log::warn!(
"导入后同步 Skill '{}' 到 {:?} 失败: {e:#}",
skill.directory,
app
);
}
}
}
imported.push(skill);
}

View File

@@ -150,6 +150,110 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
);
}
#[test]
fn import_mcp_from_codex_does_not_rewrite_codex_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
let config_path = codex_dir.join("config.toml");
let original = r#"# keep user formatting intact
model = "gpt-5"
[mcp.servers.legacy]
type = "stdio"
command = "echo"
[mcp_servers.echo]
type = "stdio"
command = "echo"
"#;
fs::write(&config_path, original).expect("seed codex config");
let state = create_test_state().expect("create test state");
let changed = McpService::import_from_codex(&state).expect("import from codex");
assert!(changed > 0, "should import servers from Codex config");
let after = fs::read_to_string(&config_path).expect("read codex config");
assert_eq!(
after, original,
"importing from Codex should not rewrite ~/.codex/config.toml"
);
}
#[test]
fn import_mcp_from_claude_does_not_sync_existing_codex_enabled_server() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
let codex_config_path = codex_dir.join("config.toml");
let codex_original = r#"[mcp.servers.keep_me]
type = "stdio"
command = "echo"
"#;
fs::write(&codex_config_path, codex_original).expect("seed codex config");
let claude_json = json!({
"mcpServers": {
"shared": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
get_claude_mcp_path(),
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed claude mcp");
let state = create_test_state().expect("create test state");
state
.db
.save_mcp_server(&McpServer {
id: "shared".to_string(),
name: "shared".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: true,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
})
.expect("seed existing mcp server");
let changed = McpService::import_from_claude(&state).expect("import from claude");
assert_eq!(changed, 0, "existing server should not count as new");
let after = fs::read_to_string(&codex_config_path).expect("read codex config");
assert_eq!(
after, codex_original,
"importing from Claude should not sync an existing Codex-enabled server"
);
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let shared = servers.get("shared").expect("shared server exists");
assert!(
shared.apps.claude,
"import should enable Claude in database"
);
assert!(shared.apps.codex, "existing Codex flag should be preserved");
}
#[test]
fn import_mcp_from_claude_invalid_json_preserves_state() {
use support::create_test_state;

View File

@@ -72,6 +72,53 @@ fn import_from_apps_respects_explicit_app_selection() {
);
}
#[test]
fn import_from_apps_does_not_rewrite_selected_app_directory() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("codex-skill");
write_skill(&ssot_skill_dir, "Stale SSOT Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "stale ssot").expect("write stale ssot prompt");
let codex_skill_dir = home.join(".codex").join("skills").join("codex-skill");
write_skill(&codex_skill_dir, "Live Codex Skill");
fs::write(codex_skill_dir.join("prompt.md"), "live codex").expect("write live codex prompt");
let state = create_test_state().expect("create test state");
let imported = SkillService::import_from_apps(
&state.db,
vec![ImportSkillSelection {
directory: "codex-skill".to_string(),
apps: SkillApps {
codex: true,
..Default::default()
},
}],
)
.expect("import skills");
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
assert!(
imported[0].apps.codex,
"import should preserve the selected Codex app state"
);
assert_eq!(
fs::read_to_string(codex_skill_dir.join("prompt.md")).expect("read live codex prompt"),
"live codex",
"import should not replace the app skill directory with SSOT contents"
);
assert!(
!fs::symlink_metadata(&codex_skill_dir)
.expect("read codex skill metadata")
.file_type()
.is_symlink(),
"import should not replace the app skill directory with a managed symlink"
);
}
#[test]
fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
let _guard = test_mutex().lock().expect("acquire test mutex");