From 1ae227eca91a0ef6a295ae4ef303d244bd5f4f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=97=A5=E5=A4=A9?= Date: Thu, 2 Apr 2026 12:47:15 +0800 Subject: [PATCH] Strengthen cleanup logging and Windsurf ignore sync --- cli/src/commands/CleanCommand.ts | 28 +- cli/src/commands/ExecuteCommand.ts | 67 ++- cli/src/plugin-runtime.ts | 23 +- libraries/logger/src/index.test.ts | 20 +- libraries/logger/src/index.ts | 50 +- libraries/logger/src/lib.rs | 93 +++ sdk/src/core/cleanup.rs | 547 +++++++++++++++--- .../plugins/GitExcludeOutputPlugin.test.ts | 116 ++++ sdk/src/plugins/GitExcludeOutputPlugin.ts | 70 ++- sdk/src/plugins/WindsurfOutputPlugin.test.ts | 218 ++++++- sdk/src/plugins/WindsurfOutputPlugin.ts | 45 +- sdk/src/plugins/plugin-core/AindexTypes.ts | 10 +- sdk/src/plugins/plugin-core/constants.ts | 3 +- sdk/src/public-config-paths.ts | 1 + sdk/src/runtime/cleanup.ts | 85 ++- 15 files changed, 1227 insertions(+), 149 deletions(-) create mode 100644 sdk/src/plugins/GitExcludeOutputPlugin.test.ts diff --git a/cli/src/commands/CleanCommand.ts b/cli/src/commands/CleanCommand.ts index ec99c8bd..0d10e57b 100644 --- a/cli/src/commands/CleanCommand.ts +++ b/cli/src/commands/CleanCommand.ts @@ -5,13 +5,35 @@ export class CleanCommand implements Command { readonly name = 'clean' async execute(ctx: CommandContext): Promise { - const {logger, outputPlugins, createCleanContext} = ctx - logger.info('running clean pipeline', {command: 'clean'}) + const {logger, outputPlugins, createCleanContext, collectedOutputContext} = ctx + logger.info('started', { + command: 'clean', + pluginCount: outputPlugins.length, + projectCount: collectedOutputContext.workspace.projects.length, + workspaceDir: collectedOutputContext.workspace.directory.path + }) + logger.info('clean phase started', {phase: 'cleanup'}) const result = await performCleanup(outputPlugins, createCleanContext(false), logger) if (result.violations.length > 0 || result.conflicts.length > 0) { + logger.info('clean halted', { + phase: 'cleanup', + conflicts: result.conflicts.length, + violations: result.violations.length, + ...result.message != null ? {message: result.message} : {} + }) return {success: false, filesAffected: 0, dirsAffected: 0, ...result.message != null ? {message: result.message} : {}} } - logger.info('clean complete', {deletedFiles: result.deletedFiles, deletedDirs: result.deletedDirs}) + logger.info('clean phase complete', { + phase: 'cleanup', + deletedFiles: result.deletedFiles, + deletedDirs: result.deletedDirs, + errors: result.errors.length + }) + logger.info('complete', { + command: 'clean', + filesAffected: result.deletedFiles, + dirsAffected: result.deletedDirs + }) return {success: true, filesAffected: result.deletedFiles, dirsAffected: result.deletedDirs} } } diff --git a/cli/src/commands/ExecuteCommand.ts b/cli/src/commands/ExecuteCommand.ts index 2a100c6f..dbc62a92 100644 --- a/cli/src/commands/ExecuteCommand.ts +++ b/cli/src/commands/ExecuteCommand.ts @@ -5,17 +5,47 @@ export class ExecuteCommand implements Command { readonly name = 'execute' async execute(ctx: CommandContext): Promise { - const {logger, outputPlugins, createCleanContext, createWriteContext} = ctx - logger.info('started', {command: 'execute'}) + const {logger, outputPlugins, createCleanContext, createWriteContext, collectedOutputContext} = ctx + logger.info('started', { + command: 'execute', + pluginCount: outputPlugins.length, + projectCount: collectedOutputContext.workspace.projects.length, + workspaceDir: collectedOutputContext.workspace.directory.path + }) const writeCtx = createWriteContext(false) + logger.info('execute phase started', {phase: 'collect-output-declarations'}) const predeclaredOutputs = await collectOutputDeclarations(outputPlugins, writeCtx) + const declarationCount = [...predeclaredOutputs.values()] + .reduce((total, declarations) => total + declarations.length, 0) + logger.info('execute phase complete', { + phase: 'collect-output-declarations', + pluginCount: predeclaredOutputs.size, + declarationCount + }) + + logger.info('execute phase started', {phase: 'cleanup-before-write'}) const cleanupResult = await performCleanup(outputPlugins, createCleanContext(false), logger, predeclaredOutputs) if (cleanupResult.violations.length > 0 || cleanupResult.conflicts.length > 0) { + logger.info('execute halted', { + phase: 'cleanup-before-write', + conflicts: cleanupResult.conflicts.length, + violations: cleanupResult.violations.length, + ...cleanupResult.message != null ? {message: cleanupResult.message} : {} + }) return {success: false, filesAffected: 0, dirsAffected: 0, ...cleanupResult.message != null ? {message: cleanupResult.message} : {}} } - logger.info('cleanup complete', {deletedFiles: cleanupResult.deletedFiles, deletedDirs: cleanupResult.deletedDirs}) + logger.info('execute phase complete', { + phase: 'cleanup-before-write', + deletedFiles: cleanupResult.deletedFiles, + deletedDirs: cleanupResult.deletedDirs + }) + + logger.info('execute phase started', { + phase: 'write-output-files', + declarationCount + }) const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx, predeclaredOutputs) let totalFiles = 0 @@ -29,17 +59,46 @@ export class ExecuteCommand implements Command { } } + logger.info('execute phase complete', { + phase: 'write-output-files', + pluginCount: results.size, + filesAffected: totalFiles, + dirsAffected: totalDirs, + writeErrors: writeErrors.length + }) + if (writeErrors.length > 0) { + logger.info('execute halted', { + phase: 'write-output-files', + writeErrors: writeErrors.length + }) return {success: false, filesAffected: totalFiles, dirsAffected: totalDirs, message: writeErrors.join('\n')} } + logger.info('execute phase started', {phase: 'sync-wsl-mirrors'}) const wslMirrorResult = await syncWindowsConfigIntoWsl(outputPlugins, writeCtx, void 0, predeclaredOutputs) if (wslMirrorResult.errors.length > 0) { + logger.info('execute halted', { + phase: 'sync-wsl-mirrors', + mirroredFiles: wslMirrorResult.mirroredFiles, + errors: wslMirrorResult.errors.length + }) return {success: false, filesAffected: totalFiles, dirsAffected: totalDirs, message: wslMirrorResult.errors.join('\n')} } totalFiles += wslMirrorResult.mirroredFiles - logger.info('complete', {command: 'execute', pluginCount: results.size}) + logger.info('execute phase complete', { + phase: 'sync-wsl-mirrors', + mirroredFiles: wslMirrorResult.mirroredFiles, + warnings: wslMirrorResult.warnings.length, + errors: wslMirrorResult.errors.length + }) + logger.info('complete', { + command: 'execute', + pluginCount: results.size, + filesAffected: totalFiles, + dirsAffected: totalDirs + }) return {success: true, filesAffected: totalFiles, dirsAffected: totalDirs} } } diff --git a/cli/src/plugin-runtime.ts b/cli/src/plugin-runtime.ts index dfc0f0f7..00f34463 100644 --- a/cli/src/plugin-runtime.ts +++ b/cli/src/plugin-runtime.ts @@ -71,14 +71,27 @@ function flushAndExit(code: number): never { async function main(): Promise { const {subcommand, json, dryRun} = parseRuntimeArgs(process.argv) if (json) setGlobalLogLevel('silent') + const logger = createLogger('PluginRuntime') + + logger.info('runtime bootstrap started', {subcommand, json, dryRun}) const userPluginConfig = await createDefaultPluginConfig(process.argv, subcommand) let command = resolveRuntimeCommand(subcommand, dryRun) if (json && !new Set(['plugins']).has(command.name)) command = new JsonOutputCommand(command) const {context, outputPlugins, userConfigOptions} = userPluginConfig - const logger = createLogger('PluginRuntime') + logger.info('runtime configuration resolved', { + command: command.name, + pluginCount: outputPlugins.length, + projectCount: context.workspace.projects.length, + workspaceDir: context.workspace.directory.path, + ...context.aindexDir != null ? {aindexDir: context.aindexDir} : {} + }) const runtimeTargets = discoverOutputRuntimeTargets(logger) + logger.info('runtime targets discovered', { + command: command.name, + jetbrainsCodexDirs: runtimeTargets.jetbrainsCodexDirs.length + }) const createCleanContext = (dry: boolean): OutputCleanContext => ({ logger, collectedOutputContext: context, @@ -102,7 +115,15 @@ async function main(): Promise { createCleanContext, createWriteContext } + logger.info('command dispatch started', {command: command.name}) const result = await command.execute(commandCtx) + logger.info('command dispatch complete', { + command: command.name, + success: result.success, + filesAffected: result.filesAffected, + dirsAffected: result.dirsAffected, + ...result.message != null ? {message: result.message} : {} + }) if (!result.success) flushAndExit(1) flushOutput() } diff --git a/libraries/logger/src/index.test.ts b/libraries/logger/src/index.test.ts index 5222f915..a4fcc338 100644 --- a/libraries/logger/src/index.test.ts +++ b/libraries/logger/src/index.test.ts @@ -83,10 +83,28 @@ describe('logger bindings', () => { logger.info('hello', {count: 1}) - expect(nativeLogger.log).toHaveBeenCalledWith('info', 'hello', '{"count":1}') + expect(nativeLogger.log).toHaveBeenCalledTimes(1) + expect(nativeLogger.log).toHaveBeenCalledWith( + 'info', + 'hello', + expect.any(String) + ) + const payload = JSON.parse(String(nativeLogger.log.mock.calls[0]?.[2])) as Record + expect(payload['count']).toBe(1) + expect(payload['loggerTiming']).toEqual(expect.any(String)) expect(nativeLogger.logDiagnostic).not.toHaveBeenCalled() }) + it('adds logger timing even when no metadata is provided', async () => { + const {createLogger} = await import('./index') + const logger = createLogger('logger-test') + + logger.info('hello') + + const payload = JSON.parse(String(nativeLogger.log.mock.calls[0]?.[2])) as Record + expect(payload['loggerTiming']).toEqual(expect.any(String)) + }) + it('skips serializing filtered plain logs on the JS side', async () => { const {createLogger} = await import('./index') const logger = createLogger('logger-test', 'info') diff --git a/libraries/logger/src/index.ts b/libraries/logger/src/index.ts index 9479d34c..946f18e5 100644 --- a/libraries/logger/src/index.ts +++ b/libraries/logger/src/index.ts @@ -74,6 +74,11 @@ const LOG_LEVEL_PRIORITY: Readonly> = { let napiBinding: NapiLoggerModule | undefined, napiBindingError: Error | undefined +const LOGGER_TIMING_STATE = { + processStartNs: process.hrtime.bigint(), + lastLogNs: void 0 as bigint | undefined +} + function isNapiLoggerModule(value: unknown): value is NapiLoggerModule { if (value == null || typeof value !== 'object') return false @@ -267,12 +272,55 @@ function normalizeLogArguments(message: string | object, meta: unknown[]): {mess } } +function formatElapsedMilliseconds(milliseconds: number): string { + if (!Number.isFinite(milliseconds) || milliseconds <= 0) return '0ms' + if (milliseconds >= 1000) return `${(milliseconds / 1000).toFixed(2)}s` + if (milliseconds >= 100) return `${Math.round(milliseconds)}ms` + return `${milliseconds.toFixed(1)}ms` +} + +function createLoggerTimingLabel(): string { + const nowNs = process.hrtime.bigint() + const sinceStartMs = Number(nowNs - LOGGER_TIMING_STATE.processStartNs) / 1_000_000 + const sincePreviousMs = LOGGER_TIMING_STATE.lastLogNs == null + ? sinceStartMs + : Number(nowNs - LOGGER_TIMING_STATE.lastLogNs) / 1_000_000 + + LOGGER_TIMING_STATE.lastLogNs = nowNs + return `+${formatElapsedMilliseconds(sincePreviousMs)} since previous log, ${formatElapsedMilliseconds(sinceStartMs)} since process start` +} + +function injectLoggerTiming(metaJson: string | undefined): string { + const loggerTiming = createLoggerTimingLabel() + if (metaJson == null) return serializePayload({loggerTiming}) + + try { + const parsed = JSON.parse(metaJson) as unknown + if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) { + return serializePayload({ + ...(parsed as Record), + loggerTiming + }) + } + + return serializePayload({ + loggerTiming, + meta: parsed + }) + } catch { + return serializePayload({ + loggerTiming, + meta: metaJson + }) + } +} + function createLogMethod(instance: NapiLoggerInstance, loggerLevel: LogLevel, level: PlainLogLevel): LoggerMethod { return (message: string | object, ...meta: unknown[]): void => { if (!shouldEmitLog(level, loggerLevel)) return const {message: normalizedMessage, metaJson} = normalizeLogArguments(message, meta) - instance.log(level, normalizedMessage, metaJson) + instance.log(level, normalizedMessage, injectLoggerTiming(metaJson)) } } diff --git a/libraries/logger/src/lib.rs b/libraries/logger/src/lib.rs index f4e87f23..2d3da3f3 100644 --- a/libraries/logger/src/lib.rs +++ b/libraries/logger/src/lib.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::{LazyLock, Mutex}; use std::thread; +use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -133,6 +134,8 @@ static GLOBAL_LOG_LEVEL: AtomicU8 = AtomicU8::new(255); // 255 = unset static BUFFERED_DIAGNOSTICS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); static OUTPUT_SINK: LazyLock> = LazyLock::new(spawn_output_sink); +static LOGGER_PROCESS_START: LazyLock = LazyLock::new(Instant::now); +static LOGGER_LAST_LOG_AT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); enum OutputCommand { Write { use_stderr: bool, output: String }, @@ -294,6 +297,95 @@ fn build_payload(message: &Value, meta: Option<&Value>) -> Value { Value::Object(map) } +fn format_elapsed_duration(duration: Duration) -> String { + let milliseconds = duration.as_secs_f64() * 1000.0; + if milliseconds <= 0.0 { + "0ms".to_string() + } else if milliseconds >= 1000.0 { + format!("{:.2}s", milliseconds / 1000.0) + } else if milliseconds >= 100.0 { + format!("{}ms", milliseconds.round() as i64) + } else { + format!("{milliseconds:.1}ms") + } +} + +fn create_logger_timing_label() -> String { + let now = Instant::now(); + let since_start = now.duration_since(*LOGGER_PROCESS_START); + let since_previous = match LOGGER_LAST_LOG_AT.lock() { + Ok(mut previous_log_at) => { + let previous = previous_log_at.unwrap_or(*LOGGER_PROCESS_START); + *previous_log_at = Some(now); + now.duration_since(previous) + } + Err(_) => since_start, + }; + + format!( + "+{} since previous log, {} since process start", + format_elapsed_duration(since_previous), + format_elapsed_duration(since_start) + ) +} + +fn payload_has_logger_timing(payload: &Value) -> bool { + match payload { + Value::Object(map) => { + if map.contains_key("loggerTiming") { + return true; + } + + if map.len() == 1 + && let Some(Value::Object(nested)) = map.values().next() + { + return nested.contains_key("loggerTiming"); + } + + false + } + _ => false, + } +} + +fn attach_logger_timing(payload: &Value) -> Value { + if payload_has_logger_timing(payload) { + return payload.clone(); + } + + let logger_timing = Value::String(create_logger_timing_label()); + + match payload { + Value::String(message) => { + let mut map = Map::new(); + map.insert("message".to_string(), Value::String(message.clone())); + map.insert("loggerTiming".to_string(), logger_timing.clone()); + Value::Object(map) + } + Value::Object(map) => { + if map.len() == 1 + && let Some((message, Value::Object(nested))) = map.iter().next() + { + let mut nested_map = nested.clone(); + nested_map.insert("loggerTiming".to_string(), logger_timing.clone()); + let mut map_with_timing = Map::new(); + map_with_timing.insert(message.clone(), Value::Object(nested_map)); + return Value::Object(map_with_timing); + } + + let mut map_with_timing = map.clone(); + map_with_timing.insert("loggerTiming".to_string(), logger_timing.clone()); + Value::Object(map_with_timing) + } + _ => { + let mut map = Map::new(); + map.insert("loggerTiming".to_string(), logger_timing); + map.insert("value".to_string(), payload.clone()); + Value::Object(map) + } + } +} + fn append_section( lines: &mut Vec, title: &str, @@ -741,6 +833,7 @@ fn print_output(level: LogLevel, output: &str) { } fn emit_message_log_record(level: LogLevel, namespace: &str, payload: Value) -> LogRecord { + let payload = attach_logger_timing(&payload); let record = LogRecord { meta: ( String::new(), diff --git a/sdk/src/core/cleanup.rs b/sdk/src/core/cleanup.rs index 367b79c9..4d476a91 100644 --- a/sdk/src/core/cleanup.rs +++ b/sdk/src/core/cleanup.rs @@ -5,6 +5,8 @@ use std::path::{Component, Path, PathBuf}; use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder}; use serde::{Deserialize, Serialize}; +use serde_json::json; +use tnmsc_logger::create_logger; use walkdir::WalkDir; use crate::core::{config, desk_paths}; @@ -195,6 +197,8 @@ struct CompiledProtectedRule { #[derive(Debug, Clone)] struct ProtectedDeletionGuard { compiled_rules: Vec, + rule_indices_by_key: std::collections::BTreeMap>, + recursive_rule_indices_by_key: HashMap>, } struct PartitionResult { @@ -530,10 +534,28 @@ impl BatchedGlobPlanner { /// Execute the batched glob expansion and fan results back to targets. /// Returns (protected_matches, delete_matches) where each is a vec of (target_index, matched_paths). fn execute(&self) -> Result { + let logger = create_logger("CleanupNative", None); let mut protected_results: HashMap> = HashMap::new(); let mut delete_results: HashMap> = HashMap::new(); + let literal_pattern_count = self + .normalized_patterns + .iter() + .filter(|pattern| !has_glob_magic(pattern)) + .count(); + let glob_pattern_count = self.normalized_patterns.len() - literal_pattern_count; + + tnmsc_logger::log_info!( + logger, + "cleanup native glob execute started", + json!({ + "literalPatternCount": literal_pattern_count, + "globPatternCount": glob_pattern_count, + "groupCount": self.groups.len(), + }) + ); // Process literal paths (non-glob patterns) directly + let mut literal_match_count = 0usize; for (pattern_index, pattern) in self.normalized_patterns.iter().enumerate() { if has_glob_magic(pattern) { continue; @@ -580,9 +602,22 @@ impl BatchedGlobPlanner { .entry(metadata.target_index) .or_default() .push(normalized_entry); + literal_match_count += 1; } + tnmsc_logger::log_info!( + logger, + "cleanup native glob literal processing complete", + json!({ + "literalPatternCount": literal_pattern_count, + "literalMatches": literal_match_count, + }) + ); + // Process each group's patterns with a single directory walk + let mut walked_entries = 0usize; + let mut matched_entries = 0usize; + let mut matched_pattern_events = 0usize; for group in &self.groups { if !group.scan_root.exists() { continue; @@ -612,12 +647,15 @@ impl BatchedGlobPlanner { let Ok(entry) = entry else { continue; }; + walked_entries += 1; let candidate = path_to_glob_string(entry.path()); let matched_indices = matcher.matches(&candidate); if matched_indices.is_empty() { continue; } + matched_entries += 1; + matched_pattern_events += matched_indices.len(); let normalized_entry = path_to_string(&normalize_path(entry.path())); @@ -653,7 +691,19 @@ impl BatchedGlobPlanner { } } + tnmsc_logger::log_info!( + logger, + "cleanup native glob group walks complete", + json!({ + "groupCount": self.groups.len(), + "walkedEntries": walked_entries, + "matchedEntries": matched_entries, + "matchedPatternEvents": matched_pattern_events, + }) + ); + // Convert HashMaps to sorted Vecs and deduplicate + tnmsc_logger::log_info!(logger, "cleanup native glob result compaction started", json!({})); let mut protected_vec: Vec<(usize, Vec)> = protected_results .into_iter() .map(|(idx, mut paths)| { @@ -674,6 +724,17 @@ impl BatchedGlobPlanner { .collect(); delete_vec.sort_by_key(|(idx, _)| *idx); + tnmsc_logger::log_info!( + logger, + "cleanup native glob result compaction complete", + json!({ + "protectedTargetCount": protected_vec.len(), + "deleteTargetCount": delete_vec.len(), + "protectedMatches": protected_vec.iter().map(|(_, paths)| paths.len()).sum::(), + "deleteMatches": delete_vec.iter().map(|(_, paths)| paths.len()).sum::(), + }) + ); + Ok((protected_vec, delete_vec)) } } @@ -927,86 +988,143 @@ fn create_guard( all_rules.extend_from_slice(rules); let compiled_rules = dedupe_and_compile_rules(&expand_protected_rules(&all_rules)?); + let mut rule_indices_by_key = std::collections::BTreeMap::>::new(); + let mut recursive_rule_indices_by_key = HashMap::>::new(); - Ok(ProtectedDeletionGuard { compiled_rules }) -} - -fn is_rule_match(target_key: &str, rule_key: &str, protection_mode: ProtectionModeDto) -> bool { - match protection_mode { - ProtectionModeDto::Direct => is_same_or_child_path(rule_key, target_key), - ProtectionModeDto::Recursive => { - is_same_or_child_path(target_key, rule_key) - || is_same_or_child_path(rule_key, target_key) + for (rule_index, rule) in compiled_rules.iter().enumerate() { + for comparison_key in &rule.comparison_keys { + rule_indices_by_key + .entry(comparison_key.clone()) + .or_default() + .push(rule_index); + if rule.protection_mode == ProtectionModeDto::Recursive { + recursive_rule_indices_by_key + .entry(comparison_key.clone()) + .or_default() + .push(rule_index); + } } } + + Ok(ProtectedDeletionGuard { + compiled_rules, + rule_indices_by_key, + recursive_rule_indices_by_key, + }) } -fn select_more_specific_rule( - candidate: &CompiledProtectedRule, - current: Option<&CompiledProtectedRule>, -) -> CompiledProtectedRule { +fn select_more_specific_rule<'a>( + candidate: &'a CompiledProtectedRule, + current: Option<&'a CompiledProtectedRule>, +) -> &'a CompiledProtectedRule { let Some(current) = current else { - return candidate.clone(); + return candidate; }; if candidate.specificity != current.specificity { return if candidate.specificity > current.specificity { - candidate.clone() + candidate } else { - current.clone() + current }; } if candidate.protection_mode != current.protection_mode { return if candidate.protection_mode == ProtectionModeDto::Recursive { - candidate.clone() + candidate } else { - current.clone() + current }; } if candidate.path < current.path { - candidate.clone() + candidate } else { - current.clone() + current } } -fn get_protected_path_violation( - target_path: &str, - guard: &ProtectedDeletionGuard, +fn comparison_key_ancestors(target_key: &str) -> impl Iterator + '_ { + Path::new(target_key) + .ancestors() + .map(path_to_string) + .map(|ancestor| { + if cfg!(windows) { + ancestor.to_lowercase() + } else { + ancestor + } + }) +} + +fn get_protected_path_violation_for_key<'a>( + absolute_target_path: &'a str, + target_key: &'a str, + guard: &'a ProtectedDeletionGuard, ) -> Option { - let absolute_target_path = path_to_string(&resolve_absolute_path(target_path)); - let target_keys = build_comparison_keys(&absolute_target_path); - let mut matched_rule: Option = None; - - for rule in &guard.compiled_rules { - let mut did_match = false; - for target_key in &target_keys { - for rule_key in &rule.comparison_keys { - if !is_rule_match(target_key, rule_key, rule.protection_mode) { - continue; - } + let mut matched_rule: Option<&CompiledProtectedRule> = None; + let mut seen_rule_indices = HashSet::new(); - matched_rule = Some(select_more_specific_rule(rule, matched_rule.as_ref())); - did_match = true; - break; + for ancestor_key in comparison_key_ancestors(target_key) { + let Some(rule_indices) = guard.recursive_rule_indices_by_key.get(&ancestor_key) else { + continue; + }; + for &rule_index in rule_indices { + if !seen_rule_indices.insert(rule_index) { + continue; } - if did_match { - break; + let rule = &guard.compiled_rules[rule_index]; + matched_rule = Some(select_more_specific_rule(rule, matched_rule)); + } + } + + for (rule_key, rule_indices) in guard.rule_indices_by_key.range(target_key.to_string()..) { + if !is_same_or_child_path(rule_key, target_key) { + break; + } + + for &rule_index in rule_indices { + if !seen_rule_indices.insert(rule_index) { + continue; } + let rule = &guard.compiled_rules[rule_index]; + matched_rule = Some(select_more_specific_rule(rule, matched_rule)); } } matched_rule.map(|rule| ProtectedPathViolationDto { - target_path: absolute_target_path, - protected_path: rule.path, + target_path: absolute_target_path.to_string(), + protected_path: rule.path.clone(), protection_mode: rule.protection_mode, - reason: rule.reason, - source: rule.source, + reason: rule.reason.clone(), + source: rule.source.clone(), }) } +fn get_protected_path_violation( + target_path: &str, + guard: &ProtectedDeletionGuard, +) -> Option { + let absolute_target_path = path_to_string(&resolve_absolute_path(target_path)); + let normalized_target_key = normalize_for_comparison(&absolute_target_path); + + if let Some(violation) = + get_protected_path_violation_for_key(&absolute_target_path, &normalized_target_key, guard) + { + return Some(violation); + } + + let Ok(real_path) = fs::canonicalize(&absolute_target_path) else { + return None; + }; + let canonical_target_key = normalize_for_comparison(&path_to_string(&real_path)); + if canonical_target_key == normalized_target_key { + return None; + } + + get_protected_path_violation_for_key(&absolute_target_path, &canonical_target_key, guard) +} + fn partition_deletion_targets(paths: &[String], guard: &ProtectedDeletionGuard) -> PartitionResult { let mut safe_paths = Vec::new(); let mut violations = Vec::new(); @@ -1031,45 +1149,44 @@ fn partition_deletion_targets(paths: &[String], guard: &ProtectedDeletionGuard) fn compact_deletion_targets(files: &[String], dirs: &[String]) -> (Vec, Vec) { let files_by_key = files .iter() - .map(|file_path| { - let resolved = path_to_string(&resolve_absolute_path(file_path)); - (resolved.clone(), resolved) - }) - .collect::>(); + .map(|file_path| path_to_string(&resolve_absolute_path(file_path))) + .collect::>(); let dirs_by_key = dirs .iter() - .map(|dir_path| { - let resolved = path_to_string(&resolve_absolute_path(dir_path)); - (resolved.clone(), resolved) - }) - .collect::>(); + .map(|dir_path| path_to_string(&resolve_absolute_path(dir_path))) + .collect::>(); let mut sorted_dir_entries = dirs_by_key.into_iter().collect::>(); sorted_dir_entries - .sort_by(|(left_key, _), (right_key, _)| left_key.len().cmp(&right_key.len())); - - let mut compacted_dirs: HashMap = HashMap::new(); - for (dir_key, dir_path) in sorted_dir_entries { - let covered_by_parent = compacted_dirs - .keys() - .any(|existing_parent_key| is_same_or_child_path(&dir_key, existing_parent_key)); + .sort_by(|left_key, right_key| left_key.len().cmp(&right_key.len()).then_with(|| left_key.cmp(right_key))); + + let mut compacted_dir_set = HashSet::new(); + let mut compacted_dir_paths = Vec::new(); + for dir_key in sorted_dir_entries { + let covered_by_parent = Path::new(&dir_key) + .ancestors() + .skip(1) + .map(path_to_string) + .any(|ancestor| compacted_dir_set.contains(&ancestor)); if !covered_by_parent { - compacted_dirs.insert(dir_key, dir_path); + compacted_dir_set.insert(dir_key.clone()); + compacted_dir_paths.push(dir_key); } } let mut compacted_files = Vec::new(); - for (file_key, file_path) in files_by_key { - let covered_by_dir = compacted_dirs - .keys() - .any(|dir_key| is_same_or_child_path(&file_key, dir_key)); + for file_path in files_by_key { + let covered_by_dir = Path::new(&file_path) + .ancestors() + .skip(1) + .map(path_to_string) + .any(|ancestor| compacted_dir_set.contains(&ancestor)); if !covered_by_dir { compacted_files.push(file_path); } } compacted_files.sort(); - let mut compacted_dir_paths = compacted_dirs.into_values().collect::>(); compacted_dir_paths.sort(); (compacted_files, compacted_dir_paths) @@ -1314,6 +1431,17 @@ fn default_protection_mode_for_target(target: &CleanupTargetDto) -> ProtectionMo } pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { + let logger = create_logger("CleanupNative", None); + tnmsc_logger::log_info!( + logger, + "cleanup native plan started", + json!({ + "pluginCount": snapshot.plugin_snapshots.len(), + "projectRootCount": snapshot.project_roots.len(), + "protectedRuleCount": snapshot.protected_rules.len(), + "emptyDirExcludeGlobs": snapshot.empty_dir_exclude_globs.len(), + }) + ); let mut delete_files = HashSet::new(); let mut delete_dirs = HashSet::new(); let mut protected_rules = snapshot.protected_rules.clone(); @@ -1393,6 +1521,18 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { } } + tnmsc_logger::log_info!( + logger, + "cleanup native plan inventory collected", + json!({ + "outputCount": output_path_owners.len(), + "deleteFileCandidates": delete_files.len(), + "deleteDirCandidates": delete_dirs.len(), + "protectedGlobTargets": protected_glob_targets.len(), + "deleteGlobTargets": delete_glob_targets.len(), + }) + ); + // Batch all glob patterns (both protected and delete) into a single planner // to minimize directory walks. This is the key performance optimization. let mut planner = BatchedGlobPlanner::new(&ignore_globs)?; @@ -1418,7 +1558,32 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { } // Execute the batched glob expansion + tnmsc_logger::log_info!( + logger, + "cleanup native glob expansion started", + json!({ + "protectedGlobTargets": protected_glob_targets.len(), + "deleteGlobTargets": delete_glob_targets.len(), + "excludeScanGlobs": ignore_globs.len(), + }) + ); let (protected_results, delete_results) = planner.execute()?; + let protected_glob_match_count = protected_results + .iter() + .map(|(_, paths)| paths.len()) + .sum::(); + let delete_glob_match_count = delete_results + .iter() + .map(|(_, paths)| paths.len()) + .sum::(); + tnmsc_logger::log_info!( + logger, + "cleanup native glob expansion complete", + json!({ + "protectedMatches": protected_glob_match_count, + "deleteMatches": delete_glob_match_count, + }) + ); // Fan protected glob results back to their targets for (target_index, matched_paths) in protected_results { @@ -1451,6 +1616,14 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { let guard = create_guard(&snapshot, &protected_rules)?; let conflicts = detect_cleanup_protection_conflicts(&output_path_owners, &guard); if !conflicts.is_empty() { + tnmsc_logger::log_info!( + logger, + "cleanup native plan blocked", + json!({ + "reason": "conflicts", + "conflicts": conflicts.len(), + }) + ); return Ok(CleanupPlan { files_to_delete: Vec::new(), dirs_to_delete: Vec::new(), @@ -1461,28 +1634,88 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { }); } - let file_partition = - partition_deletion_targets(&delete_files.into_iter().collect::>(), &guard); - let dir_partition = - partition_deletion_targets(&delete_dirs.into_iter().collect::>(), &guard); + let file_candidates = delete_files.into_iter().collect::>(); + let dir_candidates = delete_dirs.into_iter().collect::>(); + tnmsc_logger::log_info!( + logger, + "cleanup native file partition started", + json!({ + "candidateCount": file_candidates.len(), + "compiledRuleCount": guard.compiled_rules.len(), + }) + ); + let file_partition = partition_deletion_targets(&file_candidates, &guard); + tnmsc_logger::log_info!( + logger, + "cleanup native file partition complete", + json!({ + "candidateCount": file_candidates.len(), + "safeCount": file_partition.safe_paths.len(), + "violationCount": file_partition.violations.len(), + }) + ); + tnmsc_logger::log_info!( + logger, + "cleanup native directory partition started", + json!({ + "candidateCount": dir_candidates.len(), + "compiledRuleCount": guard.compiled_rules.len(), + }) + ); + let dir_partition = partition_deletion_targets(&dir_candidates, &guard); + tnmsc_logger::log_info!( + logger, + "cleanup native directory partition complete", + json!({ + "candidateCount": dir_candidates.len(), + "safeCount": dir_partition.safe_paths.len(), + "violationCount": dir_partition.violations.len(), + }) + ); + tnmsc_logger::log_info!(logger, "cleanup native target compaction started", json!({})); let (files_to_delete, dirs_to_delete) = compact_deletion_targets(&file_partition.safe_paths, &dir_partition.safe_paths); - let empty_dir_absolute_exclude_set = build_globset( - &snapshot - .empty_dir_exclude_globs + tnmsc_logger::log_info!( + logger, + "cleanup native target compaction complete", + json!({ + "compactedFiles": files_to_delete.len(), + "compactedDirs": dirs_to_delete.len(), + }) + ); + tnmsc_logger::log_info!( + logger, + "cleanup native target partition complete", + json!({ + "safeFiles": files_to_delete.len(), + "safeDirs": dirs_to_delete.len(), + "fileViolations": file_partition.violations.len(), + "dirViolations": dir_partition.violations.len(), + }) + ); + let mut empty_dir_absolute_exclude_patterns = snapshot + .empty_dir_exclude_globs + .iter() + .map(|pattern| { + if expand_home_path(pattern).is_absolute() { + normalize_glob_pattern(pattern) + } else { + path_to_glob_string(&resolve_absolute_path(&format!( + "{}/{}", + snapshot.workspace_dir, pattern + ))) + } + }) + .collect::>(); + // Skip workspace project trees entirely during workspace-level empty-directory pruning. + // Their internal cleanup is handled by the project-specific passes already. + empty_dir_absolute_exclude_patterns.extend( + snapshot + .project_roots .iter() - .map(|pattern| { - if expand_home_path(pattern).is_absolute() { - normalize_glob_pattern(pattern) - } else { - path_to_glob_string(&resolve_absolute_path(&format!( - "{}/{}", - snapshot.workspace_dir, pattern - ))) - } - }) - .collect::>(), - )?; + .map(|project_root| path_to_glob_string(&resolve_absolute_path(project_root))), + ); + let empty_dir_absolute_exclude_set = build_globset(&empty_dir_absolute_exclude_patterns)?; let empty_dir_relative_exclude_set = build_globset( &snapshot .empty_dir_exclude_globs @@ -1491,6 +1724,13 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { .map(|pattern| normalize_relative_glob_pattern(pattern)) .collect::>(), )?; + tnmsc_logger::log_info!( + logger, + "cleanup native empty directory planning started", + json!({ + "workspaceDir": snapshot.workspace_dir, + }) + ); let (empty_dirs_to_delete, empty_dir_violations) = plan_workspace_empty_directory_cleanup( &snapshot.workspace_dir, &files_to_delete, @@ -1499,12 +1739,32 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { &empty_dir_absolute_exclude_set, &empty_dir_relative_exclude_set, ); + tnmsc_logger::log_info!( + logger, + "cleanup native empty directory planning complete", + json!({ + "emptyDirsToDelete": empty_dirs_to_delete.len(), + "emptyDirViolations": empty_dir_violations.len(), + }) + ); let mut violations = file_partition.violations; violations.extend(dir_partition.violations); violations.extend(empty_dir_violations); violations.sort_by(|a, b| a.target_path.cmp(&b.target_path)); + tnmsc_logger::log_info!( + logger, + "cleanup native plan complete", + json!({ + "filesToDelete": files_to_delete.len(), + "dirsToDelete": dirs_to_delete.len(), + "emptyDirsToDelete": empty_dirs_to_delete.len(), + "violations": violations.len(), + "conflicts": 0, + }) + ); + Ok(CleanupPlan { files_to_delete, dirs_to_delete, @@ -1516,8 +1776,18 @@ pub fn plan_cleanup(snapshot: CleanupSnapshot) -> Result { } pub fn perform_cleanup(snapshot: CleanupSnapshot) -> Result { + let logger = create_logger("CleanupNative", None); + tnmsc_logger::log_info!(logger, "cleanup native perform started", json!({})); let plan = plan_cleanup(snapshot)?; if !plan.conflicts.is_empty() || !plan.violations.is_empty() { + tnmsc_logger::log_info!( + logger, + "cleanup native perform blocked", + json!({ + "conflicts": plan.conflicts.len(), + "violations": plan.violations.len(), + }) + ); return Ok(CleanupExecutionResultDto { deleted_files: 0, deleted_dirs: 0, @@ -1531,10 +1801,56 @@ pub fn perform_cleanup(snapshot: CleanupSnapshot) -> Result Result>(); errors.extend( - delete_result - .dir_errors + dir_result + .errors .into_iter() .map(|error| CleanupErrorDto { path: error.path, @@ -1563,9 +1879,9 @@ pub fn perform_cleanup(snapshot: CleanupSnapshot) -> Result Result name, + getAbsolutePath: () => path.join(workspaceBase, name) + } + } as Project +} + +function createWriteContext(workspaceBase: string, projects: readonly Project[]): OutputWriteContext { + return { + logger: createLogger('GitExcludeOutputPlugin', 'error'), + fs, + path, + glob: {} as never, + dryRun: true, + runtimeTargets: {jetbrainsCodexDirs: []}, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [...projects] + }, + globalGitIgnore: 'dist/\n', + shadowGitExclude: '.idea/\n' + } + } as unknown as OutputWriteContext +} + +function createCleanContext(workspaceBase: string, projects: readonly Project[]): OutputCleanContext { + return { + logger: createLogger('GitExcludeOutputPlugin', 'error'), + fs, + path, + glob: {} as never, + dryRun: true, + runtimeTargets: {jetbrainsCodexDirs: []}, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [...projects] + } + } + } as unknown as OutputCleanContext +} + +describe('gitExcludeOutputPlugin workspace cleanup', () => { + it('includes workspace-root .git/info/exclude for output and cleanup', async () => { + const workspaceBase = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-git-exclude-root-')) + const projectDir = path.join(workspaceBase, 'packages', 'app') + fs.mkdirSync(path.join(workspaceBase, '.git', 'info'), {recursive: true}) + fs.mkdirSync(path.join(projectDir, '.git', 'info'), {recursive: true}) + + try { + const plugin = new GitExcludeOutputPlugin() + const projects = [createWorkspaceRootProject(), createProject(workspaceBase, 'packages/app')] + const outputDeclarations = await plugin.declareOutputFiles(createWriteContext(workspaceBase, projects)) + const cleanupDeclarations = await plugin.declareCleanupPaths(createCleanContext(workspaceBase, projects)) + const outputPaths = outputDeclarations.map(declaration => declaration.path) + const cleanupPaths = cleanupDeclarations.delete?.map(target => target.path) ?? [] + + expect(outputPaths).toContain(path.join(workspaceBase, '.git', 'info', 'exclude')) + expect(outputPaths).toContain(path.join(projectDir, '.git', 'info', 'exclude')) + expect(cleanupPaths).toContain(path.join(workspaceBase, '.git', 'info', 'exclude')) + expect(cleanupPaths).toContain(path.join(projectDir, '.git', 'info', 'exclude')) + } finally { + fs.rmSync(workspaceBase, {recursive: true, force: true}) + } + }) + + it('still includes workspace-root .git/info/exclude when only the synthetic workspace project exists', async () => { + const workspaceBase = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-git-exclude-synthetic-root-')) + fs.mkdirSync(path.join(workspaceBase, '.git', 'info'), {recursive: true}) + + try { + const plugin = new GitExcludeOutputPlugin() + const projects = [createWorkspaceRootProject()] + const outputDeclarations = await plugin.declareOutputFiles(createWriteContext(workspaceBase, projects)) + const cleanupDeclarations = await plugin.declareCleanupPaths(createCleanContext(workspaceBase, projects)) + const outputPaths = outputDeclarations.map(declaration => declaration.path) + const cleanupPaths = cleanupDeclarations.delete?.map(target => target.path) ?? [] + const workspaceExcludePath = path.join(workspaceBase, '.git', 'info', 'exclude') + + expect(outputPaths).toEqual([workspaceExcludePath]) + expect(cleanupPaths).toEqual([workspaceExcludePath]) + } finally { + fs.rmSync(workspaceBase, {recursive: true, force: true}) + } + }) +}) diff --git a/sdk/src/plugins/GitExcludeOutputPlugin.ts b/sdk/src/plugins/GitExcludeOutputPlugin.ts index 8f20b92d..76877f72 100644 --- a/sdk/src/plugins/GitExcludeOutputPlugin.ts +++ b/sdk/src/plugins/GitExcludeOutputPlugin.ts @@ -1,4 +1,6 @@ import type { + OutputCleanContext, + OutputCleanupDeclarations, OutputFileDeclaration, OutputWriteContext } from './plugin-core' @@ -12,39 +14,36 @@ export class GitExcludeOutputPlugin extends AbstractOutputPlugin { override async declareOutputFiles(ctx: OutputWriteContext): Promise { const declarations: OutputFileDeclaration[] = [] - const {workspace, globalGitIgnore, shadowGitExclude} = ctx.collectedOutputContext + const {globalGitIgnore, shadowGitExclude} = ctx.collectedOutputContext const managedContent = this.buildManagedContent(globalGitIgnore, shadowGitExclude) if (managedContent.length === 0) return declarations const finalContent = this.normalizeContent(managedContent) - const writtenPaths = new Set() - const {projects} = workspace - - for (const project of projects) { - if (project.dirFromWorkspacePath == null) continue - - const projectDir = project.dirFromWorkspacePath.getAbsolutePath() - const gitRepoDirs = [projectDir, ...findAllGitRepos(projectDir)] - - for (const repoDir of gitRepoDirs) { - const gitInfoDir = resolveGitInfoDir(repoDir) - if (gitInfoDir == null) continue - - const excludePath = path.join(gitInfoDir, 'exclude') - if (writtenPaths.has(excludePath)) continue - writtenPaths.add(excludePath) - - declarations.push({ - path: excludePath, - scope: 'project', - source: {content: finalContent} - }) - } + for (const excludePath of this.collectManagedExcludePaths(ctx)) { + declarations.push({ + path: excludePath, + scope: 'project', + source: {content: finalContent} + }) } return declarations } + override async declareCleanupPaths( + ctx: OutputCleanContext + ): Promise { + const deletePaths = this.collectManagedExcludePaths(ctx).map(excludePath => ({ + path: excludePath, + kind: 'file' as const, + scope: 'project' as const, + label: 'delete.project' + })) + + if (deletePaths.length === 0) return {} + return {delete: deletePaths} + } + override async convertContent( declaration: OutputFileDeclaration, ctx: OutputWriteContext @@ -87,4 +86,27 @@ export class GitExcludeOutputPlugin extends AbstractOutputPlugin { if (trimmed.length === 0) return '' return `${trimmed}\n` } + + private collectManagedExcludePaths( + ctx: Pick + ): string[] { + const {workspace} = ctx.collectedOutputContext + const repoRoots = new Set([path.resolve(workspace.directory.path)]) + const excludePaths = new Set() + + for (const project of workspace.projects) { + if (project.dirFromWorkspacePath == null) continue + repoRoots.add(project.dirFromWorkspacePath.getAbsolutePath()) + } + + for (const repoRoot of repoRoots) { + for (const repoDir of [repoRoot, ...findAllGitRepos(repoRoot)]) { + const gitInfoDir = resolveGitInfoDir(repoDir) + if (gitInfoDir == null) continue + excludePaths.add(path.join(gitInfoDir, 'exclude')) + } + } + + return [...excludePaths] + } } diff --git a/sdk/src/plugins/WindsurfOutputPlugin.test.ts b/sdk/src/plugins/WindsurfOutputPlugin.test.ts index dbe7f76d..2e58d54a 100644 --- a/sdk/src/plugins/WindsurfOutputPlugin.test.ts +++ b/sdk/src/plugins/WindsurfOutputPlugin.test.ts @@ -1,10 +1,21 @@ -import type {CommandPrompt, OutputScopeSelection, OutputWriteContext, Project, RulePrompt, SkillPrompt} from './plugin-core' +import type {CommandPrompt, OutputCleanContext, OutputScopeSelection, OutputWriteContext, Project, RulePrompt, SkillPrompt} from './plugin-core' import * as fs from 'node:fs' +import * as os from 'node:os' import * as path from 'node:path' import {describe, expect, it} from 'vitest' -import {createLogger, FilePathKind, PromptKind} from './plugin-core' +import {createLogger, FilePathKind, IgnoreFiles, PromptKind} from './plugin-core' import {WindsurfOutputPlugin} from './WindsurfOutputPlugin' +class TestWindsurfOutputPlugin extends WindsurfOutputPlugin { + constructor(private readonly testHomeDir?: string) { + super() + } + + protected override getHomeDir(): string { + return this.testHomeDir ?? super.getHomeDir() + } +} + function createCommandPrompt(scope: 'project' | 'global', seriName: string): CommandPrompt { return { type: PromptKind.Command, @@ -137,6 +148,27 @@ function createWriteContext( } as OutputWriteContext } +function createCleanContext(workspaceBase = path.resolve('tmp/windsurf-clean')): OutputCleanContext { + return { + logger: createLogger('WindsurfOutputPlugin', 'error'), + fs, + path, + glob: {} as never, + dryRun: true, + runtimeTargets: {}, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [createWorkspaceRootProject()] + } + } + } as OutputCleanContext +} + describe('windsurfOutputPlugin synthetic workspace project output', () => { it('writes workflows and skills to each real project when project scope is selected', async () => { const workspaceBase = path.resolve('tmp/windsurf-project-scope') @@ -209,4 +241,186 @@ describe('windsurfOutputPlugin synthetic workspace project output', () => { ) expect(declarations.every(declaration => declaration.scope === 'project')).toBe(true) }) + + it('writes both Windsurf ignore files for non-prompt projects and keeps their matching content', async () => { + const workspaceBase = path.resolve('tmp/windsurf-ignore-output') + const plugin = new WindsurfOutputPlugin() + const context = { + logger: createLogger('WindsurfOutputPlugin', 'error'), + fs, + path, + glob: {} as never, + dryRun: true, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [ + createProject(workspaceBase, 'prompt-source', ['alpha'], true), + createProject(workspaceBase, 'consumer-app', ['beta']) + ] + }, + aiAgentIgnoreConfigFiles: [ + { + fileName: IgnoreFiles.WINDSURF_LEGACY, + content: 'legacy\n', + sourcePath: path.join(workspaceBase, 'aindex', 'public', IgnoreFiles.WINDSURF_LEGACY) + }, + { + fileName: IgnoreFiles.WINDSURF, + content: 'new\n', + sourcePath: path.join(workspaceBase, 'aindex', 'public', IgnoreFiles.WINDSURF) + } + ] + } + } as OutputWriteContext + + const declarations = await plugin.declareOutputFiles(context) + const codeIgnoreDeclaration = declarations.find( + declaration => declaration.path === path.join(workspaceBase, 'consumer-app', IgnoreFiles.WINDSURF) + ) + const legacyIgnoreDeclaration = declarations.find( + declaration => declaration.path === path.join(workspaceBase, 'consumer-app', IgnoreFiles.WINDSURF_LEGACY) + ) + + expect(codeIgnoreDeclaration).toBeDefined() + expect(codeIgnoreDeclaration?.source).toMatchObject({ + kind: 'ignoreFile', + content: 'new\n' + }) + expect(legacyIgnoreDeclaration).toBeDefined() + expect(legacyIgnoreDeclaration?.source).toMatchObject({ + kind: 'ignoreFile', + content: 'legacy\n' + }) + expect( + declarations.some( + declaration => declaration.path === path.join(workspaceBase, 'prompt-source', IgnoreFiles.WINDSURF) + ) + ).toBe(false) + expect( + declarations.some( + declaration => declaration.path === path.join(workspaceBase, 'prompt-source', IgnoreFiles.WINDSURF_LEGACY) + ) + ).toBe(false) + }) + + it('falls back from legacy input and still writes both Windsurf ignore files', async () => { + const workspaceBase = path.resolve('tmp/windsurf-ignore-legacy') + const plugin = new WindsurfOutputPlugin() + const context = { + logger: createLogger('WindsurfOutputPlugin', 'error'), + fs, + path, + glob: {} as never, + dryRun: true, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [createProject(workspaceBase, 'consumer-app', ['beta'])] + }, + aiAgentIgnoreConfigFiles: [ + { + fileName: IgnoreFiles.WINDSURF_LEGACY, + content: 'legacy\n', + sourcePath: path.join(workspaceBase, 'aindex', 'public', IgnoreFiles.WINDSURF_LEGACY) + } + ] + } + } as OutputWriteContext + + const declarations = await plugin.declareOutputFiles(context) + + expect( + declarations.find( + declaration => declaration.path === path.join(workspaceBase, 'consumer-app', IgnoreFiles.WINDSURF) + )?.source + ).toMatchObject({ + kind: 'ignoreFile', + content: 'legacy\n' + }) + expect( + declarations.find( + declaration => declaration.path === path.join(workspaceBase, 'consumer-app', IgnoreFiles.WINDSURF_LEGACY) + )?.source + ).toMatchObject({ + kind: 'ignoreFile', + content: 'legacy\n' + }) + }) + + it('falls back from .codeignore input and still writes legacy .codeiumignore', async () => { + const workspaceBase = path.resolve('tmp/windsurf-ignore-primary') + const plugin = new WindsurfOutputPlugin() + const context = { + logger: createLogger('WindsurfOutputPlugin', 'error'), + fs, + path, + glob: {} as never, + dryRun: true, + collectedOutputContext: { + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceBase, + getDirectoryName: () => path.basename(workspaceBase) + }, + projects: [createProject(workspaceBase, 'consumer-app', ['beta'])] + }, + aiAgentIgnoreConfigFiles: [ + { + fileName: IgnoreFiles.WINDSURF, + content: 'new\n', + sourcePath: path.join(workspaceBase, 'aindex', 'public', IgnoreFiles.WINDSURF) + } + ] + } + } as OutputWriteContext + + const declarations = await plugin.declareOutputFiles(context) + + expect( + declarations.find( + declaration => declaration.path === path.join(workspaceBase, 'consumer-app', IgnoreFiles.WINDSURF) + )?.source + ).toMatchObject({ + kind: 'ignoreFile', + content: 'new\n' + }) + expect( + declarations.find( + declaration => declaration.path === path.join(workspaceBase, 'consumer-app', IgnoreFiles.WINDSURF_LEGACY) + )?.source + ).toMatchObject({ + kind: 'ignoreFile', + content: 'new\n' + }) + }) +}) + +describe('windsurfOutputPlugin cleanup', () => { + it('declares cleanup for both .codeignore and legacy .codeiumignore', async () => { + const tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-windsurf-cleanup-')) + const workspaceBase = path.resolve('tmp/windsurf-clean') + + try { + const plugin = new TestWindsurfOutputPlugin(tempHomeDir) + const cleanup = await plugin.declareCleanupPaths(createCleanContext(workspaceBase)) + const deletePaths = cleanup.delete?.map(target => target.path.replaceAll('\\', '/')) ?? [] + + expect(deletePaths).toContain(path.join(workspaceBase, '.windsurf', 'rules').replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(workspaceBase, IgnoreFiles.WINDSURF).replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(workspaceBase, IgnoreFiles.WINDSURF_LEGACY).replaceAll('\\', '/')) + expect(deletePaths).toContain(path.join(tempHomeDir, '.codeium', 'windsurf', 'global_workflows').replaceAll('\\', '/')) + } finally { + fs.rmSync(tempHomeDir, {recursive: true, force: true}) + } + }) }) diff --git a/sdk/src/plugins/WindsurfOutputPlugin.ts b/sdk/src/plugins/WindsurfOutputPlugin.ts index d18a0795..d36208f1 100644 --- a/sdk/src/plugins/WindsurfOutputPlugin.ts +++ b/sdk/src/plugins/WindsurfOutputPlugin.ts @@ -1,7 +1,7 @@ import type {CommandPrompt, OutputFileDeclaration, OutputWriteContext, RulePrompt, SkillPrompt} from './plugin-core' import {Buffer} from 'node:buffer' import * as path from 'node:path' -import {AbstractOutputPlugin, applySubSeriesGlobPrefix, filterByProjectConfig, PLUGIN_NAMES} from './plugin-core' +import {AbstractOutputPlugin, applySubSeriesGlobPrefix, filterByProjectConfig, IgnoreFiles, PLUGIN_NAMES} from './plugin-core' const CODEIUM_WINDSURF_DIR = '.codeium/windsurf' const WORKFLOWS_SUBDIR = 'global_workflows' @@ -13,6 +13,8 @@ const SKILL_FILE_NAME = 'SKILL.md' const WINDSURF_RULES_DIR = '.windsurf' const WINDSURF_RULES_SUBDIR = 'rules' const RULE_FILE_PREFIX = 'rule-' +const WINDSURF_IGNORE_FILES = [IgnoreFiles.WINDSURF, IgnoreFiles.WINDSURF_LEGACY] as const +const LEGACY_WINDSURF_IGNORE_FILE = IgnoreFiles.WINDSURF_LEGACY type WindsurfOutputSource = | {readonly kind: 'globalMemory', readonly content: string} @@ -30,7 +32,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { outputFileName: '', treatWorkspaceRootProjectAsProject: true, dependsOn: [PLUGIN_NAMES.AgentsOutput], - indexignore: '.codeiumignore', + indexignore: IgnoreFiles.WINDSURF, commands: { subDir: WORKFLOWS_SUBDIR, transformFrontMatter: (_cmd, context) => context.sourceFrontMatter ?? {} @@ -44,6 +46,7 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { cleanup: { delete: { project: { + files: [IgnoreFiles.WINDSURF, LEGACY_WINDSURF_IGNORE_FILE], dirs: ['.windsurf/rules', '.windsurf/workflows', '.windsurf/global_workflows', '.windsurf/skills', '.codeium/windsurf/global_workflows', '.codeium/windsurf/skills'] }, global: { @@ -214,22 +217,34 @@ export class WindsurfOutputPlugin extends AbstractOutputPlugin { } } - const ignoreOutputPath = this.getIgnoreOutputPath() - const ignoreFile = this.indexignore == null - ? void 0 - : aiAgentIgnoreConfigFiles?.find(file => file.fileName === this.indexignore) - if (ignoreOutputPath != null && ignoreFile != null) { + const ignoreFilesByName = new Map( + (aiAgentIgnoreConfigFiles ?? []).map(file => [file.fileName, file.content] as const) + ) + if (ignoreFilesByName.size > 0) { + const primaryIgnoreContent = ignoreFilesByName.get(IgnoreFiles.WINDSURF) + ?? ignoreFilesByName.get(LEGACY_WINDSURF_IGNORE_FILE) + const legacyIgnoreContent = ignoreFilesByName.get(LEGACY_WINDSURF_IGNORE_FILE) + ?? ignoreFilesByName.get(IgnoreFiles.WINDSURF) + for (const project of concreteProjects) { const projectDir = project.dirFromWorkspacePath if (projectDir == null || project.isPromptSourceProject === true) continue - declarations.push({ - path: path.join(projectDir.basePath, projectDir.path, ignoreOutputPath), - scope: 'project', - source: { - kind: 'ignoreFile', - content: ignoreFile.content - } satisfies WindsurfOutputSource - }) + + for (const ignoreFileName of WINDSURF_IGNORE_FILES) { + const content = ignoreFileName === IgnoreFiles.WINDSURF + ? primaryIgnoreContent + : legacyIgnoreContent + if (content == null) continue + + declarations.push({ + path: path.join(projectDir.basePath, projectDir.path, ignoreFileName), + scope: 'project', + source: { + kind: 'ignoreFile', + content + } satisfies WindsurfOutputSource + }) + } } } diff --git a/sdk/src/plugins/plugin-core/AindexTypes.ts b/sdk/src/plugins/plugin-core/AindexTypes.ts index 599efff5..24986adc 100644 --- a/sdk/src/plugins/plugin-core/AindexTypes.ts +++ b/sdk/src/plugins/plugin-core/AindexTypes.ts @@ -122,7 +122,8 @@ export const AINDEX_FILE_NAMES = { CURSOR_IGNORE: '.cursorignore', WARP_INDEX_IGNORE: '.warpindexignore', AI_IGNORE: '.aiignore', - CODEIUM_IGNORE: '.codeiumignore' // Windsurf ignore file + WINDSURF_IGNORE: '.codeignore', // Windsurf ignore file + CODEIUM_IGNORE: '.codeiumignore' // Windsurf legacy ignore file } as const /** @@ -341,9 +342,14 @@ export const DEFAULT_AINDEX_STRUCTURE: AindexDirectory = { description: 'AI ignore file' }, { - name: AINDEX_FILE_NAMES.CODEIUM_IGNORE, + name: AINDEX_FILE_NAMES.WINDSURF_IGNORE, required: false, description: 'Windsurf ignore file' + }, + { + name: AINDEX_FILE_NAMES.CODEIUM_IGNORE, + required: false, + description: 'Windsurf legacy ignore file' } ] } as const diff --git a/sdk/src/plugins/plugin-core/constants.ts b/sdk/src/plugins/plugin-core/constants.ts index 63078971..a1e5c7c0 100644 --- a/sdk/src/plugins/plugin-core/constants.ts +++ b/sdk/src/plugins/plugin-core/constants.ts @@ -101,7 +101,8 @@ export const GlobalConfigDirs = { export const IgnoreFiles = { CURSOR: '.cursorignore', - WINDSURF: '.codeiumignore' + WINDSURF: '.codeignore', + WINDSURF_LEGACY: '.codeiumignore' } as const export const PreservedSkills = { diff --git a/sdk/src/public-config-paths.ts b/sdk/src/public-config-paths.ts index 475c3526..77913841 100644 --- a/sdk/src/public-config-paths.ts +++ b/sdk/src/public-config-paths.ts @@ -18,6 +18,7 @@ export const AI_AGENT_IGNORE_TARGET_RELATIVE_PATHS = [ AINDEX_FILE_NAMES.CURSOR_IGNORE, AINDEX_FILE_NAMES.WARP_INDEX_IGNORE, AINDEX_FILE_NAMES.AI_IGNORE, + AINDEX_FILE_NAMES.WINDSURF_IGNORE, AINDEX_FILE_NAMES.CODEIUM_IGNORE, '.kiroignore', '.traeignore' diff --git a/sdk/src/runtime/cleanup.ts b/sdk/src/runtime/cleanup.ts index fd097257..68ba5774 100644 --- a/sdk/src/runtime/cleanup.ts +++ b/sdk/src/runtime/cleanup.ts @@ -268,6 +268,28 @@ function logCleanupPlanDiagnostics( }) } +function summarizeCleanupSnapshot(snapshot: NativeCleanupSnapshot): { + pluginCount: number + outputCount: number + cleanupDeleteCount: number + cleanupProtectCount: number + cleanupExcludeScanGlobs: number + protectedRuleCount: number + projectRootCount: number + emptyDirExcludeGlobs: number +} { + return { + pluginCount: snapshot.pluginSnapshots.length, + outputCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + plugin.outputs.length, 0), + cleanupDeleteCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + (plugin.cleanup.delete?.length ?? 0), 0), + cleanupProtectCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + (plugin.cleanup.protect?.length ?? 0), 0), + cleanupExcludeScanGlobs: snapshot.pluginSnapshots.reduce((total, plugin) => total + (plugin.cleanup.excludeScanGlobs?.length ?? 0), 0), + protectedRuleCount: snapshot.protectedRules.length, + projectRootCount: snapshot.projectRoots.length, + emptyDirExcludeGlobs: snapshot.emptyDirExcludeGlobs?.length ?? 0 + } +} + function logNativeCleanupErrors( logger: ILogger, errors: readonly NativeCleanupError[] @@ -377,8 +399,26 @@ export async function collectDeletionTargets( conflicts: CleanupProtectionConflict[] excludedScanGlobs: string[] }> { + cleanCtx.logger.info('cleanup planning started', { + phase: 'cleanup-plan', + dryRun: cleanCtx.dryRun === true, + pluginCount: outputPlugins.length, + workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path + }) const snapshot = await buildCleanupSnapshot(outputPlugins, cleanCtx, predeclaredOutputs) + cleanCtx.logger.info('cleanup snapshot prepared', { + phase: 'cleanup-plan', + ...summarizeCleanupSnapshot(snapshot) + }) const plan = await planCleanupWithNative(snapshot) + cleanCtx.logger.info('cleanup planning complete', { + phase: 'cleanup-plan', + filesToDelete: plan.filesToDelete.length, + dirsToDelete: plan.dirsToDelete.length + plan.emptyDirsToDelete.length, + emptyDirsToDelete: plan.emptyDirsToDelete.length, + violations: plan.violations.length, + conflicts: plan.conflicts.length + }) if (plan.conflicts.length > 0) { throw new CleanupProtectionConflictError(plan.conflicts) @@ -400,9 +440,16 @@ export async function performCleanup( logger: ILogger, predeclaredOutputs?: ReadonlyMap ): Promise { + logger.info('cleanup execution started', { + phase: 'cleanup-execute', + dryRun: cleanCtx.dryRun === true, + pluginCount: outputPlugins.length, + workspaceDir: cleanCtx.collectedOutputContext.workspace.directory.path + }) if (predeclaredOutputs != null) { const outputs = await collectAllPluginOutputs(outputPlugins, cleanCtx, predeclaredOutputs) - logger.debug('Collected outputs for cleanup', { + logger.info('cleanup outputs collected', { + phase: 'cleanup-execute', projectDirs: outputs.projectDirs.length, projectFiles: outputs.projectFiles.length, globalDirs: outputs.globalDirs.length, @@ -411,12 +458,37 @@ export async function performCleanup( } const snapshot = await buildCleanupSnapshot(outputPlugins, cleanCtx, predeclaredOutputs) + logger.info('cleanup snapshot prepared', { + phase: 'cleanup-execute', + ...summarizeCleanupSnapshot(snapshot) + }) + logger.info('cleanup native execution started', { + phase: 'cleanup-execute', + pluginCount: snapshot.pluginSnapshots.length, + outputCount: snapshot.pluginSnapshots.reduce((total, plugin) => total + plugin.outputs.length, 0) + }) const result = await performCleanupWithNative(snapshot) + logger.info('cleanup native execution finished', { + phase: 'cleanup-execute', + deletedFiles: result.deletedFiles, + deletedDirs: result.deletedDirs, + plannedFiles: result.filesToDelete.length, + plannedDirs: result.dirsToDelete.length + result.emptyDirsToDelete.length, + emptyDirsToDelete: result.emptyDirsToDelete.length, + violations: result.violations.length, + conflicts: result.conflicts.length, + errors: result.errors.length + }) logCleanupPlanDiagnostics(logger, result) if (result.conflicts.length > 0) { logCleanupProtectionConflicts(logger, result.conflicts) + logger.info('cleanup execution blocked', { + phase: 'cleanup-execute', + reason: 'conflicts', + conflicts: result.conflicts.length + }) return { deletedFiles: 0, deletedDirs: 0, @@ -429,6 +501,11 @@ export async function performCleanup( if (result.violations.length > 0) { logProtectedDeletionGuardError(logger, 'cleanup', result.violations) + logger.info('cleanup execution blocked', { + phase: 'cleanup-execute', + reason: 'protected-path-violations', + violations: result.violations.length + }) return { deletedFiles: 0, deletedDirs: 0, @@ -450,6 +527,12 @@ export async function performCleanup( deletedDirs: result.deletedDirs, errors: loggedErrors.length }) + logger.info('cleanup execution complete', { + phase: 'cleanup-execute', + deletedFiles: result.deletedFiles, + deletedDirs: result.deletedDirs, + errors: loggedErrors.length + }) return { deletedFiles: result.deletedFiles,