fix(cli): canonicalize paths in /directory remove guard - #3867
Conversation
…tching in /directory remove # Conflicts: # .gitignore
78913ee to
b018d09
Compare
The "WorkspaceContext with path expansion" test block was added in the wrong branch (feat/directory-remove instead of feat/skipped-dirs-warning). It references getSkippedDirectories() which doesn't exist here, and uses vi.spyOn(os, 'homedir') which fails under ESM. Remove it entirely. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit d584b8a incorrectly added PR 3868's test changes to this branch: - removed debugLogger mock and skipped-directory warn test (PR 3868 feature) - fixed includeDirectories test to use real temp dirs instead of POSIX paths that break on Windows Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
wenshao
left a comment
There was a problem hiding this comment.
Missing test coverage & other issues not tied to a single diff line:
packages/core/src/utils/workspaceContext.test.ts—expandHomeDirintegration inresolveAndValidateDiris untested. No test uses~/~/pathinput withaddDirectory.packages/core/src/services/fileDiscoveryService.test.ts— 3 edge-case tests removed without replacement: empty file list, relative project root paths,filterFileswithundefinedoptions.packages/cli/src/ui/commands/directoryCommand.test.ts—completionfunction,config === nullbranch, andloadServerHierarchicalMemoryfailure path are all untested.packages/cli/src/ui/commands/directoryCommand.test.ts—realpathSyncmock fallback usesvi.importActualwithoutawait, making the fallback non-functional (swallowedTypeError).packages/core/src/config/config.test.ts— temp directory cleanup (rmSync) is unreachable on assertion failure, causing resource leaks.- Memory-refresh logic (
loadServerHierarchicalMemory+setUserMemory+setGeminiMdFileCount) is duplicated betweenaddandremovesubcommands. Extract into a shared helper. packages/core/src/utils/workspaceContext.ts:141-146— TheremoveDirectory/isInitialDirectorycatch-block fallbacks don't callexpandHomeDir, unlike theresolveAndValidateDirpath now added. For consistency, applyexpandHomeDirin fallback paths too.
— deepseek-v4-pro via Qwen Code /review
| return; | ||
| } | ||
|
|
||
| const removed = workspaceContext.removeDirectory(canonicalDirectory); |
There was a problem hiding this comment.
[Critical] Non-atomic mutation: workspaceContext.removeDirectory() (line 367) is called before settings.setValue() (line 412-421). If settings persistence fails, the in-memory workspace context is already mutated with no rollback. The next /directory remove attempt in the same session fails with "Directory not found in workspace" — only a restart recovers.
Swap the order: persist to settings first, then call removeDirectory on success. If persistence fails, the in-memory state stays intact and the user can retry.
— deepseek-v4-pro via Qwen Code /review
| // directory added at user scope would reappear on restart if we | ||
| // only clear the workspace-scoped list. | ||
| const targetDir = canonicalDirectory; | ||
| let targetScope: SettingScope | null = null; |
There was a problem hiding this comment.
[Critical] Silent success when targetScope remains null (lines 388-411). If the directory is not found in any settings scope's originalSettings.context.includeDirectories, the if (targetScope !== null) block at line 412 is skipped — setValue is never called. The directory is removed from in-memory WorkspaceContext (line 367) but remains in persisted settings. On restart, it reappears without warning.
This can happen when a directory is added via --include-directories CLI flag (not /directory add) or when the settings key exists but the array is empty. Show a warning when targetScope is null, or iterate all scopes regardless of match.
— deepseek-v4-pro via Qwen Code /review
| SettingScope.User, | ||
| ] as const) { | ||
| const scopeDirs = | ||
| settings.forScope(scope).originalSettings.context |
There was a problem hiding this comment.
[Suggestion] Settings matching uses Array.includes() strict equality (lines 396, 403). On case-insensitive filesystems (macOS) or when the stored path spelling differs from fs.realpathSync output (e.g., trailing slashes, .. segments, paths added via the UI dialog), the match fails and targetScope stays null — causing the silent-success bug described above.
| settings.forScope(scope).originalSettings.context | |
| // Normalize each scopeDir for comparison: | |
| const normalizedTarget = canonicalDirectory; | |
| if (scopeDirs.some(d => { | |
| try { return fs.realpathSync(d) === normalizedTarget; } | |
| catch { return d === targetDir || d === resolvedDirectory; } | |
| })) { | |
| targetScope = scope; | |
| existingDirs = scopeDirs; | |
| break; | |
| } |
— deepseek-v4-pro via Qwen Code /review
| settings.forScope(scope).originalSettings.context | ||
| ?.includeDirectories ?? []; | ||
| if (scopeDirs.includes(targetDir)) { | ||
| targetScope = scope; |
There was a problem hiding this comment.
[Suggestion] The scope-matching loop breaks on the first match (line 399). If the same directory is stored in both Workspace and User scopes (possible via manual settings editing or migration), only one scope is cleaned. The surviving scope's entry reappears via MergeStrategy.CONCAT on restart.
Remove the break and collect all matching scopes, then call setValue for each one.
— deepseek-v4-pro via Qwen Code /review
| })(); | ||
|
|
||
| if ( | ||
| workspaceContext.getInitialDirectories().includes(canonicalDirectory) |
There was a problem hiding this comment.
[Suggestion] The CLI manually performs expandHomeDir → path.resolve → fs.realpathSync and does a linear scan on getInitialDirectories() (line 352). WorkspaceContext already provides an isInitialDirectory() method that encapsulates this logic and handles error fallbacks. If isInitialDirectory's resolution logic changes later, this CLI guard won't pick up the change, creating inconsistent behavior.
| workspaceContext.getInitialDirectories().includes(canonicalDirectory) | |
| if (workspaceContext.isInitialDirectory(resolvedDirectory)) { |
— deepseek-v4-pro via Qwen Code /review
| ); | ||
| context.ui.setGeminiMdFileCount(fileCount); | ||
| } | ||
| } catch (error) { |
There was a problem hiding this comment.
[Suggestion] When loadServerHierarchicalMemory throws (catch block at line 456), the error message is displayed but execution falls through to the final addItem that shows "Removed directory" as success. The user sees both an error and a success message simultaneously — contradictory and confusing UX.
Add return; after the error addItem call to prevent the dual messages.
— deepseek-v4-pro via Qwen Code /review
| }, | ||
| kind: CommandKind.BUILT_IN, | ||
| supportedModes: ['interactive'] as const, | ||
| completion: async (context: CommandContext) => { |
There was a problem hiding this comment.
[Suggestion] The completion function (line 297) declares only (context: CommandContext), omitting the partialArg: string parameter. Tab completion always returns ALL removable directories regardless of what the user has already typed. For example, /directory remove /home/u + Tab still shows /var/www instead of filtering to /home/u*. The add subcommand uses getDirPathCompletions(partialArg) for path-aware filtering — remove should do the same.
| completion: async (context: CommandContext) => { | |
| completion: async (context: CommandContext, partialArg: string) => { | |
| // ... existing logic ... | |
| const removable = dirs.filter((d) => !initialDirs.includes(d)); | |
| if (!partialArg.trim()) return removable; | |
| return removable.filter((d) => d.startsWith(partialArg.trim())); | |
| }, |
— deepseek-v4-pro via Qwen Code /review
Security and robustness fix for directory management: