From 0caa399accbf3a3bc0746bc629d7fb30f30edf46 Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Mon, 2 Nov 2026 16:40:24 +0800 Subject: [PATCH 01/10] fix(workspace): align Git ignore and analysis exclusion behavior Fix automatic analysis exclusion for ignored nested repositories while preserving independent Git commit candidates. Add ignored-directory suggestions, selective exclusion and project-local dismissal on macOS and Windows. Distinguish ignored names from excluded rows and refresh lazy tree decorations safely across concurrent events. Include shared contracts, localization, regression coverage and native acceptance notes. --- ...026-10-28-workspace-indexing-exclusions.md | 63 ++ .github/workflows/ci-windows.yml | 2 +- macos/Resources/en.lproj/Localizable.strings | 16 + .../zh-Hans.lproj/Localizable.strings | 16 + .../Lithe/Core/Rust/RustCoreBridge.swift | 6 + .../Core/Rust/RustWorkspaceOperations.swift | 13 +- .../Models/Workspace/ProjectTreeLoader.swift | 14 +- .../Remote/RemoteWorkspaceRouting.swift | 5 + macos/Sources/Lithe/Theme/LitheTheme.swift | 2 + .../Views/Components/LitheTreeList.swift | 6 +- .../Lithe/Views/Editor/EditorAreaView.swift | 4 + .../Views/Editor/IgnoredDirectoryNotice.swift | 131 ++++ .../Views/Workspace/ProjectSidebarView.swift | 9 + .../Workspace/ProjectTreeSelection.swift | 3 +- .../Workspace/FileNodePathLookup.swift | 15 +- .../Workspace/WorkspaceFeatureContracts.swift | 4 + .../Workspace/WorkspaceIndexingPolicy.swift | 8 + .../Workspace/WorkspaceModels.swift | 6 +- .../Application/WorkspaceFeatureModel.swift | 170 +++-- .../LazyProjectTreeInventoryTests.swift | 185 ++++++ .../LitheTests/LitheTreeRowStyleTests.swift | 8 +- .../LitheTests/ProjectTreeLoaderTests.swift | 8 +- .../WorkspaceDirectoryMarkStoreTests.swift | 14 + rust/lithe-core/src/git/mod.rs | 26 + rust/lithe-core/src/project/files.rs | 18 +- rust/lithe-core/src/project/indexing.rs | 579 ++++++++++++++++-- rust/lithe-core/src/protocol/command.rs | 5 + rust/lithe-core/src/protocol/contracts.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 17 + shared/contracts/rust-core-api.md | 22 +- .../ignored-directory-suggestions-v1.json | 13 + shared/i18n/locales/en-US.json | 10 +- shared/i18n/locales/zh-CN.json | 10 +- shared/i18n/messages.json | 96 +++ .../workspace-indexing-exclusions.json | 13 +- windows/tauri/src-tauri/src/core.rs | 2 + .../editor/components/code-editor.tsx | 2 + .../ignored-directory-notice.test.tsx | 143 +++++ .../components/ignored-directory-notice.tsx | 246 ++++++++ .../components/file-explorer-tree-item.tsx | 6 +- .../components/file-explorer-tree.tsx | 12 + .../stores/workspace-indexing.ts | 20 + .../styles/file-explorer-tree.css | 10 + windows/tauri/src/i18n/shared.generated.ts | 16 + 44 files changed, 1881 insertions(+), 97 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Editor/IgnoredDirectoryNotice.swift create mode 100644 shared/fixtures/workspace/ignored-directory-suggestions-v1.json create mode 100644 windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx create mode 100644 windows/tauri/src/features/editor/components/ignored-directory-notice.tsx diff --git a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md index 365183c685..f10bc71cc4 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md +++ b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md @@ -7,6 +7,7 @@ 目录能否在树里浏览,与是否进入 Lithe 自有索引是两种不同的策略。 手动排除及 Git 自动排除由 Rust Core 判断,两端只存储项目偏好并触发刷新。 语言服务拥有自己的扫描器;Java 导入排除不能当作整个 Java 资源扫描已停止的证据。 +目录排除只约束分析和搜索,不改变 Git 仓库发现、提交候选或未跟踪目录汇总。 ## 问题 @@ -419,6 +420,68 @@ macOS 使用最新构建的可执行及 Swift 资源,组装独立 QA bundle, 预算或 CI 跳过规则;按用户要求不在本机运行 Windows 测试,执行证据由修复 提交的 Windows CI 提供,不能沿用 `18841d18` 的部分成功结果。 +## 2026-11-02:忽略的嵌套仓库、提交树与编辑器提示 + +自动排除以包含目录的 Git 仓库为准,不再因子目录带 `.git` 目录或 gitfile 而豁免。 +父仓库的原生 `check-ignore` 判定与批量 `ls-files` 共同保护已跟踪后代和 submodule gitlink; +独立打开的嵌套根仍使用自己的策略。复现夹具覆盖自动开关、显式包含、手动排除和 gitfile。 +目录索引策略不参与 Git 仓库发现、提交状态或未跟踪目录展开;不向 Git 请求传递分析策略。 +例如手动排除 `cache` 后,它不进入分析和搜索,但其已跟踪改动和未被 Git 忽略的新文件仍能提交。 +不要用目录排除隐藏提交候选或改变汇总计数。Git 忽略仍由各仓库自己的原生 Git 状态决定; +父仓库忽略嵌套目录不等于禁用里面的独立仓库。 + +打开 `.gitignore` 的横条调用只读 `workspace.ignoredDirectorySuggestions`。查询限目录、条目总数和深度, +按兄弟目录批量调用 Git,不读取文件正文、不跟随符号链接,也不展开已经发现的候选。 +不完整或 Git 失败必须提示,不能当成“没有待排除目录”。关闭自动排除时仍可检查候选, +缓存的检查结果不得反向启用 provider 排除。只有用户确认后才把仍有效的所选目录保存为 +手动排除;不再显示是应用存储内的项目偏好,不改用户的 `.gitignore` 或索引。 +原生验收还发现候选缓存会使自动排除关闭时的状态汇总错误地标出 Git 排除原因, +虽然搜索库存和 provider 仍正确。configure 与 refresh 的状态汇总现在也受自动开关约束; +回归断言候选查询后的重复 configure 不改变有效原因、库存或租约版本。 + +显示沿用 JetBrains 的 Non-Project Files 行底色:浅色 `#FFFFE4`,深色 `#4F4B41`; +Git ignored 仍仅修改文件名颜色。选中行先保证既有选中色和文字对比度。出处为 +JetBrains/intellij-community 的 `536da82a2cbce90820cbb0c158d91e78cda75d82` 中 +`NonProjectFilesScope.java` 与 `FileColorManagerImpl.java`。本次没有新增运行时资源目录; +查询只读取工作区与 Git,偏好沿用平台存储 adapter,不写发行 bundle,因此不改变签名或 delta 基线。 + +原生验收补充:macOS 提示必须保留空态也存在的容器,不能把首次查询的 task 挂在 +完全为空的条件 Group 上。原生窗口挂载回归覆盖空态下启动首次查询; +夹具必须返回非零索引 revision,与生产已完成配置的状态一致。 +项目树原有 Git 变更投影不包含 ignored,不能据此声称名称已经着色。Core 的逐层目录列表 +现在按当前目录的原生 Git 批量返回 ignoredPaths;未知结果省略,Swift 只给名称着色。 +该状态随懒加载、包压缩和可见行投影传递,不使用分析排除原因,也不影响 Git 提交候选。 +忽略规则变更但库存文件未变时仍发布新的树装饰,保留库存 UUID,避免重新启动项目服务。 +自动分析排除关闭时,Git 元数据事件只重列已加载层;遇到并发文件事件或目录展开时重试, +不覆盖后来加载的子节点。新文件先提交库存,再仅查询其可见父目录以补齐 Git 装饰, +不会为每个新文件重新扫描整个工作区;门控回归覆盖在途取消及连续创建。 + +PyCharm 2026.2.3 的同项目原生对照确认:手动排除后,普通未跟踪文件及目录仍出现在提交面板, +显式刷新和重新打开项目后结果相同。保持父 `.gitignore` 的 `projects/ShipFlow/` 不变, +将该目录登记为独立 VCS root 后,提交面板分别显示它的已跟踪修改与未跟踪文件。 +因此区分分析排除、父仓库忽略、独立仓库映射,不能从“没有出现在外层未跟踪列表”推断 +内部仓库已被禁用。提示条的候选查看、部分选择排除、项目级不再显示及名称/整行颜色也已对照。 +测试项目的 `.gitignore` 和索引哈希不变;对照配置只写入隔离项目的 `.idea`。 + +最终运行补修源码通过 Core 全部 1,582 项与 macOS 相关 90 项逐项计时回归,包含原生挂载、 +颜色刷新、并发创建/展开、Git 边界、懒加载和偏好。此前 Git 边界调整后的 Bun 31 项及 +TypeScript 检查通过;前端此后未改变。较早 Core 1,581/Swift 75/Bun 57 报告不代替最终源码。 +Core 曾在并行重编译时因无关的大转义清单 rebase 用例超时,停止竞争编译后按原预算全量通过。 +最终包已替换 `/Applications/Lithe-Dev.app`,构建时间为 `2026-11-02T07:41:31Z`。 +原生验收确认:自动开启时带独立 `.git` 的 ShipFlow 被分析排除,实际内容搜索仅返回 visible; +自动关闭并仅选择 ignored-choice 手动排除后,搜索保留 ignored-name-only 与 visible。 +提示查看、部分选择、项目级不再显示及重启持久化通过;刷新提交面板后,manual-repo 和 +ShipFlow 的已跟踪修改、未跟踪文件均仍存在。最终截图可区分 ignored-only 名称棕色与 +手动排除的整行浅色底;候选查询后再刷新也不再错误显示自动排除原因。 +运行中修改规则的界面截图因自动化连接不稳定未形成有效证据,颜色刷新以模型回归为依据。 +验收前后 bundle 全部 1,426 项的内容、模式及符号链接目标一致,签名验证通过。 +测试修改的 Dev 偏好精确恢复,QA 缓存归档;验收应用和辅助进程均已关闭,安装替换保留。 +原始截图和逐项记录见 `.artifacts/workspace-exclusion-runtime/acceptance.json`。 +本机 Swift 6.4.2/Xcode 27.2 +不替代固定 CI 工具链或 Windows 原生验收;Windows 宿主的 macOS 检查仍只报与未修改基线 +相同的两个平台专有 API 错误。本次本地 release 构建使用隔离安装的官方 Rust 1.95.0; +关闭本机符号裁剪以绕过 macOS 27 的 LINKEDIT 对齐加载问题,不改变仓库发布配置。 + ## 适用范围 - `rust/lithe-core/src/project/indexing.rs` diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index b478309122..fb6830c1eb 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -644,7 +644,7 @@ jobs: # Keep DOM setup, native spies and store mocks isolated between suites. node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-settings-hierarchy.json -- src/features/settings/components/settings-hierarchy.test.tsx if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - node $runner --suite-timeout-ms 30000 --report .artifacts/test-stability/windows-git-indexing-settings.json -- src/features/settings/components/git-indexing-settings.test.tsx src/features/settings/lib/run-settings-search.test.ts + node $runner --suite-timeout-ms 30000 --report .artifacts/test-stability/windows-git-indexing-settings.json -- src/features/settings/components/git-indexing-settings.test.tsx src/features/settings/lib/run-settings-search.test.ts src/features/editor/components/ignored-directory-notice.test.tsx if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-settings-theme-sync.json -- src/features/settings/lib/settings-theme-sync.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 8954d42ab0..15350d5ff4 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -998,6 +998,7 @@ "Could not be determined" = "Could not be determined"; "Could not check for updates" = "Could not check for updates"; "Could not check for updates." = "Could not check for updates."; +"Could not check ignored directories." = "Could not check ignored directories."; "Could not clear this project's local history. Check storage permissions and try again." = "Could not clear this project's local history. Check storage permissions and try again."; "Could not compare the local adapter with the verified version." = "Could not compare the local adapter with the verified version."; "Could not confirm" = "Could not confirm"; @@ -1283,6 +1284,7 @@ "Dockerfile Support" = "Dockerfile Support"; "Documentation" = "Documentation"; "Don't Save" = "Don't Save"; +"Don't Show Again" = "Don't Show Again"; "Don't ask again" = "Don't ask again"; "Done" = "Done"; "Don’t Allow" = "Don’t Allow"; @@ -1462,11 +1464,13 @@ "Exception" = "Exception"; "Exception Breakpoints" = "Exception Breakpoints"; "Exception condition" = "Exception condition"; +"Exclude Selected" = "Exclude Selected"; "Exclude from dependency tree" = "Exclude from dependency tree"; "Excluded" = "Excluded"; "Excluded Paths" = "Excluded Paths"; "Excluded from indexing by Git ignore rules" = "Excluded from indexing by Git ignore rules"; "Excluded from indexing by a parent directory" = "Excluded from indexing by a parent directory"; +"Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules." = "Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules."; "Exclusion list" = "Exclusion list"; "Executable" = "Executable"; "Execute Maven Goal" = "Execute Maven Goal"; @@ -1815,6 +1819,7 @@ "If: %@" = "If: %@"; "Ignore whitespace" = "Ignore whitespace"; "Ignored" = "Ignored"; +"Ignored Directories" = "Ignored Directories"; "Ignored by regular expression line %lld" = "Ignored by regular expression line %lld"; "Ignored by wildcard rule %@" = "Ignored by wildcard rule %@"; "Ignored entries: %lld" = "Ignored entries: %lld"; @@ -3741,7 +3746,9 @@ "Solidity Support" = "Solidity Support"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying."; "Some dependencies are not shown." = "Some dependencies are not shown."; +"Some directories could not be checked. The list may be incomplete." = "Some directories could not be checked. The list may be incomplete."; "Some directories could not be scanned. Automatic project analysis was not refreshed." = "Some directories could not be scanned. Automatic project analysis was not refreshed."; +"Some ignored directories are still included in analysis and search." = "Some ignored directories are still included in analysis and search."; "Some installation versions were not checked in time." = "Some installation versions were not checked in time."; "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; "Some selected files belong to another version control root. Refresh and select them again." = "Some selected files belong to another version control root. Refresh and select them again."; @@ -4688,6 +4695,7 @@ "Versions & Source" = "Versions & Source"; "View" = "View"; "View Breakpoints" = "View Breakpoints"; +"View Directories" = "View Directories"; "View Options" = "View Options"; "View Saved Changes" = "View Saved Changes"; "View breakpoints" = "View breakpoints"; @@ -4950,18 +4958,26 @@ "indexing.cancelExclusion" = "Cancel Exclusion"; "indexing.configureFailed" = "Could not apply project indexing exclusions. Try reopening the project."; "indexing.description" = "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nNative exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee."; +"indexing.dismissNotice" = "Don't Show Again"; +"indexing.excludeSelected" = "Exclude Selected"; "indexing.gitFailed" = "Git ignore exclusions could not be fully resolved. Manual exclusions remain active."; "indexing.gitReason" = "Excluded from indexing by Git ignore rules"; +"indexing.ignoredDirectories" = "Ignored Directories"; "indexing.incomplete" = "Some directories could not be scanned. Automatic project analysis was not refreshed."; "indexing.inheritedReason" = "Excluded from indexing by a parent directory"; "indexing.invalidSettings" = "Project indexing settings are invalid. The saved data has been preserved."; "indexing.languageDescription" = "Native exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee."; "indexing.manualReason" = "Manually excluded from indexing"; +"indexing.notice" = "Some ignored directories are still included in analysis and search."; +"indexing.noticeFailed" = "Could not check ignored directories."; +"indexing.noticeHelp" = "Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules."; +"indexing.noticeIncomplete" = "Some directories could not be checked. The list may be incomplete."; "indexing.permissionDenied" = "Lithe cannot access the project folder. Check its permissions, then retry."; "indexing.providerDependent" = "Provider-dependent"; "indexing.readFailed" = "Could not read project indexing settings. Try reopening the project."; "indexing.remoteUnsupported" = "Directory indexing exclusions are not supported for remote projects."; "indexing.title" = "Project Indexing"; +"indexing.viewDirectories" = "View Directories"; "indexing.workspaceUnavailable" = "The project folder is unavailable. Restore it, then retry."; "indexing.writeFailed" = "Could not save project indexing settings. Your previous settings remain unchanged."; "java.launch.error.buildCancelled" = "The Java project build was cancelled before it finished."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 98eeef9e60..3fe3076ec5 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -998,6 +998,7 @@ "Could not be determined" = "无法确定"; "Could not check for updates" = "无法检查更新"; "Could not check for updates." = "无法检查更新。"; +"Could not check ignored directories." = "无法检查被忽略的目录。"; "Could not clear this project's local history. Check storage permissions and try again." = "无法清空此项目的本地历史。请检查存储目录权限后重试。"; "Could not compare the local adapter with the verified version." = "无法比较本地适配器与已核验版本。"; "Could not confirm" = "无法确认"; @@ -1283,6 +1284,7 @@ "Dockerfile Support" = "Dockerfile 支持"; "Documentation" = "文档"; "Don't Save" = "不保存"; +"Don't Show Again" = "不再显示"; "Don't ask again" = "不再询问"; "Done" = "完成"; "Don’t Allow" = "不允许"; @@ -1462,11 +1464,13 @@ "Exception" = "异常"; "Exception Breakpoints" = "异常断点"; "Exception condition" = "异常条件"; +"Exclude Selected" = "排除所选目录"; "Exclude from dependency tree" = "从依赖树中排除"; "Excluded" = "已排除"; "Excluded Paths" = "已排除路径"; "Excluded from indexing by Git ignore rules" = "根据 Git 忽略规则排除索引"; "Excluded from indexing by a parent directory" = "继承父目录的索引排除"; +"Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules." = "排除目录后,Lithe 不再分析和搜索其中的文件,但不会改变 Git 提交候选,项目树仍可浏览文件。语言服务器仍可能扫描构建系统声明的模块。"; "Exclusion list" = "排除列表"; "Executable" = "可执行文件"; "Execute Maven Goal" = "执行 Maven 目标"; @@ -1815,6 +1819,7 @@ "If: %@" = "条件:%@"; "Ignore whitespace" = "忽略空白"; "Ignored" = "已忽略"; +"Ignored Directories" = "被忽略的目录"; "Ignored by regular expression line %lld" = "被正则表达式第 %lld 行忽略"; "Ignored by wildcard rule %@" = "被通配符规则 %@ 忽略"; "Ignored entries: %lld" = "已忽略条目:%lld"; @@ -3741,7 +3746,9 @@ "Solidity Support" = "Solidity 支持"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "部分 MCP 配置文件未能恢复:%@。请刷新并检查这些文件后重试。"; "Some dependencies are not shown." = "部分依赖未显示。"; +"Some directories could not be checked. The list may be incomplete." = "部分目录无法检查,列表可能不完整。"; "Some directories could not be scanned. Automatic project analysis was not refreshed." = "部分目录无法扫描,未刷新项目自动分析。"; +"Some ignored directories are still included in analysis and search." = "部分被忽略的目录未从分析和搜索中排除。"; "Some installation versions were not checked in time." = "部分安装的版本未能及时检查。"; "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; "Some selected files belong to another version control root. Refresh and select them again." = "部分所选文件属于其他版本控制根目录。请刷新后重新选择。"; @@ -4688,6 +4695,7 @@ "Versions & Source" = "版本与来源"; "View" = "查看"; "View Breakpoints" = "查看断点"; +"View Directories" = "查看目录"; "View Options" = "视图选项"; "View Saved Changes" = "查看已保存的更改"; "View breakpoints" = "查看断点"; @@ -4950,18 +4958,26 @@ "indexing.cancelExclusion" = "取消排除"; "indexing.configureFailed" = "无法应用项目索引排除策略,请重新打开项目后重试。"; "indexing.description" = "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。"; +"indexing.dismissNotice" = "不再显示"; +"indexing.excludeSelected" = "排除所选目录"; "indexing.gitFailed" = "无法完整解析 Git 忽略规则,手动排除仍然生效。"; "indexing.gitReason" = "根据 Git 忽略规则排除索引"; +"indexing.ignoredDirectories" = "被忽略的目录"; "indexing.incomplete" = "部分目录无法扫描,未刷新项目自动分析。"; "indexing.inheritedReason" = "继承父目录的索引排除"; "indexing.invalidSettings" = "项目索引设置格式无效,原始设置已保留。"; "indexing.languageDescription" = "已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。"; "indexing.manualReason" = "手动排除索引"; +"indexing.notice" = "部分被忽略的目录未从分析和搜索中排除。"; +"indexing.noticeFailed" = "无法检查被忽略的目录。"; +"indexing.noticeHelp" = "排除目录后,Lithe 不再分析和搜索其中的文件,但不会改变 Git 提交候选,项目树仍可浏览文件。语言服务器仍可能扫描构建系统声明的模块。"; +"indexing.noticeIncomplete" = "部分目录无法检查,列表可能不完整。"; "indexing.permissionDenied" = "Lithe 无法访问项目目录。请检查权限后重试。"; "indexing.providerDependent" = "取决于语言服务"; "indexing.readFailed" = "无法读取项目索引设置,请重新打开项目后重试。"; "indexing.remoteUnsupported" = "远程项目暂不支持目录索引排除。"; "indexing.title" = "项目索引"; +"indexing.viewDirectories" = "查看目录"; "indexing.workspaceUnavailable" = "项目目录不可用。请恢复目录后重试。"; "indexing.writeFailed" = "无法保存项目索引设置,原有设置未改变。"; "java.launch.error.buildCancelled" = "Java 项目构建在完成前已被取消。"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index c22dfe09db..269d499360 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -122,6 +122,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { struct Directory: Decodable, Sendable { let path: String let children: [WorkspaceNodePayload] + let ignoredPaths: [String]? } } @@ -2528,6 +2529,11 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let _: Response? = execute(command: "workspace.releaseIndexing", payload: Payload(root: rootURL.path, revision: revision, ownerId: ownerID)) } + func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + struct Request: Encodable { let root: String } + return try executeResult(command: "workspace.ignoredDirectorySuggestions", payload: Request(root: rootURL.path), timeoutMilliseconds: 10_000).get() + } + func configureIndexing(at rootURL: URL, policy: WorkspaceIndexingPolicy, refresh: Bool, ownerID: String? = nil) throws -> WorkspaceIndexingStatus { struct Request: Encodable { let root: String diff --git a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift index 88829f4950..ad0fd08737 100644 --- a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift @@ -34,6 +34,12 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { } } + func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + do { return try core.ignoredDirectorySuggestions(at: rootURL) } + catch let error as RustCoreBridge.CoreCallError { throw indexingFailure(error) } + catch { throw WorkspaceIndexingFailure.configureFailed } + } + func snapshot( at rootURL: URL, visibilityRules: FileVisibilityRules @@ -92,10 +98,13 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { } return Dictionary( payload.directories.map { directory in - ( + let ignoredPaths = Set(directory.ignoredPaths ?? []) + return ( url(for: directory.path).standardizedFileURL.path, directory.children.map { - ProjectDirectoryEntry(url: url(for: $0.path), isDirectory: $0.isDirectory, isEmptyDirectory: $0.isDirectory && $0.children?.isEmpty == true) + ProjectDirectoryEntry(url: url(for: $0.path), isDirectory: $0.isDirectory, + isEmptyDirectory: $0.isDirectory && $0.children?.isEmpty == true, + isGitIgnored: ignoredPaths.contains($0.path)) } ) }, diff --git a/macos/Sources/Lithe/Models/Workspace/ProjectTreeLoader.swift b/macos/Sources/Lithe/Models/Workspace/ProjectTreeLoader.swift index 311820e74b..203b863f95 100644 --- a/macos/Sources/Lithe/Models/Workspace/ProjectTreeLoader.swift +++ b/macos/Sources/Lithe/Models/Workspace/ProjectTreeLoader.swift @@ -5,6 +5,7 @@ struct ProjectDirectoryEntry: Sendable { let url: URL let isDirectory: Bool var isEmptyDirectory = false + var isGitIgnored = false } /// Bounded, consumable lookahead from package compaction, never a second tree. @@ -113,7 +114,8 @@ struct ProjectTreeLoader { children = [FileNode( url: directory.url, isDirectory: true, children: children, collapsedAncestorPaths: directory.collapsedAncestorPaths, - isInsideSourceRoot: directory.isInsideSourceRoot + isInsideSourceRoot: directory.isInsideSourceRoot, + isGitIgnored: directory.isGitIgnored )] } result[key] = children @@ -133,13 +135,14 @@ struct ProjectTreeLoader { let insideSourceRoot = row.isInsideSourceRoot || LitheIcons.isSourceRootDirectory(row.url) result[key] = entries.enumerated().map { index, entry in if entry.isDirectory, insideSourceRoot { - chains.append(CompactionChain(owner: key, index: index, tip: entry.url)) + chains.append(CompactionChain(owner: key, index: index, tip: entry.url, isGitIgnored: entry.isGitIgnored)) } return FileNode( url: entry.url, isDirectory: entry.isDirectory, children: entry.isEmptyDirectory ? [] : nil, - isInsideSourceRoot: entry.isDirectory && insideSourceRoot + isInsideSourceRoot: entry.isDirectory && insideSourceRoot, + isGitIgnored: entry.isGitIgnored ) } } @@ -157,6 +160,7 @@ struct ProjectTreeLoader { LitheIcons.isValidPackageName(only.url.lastPathComponent) { chain.collapsed.append(chain.tip.path) chain.tip = only.url + chain.isGitIgnored = only.isGitIgnored continuing.append(chain) } else { if let entries = listed[chain.tip.standardizedFileURL.path], let generation { @@ -194,6 +198,7 @@ struct ProjectTreeLoader { let owner: String let index: Int var tip: URL + var isGitIgnored: Bool var collapsed: [String] = [] func apply(to result: inout [String: [FileNode]], isEmpty: Bool = false) { @@ -203,7 +208,8 @@ struct ProjectTreeLoader { isDirectory: true, children: isEmpty ? [] : nil, collapsedAncestorPaths: collapsed, - isInsideSourceRoot: true + isInsideSourceRoot: true, + isGitIgnored: isGitIgnored ) } } diff --git a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift index 2efc56bd26..21703278c3 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift @@ -177,6 +177,11 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { local.releaseIndexing(at: rootURL, revision: revision, ownerID: ownerID) } + func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + guard registry.binding(forRoot: rootURL) == nil else { throw WorkspaceIndexingFailure.configureFailed } + return try local.ignoredDirectorySuggestions(at: rootURL) + } + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceSnapshot? { guard let binding = registry.binding(forRoot: rootURL) else { return try local.snapshot(at: rootURL, visibilityRules: visibilityRules) diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index 1203f3dde0..db8c9f48c5 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -485,6 +485,8 @@ enum LitheTheme { static let verticalInset: CGFloat = 4 static var text: Color { searchFieldText } static var secondaryText: Color { searchFieldPlaceholder } + // IntelliJ Community 536da82a: NonProjectFilesScope -> FileColor.Yellow. + static var excludedBackground: Color { controlColor(light: 0xFFFFE4, dark: 0x4F4B41) } static var focusedSelection: Color { activeTheme == .lithe ? controlColor(light: 0xD0DFFE, dark: 0x2A4371) : selection } diff --git a/macos/Sources/Lithe/Views/Components/LitheTreeList.swift b/macos/Sources/Lithe/Views/Components/LitheTreeList.swift index 87d05ac5d3..a1493db8f8 100644 --- a/macos/Sources/Lithe/Views/Components/LitheTreeList.swift +++ b/macos/Sources/Lithe/Views/Components/LitheTreeList.swift @@ -10,6 +10,7 @@ struct LitheTreeListHierarchy { var activatesOnDoubleClick = false var disclosureLabel: String? = nil var accessibilityTitle: String? = nil + var background: Color? = nil var isKeyboardNavigable: Bool { isSelectable || isBranch } @@ -234,7 +235,8 @@ struct LitheTreeList some View { content.litheRowHover( @@ -596,6 +599,7 @@ struct LitheTreeListRowSurface: ViewModifier { activeBackground: isFocused && controlActiveState == .key ? LitheTheme.Tree.focusedSelection : LitheTheme.Tree.inactiveSelection, hoverBackground: LitheTheme.Tree.hover) + .background(background ?? .clear, in: RoundedRectangle(cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius)) } } diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 436be6c9a7..0f8557b0ba 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -1168,6 +1168,10 @@ struct EditorAreaView: View { } else if !paneDiffSelected { VStack(spacing: 0) { externalConflictBanner + if paneTerminal == nil, let document = paneDocument, + document.url.lastPathComponent == ".gitignore" { + IgnoredDirectoryNotice(feature: model.workspaceFeature, fileURL: document.url) + } activeEditor } } diff --git a/macos/Sources/Lithe/Views/Editor/IgnoredDirectoryNotice.swift b/macos/Sources/Lithe/Views/Editor/IgnoredDirectoryNotice.swift new file mode 100644 index 0000000000..a3c884ed84 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/IgnoredDirectoryNotice.swift @@ -0,0 +1,131 @@ +import SwiftUI +import LitheCoreContracts +import LitheWorkspaceModule + +/// Opening an ignore file only inspects; the selected directories are excluded on confirmation. +struct IgnoredDirectoryNotice: View { + @ObservedObject var feature: WorkspaceFeatureModel + let fileURL: URL + @State private var suggestions = WorkspaceIgnoredDirectorySuggestions() + @State private var failed = false + @State private var showingDirectories = false + @State private var selected: Set = [] + @State private var applying = false + @State private var retry = 0 + + private struct QueryID: Equatable { + let file: URL + let generation: Int + let revision: UInt64 + let dismissed: Bool + let retry: Int + } + private var queryID: QueryID { + QueryID(file: fileURL, generation: feature.workspaceGeneration, + revision: feature.indexingStatus.revision, + dismissed: feature.indexingSettings.ignoredDirectoryNoticeDismissed == true, retry: retry) + } + + var body: some View { + // Keep a mounted container while the first query has no content yet. + // An empty conditional Group never starts its task on macOS. + VStack(spacing: 0) { + if feature.contains(fileURL), !queryID.dismissed, feature.indexingStatus.supportsExclusions { + if failed { + LitheBanner(status: .warning, layout: .editor) { + HStack { + Text("Could not check ignored directories.") + Spacer() + Button("Retry") { retry += 1 } + } + } + } else if !suggestions.directories.isEmpty { + LitheBanner(status: .info, layout: .editor) { + HStack(spacing: LitheTheme.Banner.actionGap) { + Text("Some ignored directories are still included in analysis and search.") + Spacer(minLength: 0) + Button("View Directories") { + selected = Set(suggestions.directories) + showingDirectories = true + } + Button("Don't Show Again") { feature.dismissIgnoredDirectoryNotice() } + LitheInfoTip("Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules.") + } + } + } else if suggestions.incomplete || suggestions.gitIgnoreFailed { + LitheBanner(status: .warning, layout: .editor) { + Text("Some directories could not be checked. The list may be incomplete.") + Button("Retry") { retry += 1 } + } + } + } + } + .task(id: queryID) { + let identity = queryID + suggestions = WorkspaceIgnoredDirectorySuggestions() + failed = false + showingDirectories = false + guard feature.contains(fileURL), !identity.dismissed, feature.indexingStatus.supportsExclusions, + identity.revision != 0 else { return } + do { + let result = try await feature.ignoredDirectorySuggestions() + guard !Task.isCancelled, queryID == identity else { return } + suggestions = result + } catch is CancellationError {} catch { + guard !Task.isCancelled, queryID == identity else { return } + failed = true + } + } + .sheet(isPresented: $showingDirectories) { directoryPicker } + } + + private var directoryPicker: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Ignored Directories").font(LitheTheme.uiFont(size: 16, weight: .semibold)) + if suggestions.incomplete || suggestions.gitIgnoreFailed { + LitheBanner(status: .warning) { + Text("Some directories could not be checked. The list may be incomplete.") + } + } + LitheTreeList(suggestions.directories, id: \.self, selection: $selected, + togglesSelectionOnClick: true) { path in + HStack { + Image(systemName: selected.contains(path) ? "checkmark.square.fill" : "square") + .foregroundStyle(selected.contains(path) ? LitheTheme.accent : LitheTheme.secondaryText) + Text(verbatim: path).lineLimit(1).truncationMode(.middle) + } + } + .frame(height: 280) + .disabled(applying) + if failed { + Text("Could not check ignored directories.").foregroundStyle(LitheTheme.secondaryText) + } + if let error = feature.indexingError { Text(error).foregroundStyle(LitheTheme.secondaryText) } + HStack(spacing: LitheTheme.ActionButton.spacing) { + Spacer() + Button("Cancel") { showingDirectories = false } + .buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) + .disabled(applying) + Button("Exclude Selected") { + let generation = feature.workspaceGeneration + let requested = selected + applying = true + Task { + defer { applying = false } + guard feature.workspaceGeneration == generation else { return } + do { + try await feature.excludeIgnoredDirectories(requested) + showingDirectories = false + retry += 1 + } catch is CancellationError {} catch { failed = true } + } + } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .keyboardShortcut(.defaultAction) + .disabled(applying || selected.isEmpty) + } + } + .padding(20).frame(width: 600).background(LitheTheme.raised) + .interactiveDismissDisabled(applying) + } +} diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index 3f25971739..a3df2e7824 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -670,6 +670,14 @@ private struct ProjectFileTreeContent: View, Equatable { // every descendant and cannot virtualize a large expanded directory. LitheTreeList( visibleRows, id: \.id, selection: .constant(Set()), + hierarchy: { row in + let path = row.node.url.path == root.url.path ? "." + : String(row.node.url.path.dropFirst(root.url.path.count + 1)) + let excluded = supportsIndexingExclusions && directoryMarks.contains { directory, mark in + mark == .excluded && (directory == "." || path == directory || path.hasPrefix(directory + "/")) + } + return LitheTreeListHierarchy(background: excluded ? LitheTheme.Tree.excludedBackground : nil) + }, native: native, scrollRequest: scrollRequest ) { row in FileNodeRow( @@ -1141,6 +1149,7 @@ private struct FileNodeRow: View { private var statusColor: Color? { gitStatusColor ?? gitStatus.vcsStatus(for: node.url)?.treeColor + ?? (node.isGitIgnored ? LitheTheme.Commit.fileIgnored : nil) } private var gitStatusColor: Color? { diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift b/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift index 941ec3980d..85ac83a12b 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift @@ -91,7 +91,8 @@ struct ProjectTreeSelection: Equatable { isDirectory: node.isDirectory, children: node.children?.isEmpty == true ? [] : nil, collapsedAncestorPaths: node.collapsedAncestorPaths, - isInsideSourceRoot: node.isInsideSourceRoot + isInsideSourceRoot: node.isInsideSourceRoot, + isGitIgnored: node.isGitIgnored ) self.depth = depth } diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift b/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift index cde8c50b33..eb98934644 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift @@ -83,6 +83,18 @@ extension FileNode { } } + /// Updates a visible row's Git decoration without replacing its loaded children. + package func settingGitIgnored(_ ignored: Bool, at url: URL) -> FileNode? { + guard let components = relativeComponents(of: url), + let location = rowLocation(of: components[...]), + location.isExact, !location.isBelowUnloadedRow else { return nil } + return updatingNode(atIndices: location.indices[...]) { node in + FileNode(url: node.url, isDirectory: node.isDirectory, children: node.children, + collapsedAncestorPaths: node.collapsedAncestorPaths, + isInsideSourceRoot: node.isInsideSourceRoot, isGitIgnored: ignored) + } + } + /// Adds a file below the directory row that owns its parent path. Nothing /// changes when that directory has not been loaded. package func inserting(file url: URL) -> TreeEdit { @@ -192,7 +204,8 @@ extension FileNode { isDirectory: isDirectory, children: children, collapsedAncestorPaths: collapsedAncestorPaths, - isInsideSourceRoot: isInsideSourceRoot + isInsideSourceRoot: isInsideSourceRoot, + isGitIgnored: isGitIgnored ) } diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift index baa6405e50..20709bb3a5 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift @@ -71,6 +71,7 @@ package protocol WorkspaceSessionStoring: AnyObject { package protocol WorkspaceOperations: Sendable { func releaseIndexing(at rootURL: URL, revision: UInt64, ownerID: String?) func configureIndexing(at rootURL: URL, policy: WorkspaceIndexingPolicy, refresh: Bool, ownerID: String?) throws -> WorkspaceIndexingStatus + func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions /// Drops package-compaction lookahead after workspace or filesystem changes. func invalidateDirectoryListings() @@ -99,6 +100,9 @@ extension WorkspaceOperations { } package func releaseIndexing(at rootURL: URL, revision: UInt64, ownerID: String?) {} package func configureIndexing(at rootURL: URL, policy: WorkspaceIndexingPolicy, refresh: Bool, ownerID: String?) throws -> WorkspaceIndexingStatus { WorkspaceIndexingStatus() } + package func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + throw WorkspaceIndexingFailure.configureFailed + } package func invalidateDirectoryListings() {} /// Operations whose snapshots already carry the whole tree never leave a diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift index e4c71c78f4..da97c6d98e 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift @@ -17,6 +17,14 @@ package struct WorkspaceIndexingSettings: Codable, Equatable, Sendable { package var gitIgnoreEnabled = true package var includedDirectories: [String] = [] package var previousRoles: [String: WorkspaceDirectoryMark] = [:] + package var ignoredDirectoryNoticeDismissed: Bool? + package init() {} +} + +package struct WorkspaceIgnoredDirectorySuggestions: Decodable, Sendable { + package var directories: [String] = [] + package var incomplete = false + package var gitIgnoreFailed = false package init() {} } diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift index eb2bdfdd8f..7d3ac2c164 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift @@ -11,19 +11,23 @@ package struct FileNode: Identifiable, Hashable, Sendable { package let collapsedAncestorPaths: [String] /// 该目录是否位于源码根之下,决定用包图标还是普通文件夹图标。 package let isInsideSourceRoot: Bool + /// Native Git decoration for this visible path, independent of analysis policy. + package let isGitIgnored: Bool package init( url: URL, isDirectory: Bool, children: [FileNode]?, collapsedAncestorPaths: [String] = [], - isInsideSourceRoot: Bool = false + isInsideSourceRoot: Bool = false, + isGitIgnored: Bool = false ) { self.url = url self.isDirectory = isDirectory self.children = children self.collapsedAncestorPaths = collapsedAncestorPaths self.isInsideSourceRoot = isInsideSourceRoot + self.isGitIgnored = isGitIgnored } package var id: String { url.path } diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 48e5604661..a20dd38e35 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -555,7 +555,12 @@ package final class WorkspaceFeatureModel: ObservableObject { return .unavailable } if indexingOnly, snapshot.files == projectFiles, snapshot.incompleteDirectories.isEmpty, - appliedSnapshot?.incompleteDirectories.isEmpty == true { + let previous = appliedSnapshot, previous.incompleteDirectories.isEmpty { + // Rules can change row decorations without changing analysis membership. + // Preserve inventory identity so services do not restart for a color update. + rootNode = snapshot.root + appliedSnapshot = WorkspaceSnapshot(root: snapshot.root, files: previous.files, + nestedWorktrees: previous.nestedWorktrees, incompleteDirectories: [], id: previous.id) isLoadingWorkspace = false isRefreshingWorkspace = false return .loaded(snapshot) @@ -637,30 +642,37 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func markDirectory(_ url: URL, action: WorkspaceDirectoryAction) async { + await markDirectories([url], action: action) + } + + private func markDirectories(_ urls: [URL], action: WorkspaceDirectoryAction) async { guard !isPerformingProjectItemOperation, !indexingPreferencesFailed, action != .exclude || indexingStatus.supportsExclusions, - let workspaceURL, - let relativePath = Self.relativePath(for: url, in: workspaceURL) else { return } + let workspaceURL else { return } + let relativePaths = urls.compactMap { Self.relativePath(for: $0, in: workspaceURL) } + guard relativePaths.count == urls.count, !relativePaths.isEmpty else { return } let workspaceGeneration = self.workspaceGeneration let previousMarks = directoryMarks var updatedMarks = directoryMarks let previousSettings = indexingSettings - switch action { - case .cancelExclusion: - guard !directoryMarks.contains(where: { $0.value == .excluded && $0.key != relativePath && (relativePath.hasPrefix($0.key + "/") || $0.key == ".") }) else { return } - let remembered = indexingSettings.previousRoles.removeValue(forKey: relativePath) - if updatedMarks[relativePath] == .excluded { - updatedMarks[relativePath] = remembered ?? .plain + for relativePath in relativePaths { + switch action { + case .cancelExclusion: + guard !directoryMarks.contains(where: { $0.value == .excluded && $0.key != relativePath && (relativePath.hasPrefix($0.key + "/") || $0.key == ".") }) else { return } + let remembered = indexingSettings.previousRoles.removeValue(forKey: relativePath) + if updatedMarks[relativePath] == .excluded { + updatedMarks[relativePath] = remembered ?? .plain + } + if !indexingSettings.includedDirectories.contains(relativePath) { indexingSettings.includedDirectories.append(relativePath) } + case .exclude: + if let previous = updatedMarks[relativePath], previous != .excluded { indexingSettings.previousRoles[relativePath] = previous } + updatedMarks[relativePath] = .excluded + indexingSettings.includedDirectories.removeAll { $0 == relativePath } + case .setRole(let role): + guard role != .excluded else { return } + updatedMarks[relativePath] = role } - if !indexingSettings.includedDirectories.contains(relativePath) { indexingSettings.includedDirectories.append(relativePath) } - case .exclude: - if let previous = updatedMarks[relativePath], previous != .excluded { indexingSettings.previousRoles[relativePath] = previous } - updatedMarks[relativePath] = .excluded - indexingSettings.includedDirectories.removeAll { $0 == relativePath } - case .setRole(let role): - guard role != .excluded else { return } - updatedMarks[relativePath] = role } guard updatedMarks != previousMarks || previousSettings != indexingSettings else { return } @@ -709,6 +721,41 @@ package final class WorkspaceFeatureModel: ObservableObject { await refreshCurrent() } + package func ignoredDirectorySuggestions() async throws -> WorkspaceIgnoredDirectorySuggestions { + guard let workspaceURL, indexingStatus.supportsExclusions, !indexingPreferencesFailed else { + throw WorkspaceIndexingFailure.configureFailed + } + let generation = workspaceGeneration + let result = try await Task.detached(priority: .utility) { [operations] in + try operations.ignoredDirectorySuggestions(at: workspaceURL) + }.value + guard !Task.isCancelled, self.workspaceURL == workspaceURL, + self.workspaceGeneration == generation else { throw CancellationError() } + return result + } + + package func excludeIgnoredDirectories(_ paths: Set) async throws { + let generation = workspaceGeneration + let suggestions = try await ignoredDirectorySuggestions() + guard let workspaceURL, workspaceGeneration == generation else { throw CancellationError() } + let chosen = suggestions.directories.filter { paths.contains($0) } + await markDirectories(chosen + .map { workspaceURL.appendingPathComponent($0, isDirectory: true) }, action: .exclude) + guard self.workspaceGeneration == generation else { throw CancellationError() } + guard chosen.allSatisfy({ directoryMarks[$0] == .excluded }) else { throw WorkspaceIndexingFailure.writeFailed } + } + + package func dismissIgnoredDirectoryNotice() { + guard let workspaceURL, !indexingPreferencesFailed else { return } + var settings = indexingSettings + settings.ignoredDirectoryNoticeDismissed = true + do { + try directoryMarkStore.saveIndexingSettings(settings, for: workspaceURL) + indexingSettings = settings + indexingSettingsRevision &+= 1 + } catch { recordIndexingFailure(error, fallback: .writeFailed) } + } + package func isIndexExcluded(_ url: URL) -> Bool { guard let workspaceURL, let path = Self.relativePath(for: url, in: workspaceURL) else { return false } @@ -1395,11 +1442,11 @@ package final class WorkspaceFeatureModel: ObservableObject { let rulesChanged = batch.gitMetadataMayHaveChanged || (batch.gitStateMayHaveChanged && batch.workspacePaths.isEmpty) || batch.workspacePaths.contains { URL(fileURLWithPath: $0).lastPathComponent == ".gitignore" } - if rulesChanged, indexingSettings.gitIgnoreEnabled { + if rulesChanged { indexingRulesRevision &+= 1 pendingIndexingRuleRefresh = true } - if !batch.workspacePaths.isEmpty || batch.watchRootsChanged || batch.requiresFullRescan { + if rulesChanged || !batch.workspacePaths.isEmpty || batch.watchRootsChanged || batch.requiresFullRescan { // Invalidate before debounce: an expansion must not consume pre-event lookahead. invalidateDirectoryListings() } @@ -1561,7 +1608,11 @@ package final class WorkspaceFeatureModel: ObservableObject { guard !Task.isCancelled, workspaceGeneration == generation, activeGitRefreshID == refreshID else { return } if pendingIndexingRuleRefresh, !isLoadingWorkspace, !isRefreshingWorkspace { pendingIndexingRuleRefresh = false - await refreshCurrentSnapshot(refreshGitAfterwards: false, indexingOnly: true) + if indexingSettings.gitIgnoreEnabled { + await refreshCurrentSnapshot(refreshGitAfterwards: false, indexingOnly: true) + } else if let workspaceURL { + await refreshExcludedTree(at: workspaceURL) + } } } @@ -1611,12 +1662,18 @@ package final class WorkspaceFeatureModel: ObservableObject { notifyWorkspaceFileChanges?(fileChanges) } + let createdFiles = changedURLs.filter { + !isKnownProjectFile($0) && fileOperations.fileExists(at: $0) && !fileOperations.isDirectory(at: $0) + } var reloadedProjectServices = appliedSnapshot?.id != previousSnapshotID switch incrementalTree(applying: changedURLs, at: workspaceURL) { case .unchanged: break case .updated(let root, let files, let reloadsTree): await applyIncrementalTree(root: root, files: files, reloadsTree: reloadsTree) + // Commit membership before yielding for decoration reads, so a later + // watcher batch cannot lose this batch's created/deleted signals. + if !reloadsTree { await refreshFileDecorations(createdFiles, at: workspaceURL) } reloadedProjectServices = true case .requiresSnapshot: await refreshCurrentSnapshot(refreshGitAfterwards: false) @@ -1773,24 +1830,66 @@ package final class WorkspaceFeatureModel: ObservableObject { return didChange ? .updated(root: root, files: files, reloadsTree: reloadsTree) : .unchanged } + /// Reads only parents of newly inserted visible files. Unopened subtrees stay + /// lazy, and decoration updates never publish a new analysis inventory. + private func refreshFileDecorations(_ urls: [URL], at workspaceURL: URL) async { + guard !urls.isEmpty else { return } + let generation = workspaceGeneration + let operations = self.operations + while self.workspaceURL == workspaceURL, workspaceGeneration == generation, + let currentRoot = rootNode { + let revision = directoryListingRevision + let rules = visibilityRules + let parents = Set(urls.map { $0.deletingLastPathComponent() }).compactMap { url -> FileNode? in + guard let row = currentRoot.node(exactlyAt: url), row.isDirectory, row.children != nil else { return nil } + return row + } + guard !parents.isEmpty else { return } + let listed = await Task.detached(priority: .userInitiated) { + operations.childRows(of: parents, at: workspaceURL, visibilityRules: rules) + }.value + guard self.workspaceURL == workspaceURL, workspaceGeneration == generation else { return } + // These files were already committed before the read. A later batch + // can cancel its caller; retry against the current listing revision. + guard directoryListingRevision == revision else { continue } + guard let listed, var root = rootNode, let snapshot = appliedSnapshot else { return } + let requested = Set(urls) + for children in listed.values { + for child in children where !child.isDirectory && requested.contains(child.url) { + if let updated = root.settingGitIgnored(child.isGitIgnored, at: child.url) { root = updated } + } + } + rootNode = root + appliedSnapshot = WorkspaceSnapshot(root: root, files: projectFiles, + nestedWorktrees: snapshot.nestedWorktrees, incompleteDirectories: snapshot.incompleteDirectories, id: snapshot.id) + return + } + } + /// Relists only already-loaded levels; browsing never contributes index candidates. private func refreshExcludedTree(at workspaceURL: URL) async { - guard let root = rootNode, let snapshot = appliedSnapshot else { return } let generation = workspaceGeneration - let revision = directoryListingRevision let operations = self.operations - let rules = visibilityRules - let loaded = Self.loadedDirectoryPaths(in: root) - let base = FileNode(url: root.url, isDirectory: true, children: nil) - let relisted = await Task.detached(priority: .userInitiated) { - Self.listingTree(base, reloading: loaded, at: workspaceURL, operations: operations, rules: rules) - }.value - guard self.workspaceURL == workspaceURL, workspaceGeneration == generation, - directoryListingRevision == revision, appliedSnapshot?.id == snapshot.id, - let relisted else { return } - rootNode = relisted - appliedSnapshot = WorkspaceSnapshot(root: relisted, files: projectFiles, - nestedWorktrees: snapshot.nestedWorktrees, incompleteDirectories: snapshot.incompleteDirectories, id: snapshot.id) + while self.workspaceURL == workspaceURL, workspaceGeneration == generation, + let root = rootNode, let snapshot = appliedSnapshot { + let revision = directoryListingRevision + let rules = visibilityRules + let loaded = Self.loadedDirectoryPaths(in: root) + let base = FileNode(url: root.url, isDirectory: true, children: nil) + let relisted = await Task.detached(priority: .userInitiated) { + Self.listingTree(base, reloading: loaded, at: workspaceURL, operations: operations, rules: rules) + }.value + guard self.workspaceURL == workspaceURL, workspaceGeneration == generation else { return } + // Rule refreshes must survive overlapping file events, and expansion + // can change loaded children without changing the inventory identity. + guard directoryListingRevision == revision, appliedSnapshot?.id == snapshot.id, + rootNode == root else { continue } + guard let relisted else { return } + rootNode = relisted + appliedSnapshot = WorkspaceSnapshot(root: relisted, files: projectFiles, + nestedWorktrees: snapshot.nestedWorktrees, incompleteDirectories: snapshot.incompleteDirectories, id: snapshot.id) + return + } } /// Publishes an updated inventory as a new snapshot so project services @@ -2051,7 +2150,8 @@ package final class WorkspaceFeatureModel: ObservableObject { isDirectory: node.isDirectory, children: children.compactMap { removingProjectItem(targetURL, from: $0) }, collapsedAncestorPaths: node.collapsedAncestorPaths, - isInsideSourceRoot: node.isInsideSourceRoot + isInsideSourceRoot: node.isInsideSourceRoot, + isGitIgnored: node.isGitIgnored ) } diff --git a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift index 63051f365e..0dc945a022 100644 --- a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift +++ b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift @@ -1,13 +1,180 @@ +import AppKit import Combine import Foundation import LitheCoreContracts import LitheModuleAPI import LitheWorkspaceModule import Testing +import SwiftUI +@testable import Lithe @Suite("Lazy project tree inventory") @MainActor struct LazyProjectTreeInventoryTests { + @Test + func mountedEmptyIgnoredNoticeStartsInspection() async throws { + let root = URL(fileURLWithPath: "/in-memory/mounted-ignored-notice") + let fixture = InventoryFixture(root: root, files: [], rows: [root.path: []]) + defer { fixture.close() } + fixture.disk.setConfigurationRevision(1) + await fixture.load() + #expect(fixture.model.indexingStatus.revision == 1) + fixture.disk.setSuggestions(["archive"]) + let host = NSHostingView(rootView: IgnoredDirectoryNotice( + feature: fixture.model, fileURL: root.appendingPathComponent(".gitignore"))) + host.sizingOptions = [] + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 700, height: 80), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { window.contentView = nil; window.close() } + window.orderFront(nil) + host.layoutSubtreeIfNeeded() + #expect(await fixture.disk.suggestionsRequested.waitUntilOpen()) + #expect(fixture.model.directoryMarks.isEmpty) + } + + @Test + func unchangedInventoryStillRefreshesGitIgnoredDecorations() async throws { + let root = URL(fileURLWithPath: "/in-memory/ignored-color-rules") + let file = root.appendingPathComponent("output.log") + let fixture = InventoryFixture(root: root, files: [file], rows: [ + root.path: [FileNode(url: file, isDirectory: false, children: nil)], + ]) + defer { fixture.close() } + await fixture.load() + await fixture.model.setGitIgnoreIndexing(false) + let snapshotID = fixture.model.appliedSnapshot?.id + let publications = fixture.publishedFiles.count + for ignored in [true, false] { + fixture.disk.set(files: [file], rows: [ + root.path: [FileNode(url: file, isDirectory: false, children: nil, isGitIgnored: ignored)], + ]) + let refreshed = fixture.expectRefresh(for: root.appendingPathComponent(".gitignore")) + try fixture.emit([root.appendingPathComponent(".gitignore")]) + #expect(await refreshed.waitUntilOpen()) + #expect(fixture.model.rootNode?.node(exactlyAt: file)?.isGitIgnored == ignored) + #expect(fixture.model.appliedSnapshot?.root.node(exactlyAt: file)?.isGitIgnored == ignored) + #expect(fixture.model.appliedSnapshot?.id == snapshotID) + #expect(fixture.publishedFiles.count == publications) + } + } + + @Test + func gitMetadataRefreshesColorsWithAutomaticIndexExclusionDisabled() async throws { + let root = URL(fileURLWithPath: "/in-memory/ignored-color-metadata") + let file = root.appendingPathComponent("output.log") + let fixture = InventoryFixture(root: root, files: [file], rows: [ + root.path: [FileNode(url: file, isDirectory: false, children: nil)], + ]) + defer { fixture.close() } + await fixture.load() + await fixture.model.setGitIgnoreIndexing(false) + let scans = fixture.disk.snapshotCount, publications = fixture.publishedFiles.count + let snapshotID = fixture.model.appliedSnapshot?.id + fixture.disk.set(files: [file], rows: [ + root.path: [FileNode(url: file, isDirectory: false, children: nil, isGitIgnored: true)], + ]) + let colored = fixture.expectIgnoredDecoration(for: file) + let source = try #require(fixture.watcher.source) + source.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true, gitMetadataMayHaveChanged: true)) + #expect(await colored.waitUntilOpen()) + #expect(fixture.disk.snapshotCount == scans) + #expect(fixture.publishedFiles.count == publications) + #expect(fixture.model.appliedSnapshot?.id == snapshotID) + } + + @Test(arguments: [false, true]) + func metadataColorRefreshRetriesConcurrentFileEventsAndExpansion(expand: Bool) async throws { + let root = URL(fileURLWithPath: "/in-memory/ignored-color-overlap") + let file = root.appendingPathComponent("output.log") + let docs = root.appendingPathComponent("docs"), child = docs.appendingPathComponent("readme.txt") + let fixture = InventoryFixture(root: root, files: [file, child], rows: [ + root.path: [FileNode(url: docs, isDirectory: true, children: nil), + FileNode(url: file, isDirectory: false, children: nil)], + docs.path: [FileNode(url: child, isDirectory: false, children: nil)], + ]) + defer { fixture.close() } + await fixture.load() + await fixture.model.setGitIgnoreIndexing(false) + let scans = fixture.disk.snapshotCount + fixture.disk.set(files: [file, child], rows: [ + root.path: [FileNode(url: docs, isDirectory: true, children: nil), + FileNode(url: file, isDirectory: false, children: nil, isGitIgnored: true)], + docs.path: [FileNode(url: child, isDirectory: false, children: nil)], + ]) + fixture.disk.blockNextListing() + let colored = fixture.expectIgnoredDecoration(for: file) + let source = try #require(fixture.watcher.source) + source.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true, gitMetadataMayHaveChanged: true)) + #expect(await fixture.disk.listingStarted.waitUntilOpen()) + if expand { + await fixture.model.loadExpandedDirectories([root.path, docs.path]) + #expect(fixture.model.rootNode?.node(exactlyAt: child) != nil) + } else { + let refreshed = fixture.expectRefresh(for: file) + try fixture.emit([file]) + #expect(await refreshed.waitUntilOpen()) + } + fixture.disk.releaseListing.open() + #expect(await colored.waitUntilOpen()) + #expect(!fixture.disk.listingTimedOut) + if expand { #expect(fixture.model.rootNode?.node(exactlyAt: child) != nil) } + #expect(fixture.disk.snapshotCount == scans) + } + + @Test + func newIgnoredFilesReceiveColorsWithoutRescanningOrLosingConcurrentCreations() async throws { + let root = URL(fileURLWithPath: "/in-memory/ignored-color-creation") + let first = root.appendingPathComponent("first.log"), second = root.appendingPathComponent("second.log") + let fixture = InventoryFixture(root: root, files: [], rows: [root.path: []]) + defer { fixture.close() } + await fixture.load() + await fixture.model.setGitIgnoreIndexing(false) + let scans = fixture.disk.snapshotCount + fixture.disk.set(files: [first], rows: [ + root.path: [FileNode(url: first, isDirectory: false, children: nil, isGitIgnored: true)], + ]) + fixture.disk.blockNextListing() + let firstColored = fixture.expectIgnoredDecoration(for: first) + try fixture.emit([first]) + #expect(await fixture.disk.listingStarted.waitUntilOpen()) + #expect(fixture.model.projectFiles == [first]) + fixture.disk.set(files: [first, second], rows: [ + root.path: [first, second].map { FileNode(url: $0, isDirectory: false, children: nil, isGitIgnored: true) }, + ]) + let refreshed = fixture.expectRefresh(for: second) + try fixture.emit([second]) + #expect(await refreshed.waitUntilOpen()) + fixture.disk.releaseListing.open() + #expect(await firstColored.waitUntilOpen()) + #expect(!fixture.disk.listingTimedOut) + #expect(Set(fixture.model.projectFiles) == Set([first, second])) + #expect(fixture.model.rootNode?.node(exactlyAt: second)?.isGitIgnored == true) + #expect(fixture.disk.snapshotCount == scans) + } + + @Test + func ignoredDirectoryInspectionIsReadOnlyAndSelectedExclusionsRefreshOnce() async throws { + let root = URL(fileURLWithPath: "/in-memory/ignored-notice") + let fixture = InventoryFixture(root: root, files: [], rows: [root.path: []]) + defer { fixture.close() } + await fixture.load() + fixture.disk.setSuggestions(["archive", "cache", "keep"]) + let scans = fixture.disk.snapshotCount + let settings = fixture.model.indexingSettings + #expect(try await fixture.model.ignoredDirectorySuggestions().directories == ["archive", "cache", "keep"]) + #expect(fixture.model.directoryMarks.isEmpty) + #expect(fixture.model.indexingSettings == settings) + #expect(fixture.disk.snapshotCount == scans) + try await fixture.model.excludeIgnoredDirectories(["archive", "cache", "not-in-suggestions"]) + #expect(fixture.model.directoryMarks == ["archive": .excluded, "cache": .excluded]) + #expect(fixture.disk.snapshotCount == scans + 1) + #expect(try await fixture.model.ignoredDirectorySuggestions().directories == ["keep"]) + fixture.model.dismissIgnoredDirectoryNotice() + #expect(fixture.model.indexingSettings.ignoredDirectoryNoticeDismissed == true) + #expect(fixture.disk.snapshotCount == scans + 1) + } @Test func sharedDirectoryActionsPreserveRolesAndIncludeOverrides() async throws { struct Fixture: Decodable { @@ -805,6 +972,13 @@ private final class InventoryFixture { return gate } + func expectIgnoredDecoration(for url: URL) -> TestGate { + let gate = TestGate() + model.$rootNode.filter { $0?.node(exactlyAt: url)?.isGitIgnored == true } + .sink { _ in gate.open() }.store(in: &refreshObservations) + return gate + } + func expectGitRefresh() -> TestGate { let gate = TestGate() gitRefreshGate = gate @@ -845,6 +1019,17 @@ private final class InventoryDisk: WorkspaceOperations, WorkspaceFileOperations, private var incompleteDirectories: [String] = [] func setIncompleteDirectories(_ paths: [String]) { lock.withLock { incompleteDirectories = paths } } private var policy: WorkspaceIndexingPolicy? + private var suggestedPaths: [String] = [] + let suggestionsRequested = TestGate() + func setSuggestions(_ paths: [String]) { lock.withLock { suggestedPaths = paths } } + func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + suggestionsRequested.open() + return lock.withLock { + var result = WorkspaceIgnoredDirectorySuggestions() + result.directories = suggestedPaths.filter { !(policy?.manualExcludedDirectories.contains($0) ?? false) } + return result + } + } var supportsExclusions = true private var blockConfiguration = false private var failConfiguration = false diff --git a/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift b/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift index 5ce136fffd..e714952f40 100644 --- a/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift +++ b/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift @@ -10,6 +10,7 @@ struct LitheTreeRowStyleTests { @Test(arguments: [ColorScheme.dark, .light], [ControlActiveState.key, .inactive]) func selectedRowsHonorRegionAndWindowFocus(scheme: ColorScheme, windowState: ControlActiveState) throws { for focused in [false, true] { + for selected in [false, true] { for tree in [false, true] { let row = HStack(spacing: 2) { Color.clear.frame(width: 16, height: 16) @@ -19,9 +20,9 @@ struct LitheTreeRowStyleTests { let content = tree ? AnyView( row.frame(height: LitheTheme.Tree.rowHeight).modifier( - LitheTreeListRowSurface(isSelected: true, isFocused: focused))) + LitheTreeListRowSurface(isSelected: selected, isFocused: focused, background: LitheTheme.Tree.excludedBackground))) : AnyView( - row.frame(height: 40).modifier(LitheTreeListRowSurface(isSelected: true, isFocused: focused))) + row.frame(height: 40).modifier(LitheTreeListRowSurface(isSelected: selected, isFocused: focused, background: LitheTheme.Tree.excludedBackground))) let renderer = ImageRenderer(content: content.frame(width: 220) .environment(\.colorScheme, scheme) .environment(\.controlActiveState, windowState)) @@ -39,7 +40,7 @@ struct LitheTreeRowStyleTests { context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) } let active = focused && windowState == .key - let expected: UInt32 = scheme == .dark + let expected: UInt32 = !selected ? (scheme == .dark ? 0x4F4B41 : 0xFFFFE4) : scheme == .dark ? (active ? 0x2A4371 : 0x33353B) : (active ? 0xD0DFFE : 0xE9EAEE) // Sample the row interior beyond text, so actual shared chrome rather @@ -51,6 +52,7 @@ struct LitheTreeRowStyleTests { #expect(pixels[offset + 3] == 255) #expect(pixels[3] == 0) // Rounded corners retain their inset. } + } } } } diff --git a/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift b/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift index c43734234a..cf7133d1f3 100644 --- a/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift +++ b/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift @@ -132,8 +132,8 @@ struct ProjectTreeLoaderTests { let generation = cache.prepare(at: root, rules: .default) let disk: [String: [ProjectDirectoryEntry]] = [ source.path: [.init(url: com, isDirectory: true)], - com.path: [.init(url: package, isDirectory: true)], - package.path: [.init(url: file, isDirectory: false)], + com.path: [.init(url: package, isDirectory: true, isGitIgnored: true)], + package.path: [.init(url: file, isDirectory: false, isGitIgnored: true)], ] var reads: [String: Int] = [:] let loader = ProjectTreeLoader(cache: cache, generation: generation) { directories in @@ -149,6 +149,10 @@ struct ProjectTreeLoaderTests { #expect(rows.first?.url == package) #expect(rows.first?.collapsedAncestorPaths == [com.path]) #expect(rows.first?.children?.map(\.url) == [file]) + #expect(rows.first?.isGitIgnored == true) + #expect(rows.first?.children?.first?.isGitIgnored == true) + let visible = ProjectTreeSelection.VisibleRow(node: try #require(rows.first), depth: 1) + #expect(visible.node.isGitIgnored) #expect(reads == [source.path: 1, com.path: 1, package.path: 1]) // Auto-expansion consumes compaction lookahead without a second read. // A later explicit refresh still reads the directory again. diff --git a/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift b/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift index 1f43ccf003..07b5753f12 100644 --- a/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift +++ b/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift @@ -4,6 +4,20 @@ import LitheCoreContracts @testable import Lithe struct WorkspaceDirectoryMarkStoreTests { + @Test + func noticeDismissalReadsLegacyPreferencesAndPersistsPerProject() throws { + let preferences = DirectoryMarkKeyValueStore() + let store = WorkspaceDirectoryMarkStore(store: preferences) + let root = URL(fileURLWithPath: "/projects/legacy") + preferences.set(Data(#"{"gitIgnoreEnabled":false,"includedDirectories":["cache"],"previousRoles":{}}"#.utf8), + forKey: "lithe.workspace-indexing." + root.path) + var settings = try store.loadIndexingSettings(for: root) + #expect(settings.ignoredDirectoryNoticeDismissed == nil) + settings.ignoredDirectoryNoticeDismissed = true + try store.saveIndexingSettings(settings, for: root) + #expect(try store.loadIndexingSettings(for: root) == settings) + #expect(try store.loadIndexingSettings(for: URL(fileURLWithPath: "/projects/other")).ignoredDirectoryNoticeDismissed != true) + } @Test(arguments: ["broken", "null", "{}", "{\"gitIgnoreEnabled\":null,\"includedDirectories\":[]}", "{\"gitIgnoreEnabled\":true,\"includedDirectories\":[1]}"]) func invalidIndexingSettingsAreClassifiedAndPreserved(_ raw: String) throws { let preferences = DirectoryMarkKeyValueStore() diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 33f98b7f30..4ae02ff955 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -8123,6 +8123,32 @@ pub(crate) fn untracked_candidates(directory: &Path, candidates: &[String]) -> O ) } +/// Returns immediate child directories without any tracked descendant or gitlink. +/// Native literal pathspec batches keep process count proportional to argument bytes. +pub(crate) fn untracked_directory_candidates( + directory: &Path, + candidates: &[String], +) -> Option> { + let mut untracked = candidates.iter().cloned().collect::>(); + for batch in pathspec_batches(candidates, LS_FILES_PATHSPEC_BUDGET) { + let mut arguments = vec!["--literal-pathspecs", "ls-files", "-z", "--"]; + arguments.extend(batch.iter().map(String::as_str)); + let output = run_git(directory, &arguments).ok()?; + if !output.status.success() { + return None; + } + for record in output + .stdout + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + { + let path = String::from_utf8_lossy(record); + untracked.remove(path.split('/').next().unwrap_or(&path)); + } + } + Some(untracked) +} + /// Splits `paths` into consecutive non-empty batches whose summed byte length /// stays within `budget`; a single longer path forms its own batch. fn pathspec_batches(paths: &[String], budget: usize) -> Vec<&[String]> { diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index 1cd8ca61bb..dbc3420834 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -357,6 +357,21 @@ pub fn list_directories( } Err(error) => return Err(error), }; + // Query this visible sibling batch in its containing repository. Nested + // repositories own their children's rules, independently of analysis policy. + let names = listing + .children + .iter() + .map(|(path, _)| node_name(path)) + .collect::>(); + let ignored_paths = crate::git::ignored_paths(&directory, &names)?.map(|paths| { + let mut paths = paths + .into_iter() + .map(|name| relative_path(&directory.join(name), &root)) + .collect::>(); + paths.sort(); + paths + }); let children = listing .children .into_iter() @@ -377,6 +392,7 @@ pub fn list_directories( directories.push(DirectoryListing { path: relative_path(&directory, &root), children, + ignored_paths, nested_worktrees: listing .nested_worktrees .iter() @@ -1118,7 +1134,7 @@ impl VisibilityRules { } /// Binds scan ownership to the caller's workspace without changing directory browsing. - fn with_workspace_roots(mut self, root: &Path, roots: &[String]) -> Self { + pub(crate) fn with_workspace_roots(mut self, root: &Path, roots: &[String]) -> Self { for value in roots { // Disconnected siblings must not fail an otherwise valid scoped request. // Missing descendants still retain their boundary until the host removes the root. diff --git a/rust/lithe-core/src/project/indexing.rs b/rust/lithe-core/src/project/indexing.rs index 09f34cfd87..ad4bb716d0 100644 --- a/rust/lithe-core/src/project/indexing.rs +++ b/rust/lithe-core/src/project/indexing.rs @@ -42,6 +42,131 @@ pub struct IndexingStatus { pub git_ignore_failed: bool, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +/// Reads exclusion suggestions for an already configured workspace, without changing its policy. +pub struct IgnoredDirectorySuggestionsRequest { + pub root: String, + /// Explicit root membership; nested roots are inspected with their own policy. + #[serde(default)] + pub workspace_roots: Vec, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +/// A bounded directory-only inventory. Partial results never establish absence. +pub struct IgnoredDirectorySuggestions { + pub directories: Vec, + pub incomplete: bool, + pub git_ignore_failed: bool, +} + +/// Finds Git-ignored directory roots still eligible for workspace analysis. +/// Native Git retains ownership of ignore syntax and tracked-file protection. +pub fn ignored_directory_suggestions( + request: IgnoredDirectorySuggestionsRequest, +) -> Result { + scan_ignored_directories(request, 10_000, 32, 100_000) +} + +/// Bounds both descended directories and entries read from wide, file-heavy parents. +fn scan_ignored_directories( + request: IgnoredDirectorySuggestionsRequest, + max_directories: usize, + max_depth: usize, + max_entries: usize, +) -> Result { + let root = existing_root(&request.root)?; + let boundaries = super::files::VisibilityRules::new(Vec::new(), Vec::new()) + .with_workspace_roots(&root, &request.workspace_roots); + let context = active_provider_context(&root).ok_or_else(|| { + CoreError::new(ErrorCode::Cancelled, "Workspace indexing is not configured") + })?; + let mut result = IgnoredDirectorySuggestions::default(); + let mut queue = std::collections::VecDeque::from([(root.clone(), 0usize)]); + let mut visited = 0; + let mut entries_read = 0; + while let Some((directory, depth)) = queue.pop_front() { + context.check()?; + if visited == max_directories { + result.incomplete = true; + break; + } + visited += 1; + if boundaries.is_delegated(&directory) { + continue; + } + let relative = relative_path(&directory, &root); + if context.reason(&relative, true)?.is_some() { + continue; + } + if directory != root { + context.prepare_parent(directory.parent().unwrap_or(&root), &relative)?; + let ignored = context + .decisions + .lock() + .map_err(|_| { + CoreError::new(ErrorCode::Unknown, "Indexing decision lock was poisoned") + })? + .get(&relative) + == Some(&true); + if ignored { + result.directories.push(relative); + continue; + } + } + if entries_read == max_entries { + result.incomplete = true; + continue; + } + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(_) => { + result.incomplete = true; + continue; + } + }; + let mut children = std::collections::BTreeSet::new(); + for entry in entries { + context.check()?; + if entries_read == max_entries { + result.incomplete = true; + break; + } + entries_read += 1; + match entry { + Ok(entry) if entry.file_name() != ".git" => match entry.file_type() { + Ok(kind) if kind.is_dir() => { + children.insert(entry.path()); + if children.len() + queue.len() + visited > max_directories { + children.pop_last(); + result.incomplete = true; + } + } + Ok(_) => {} + Err(_) => result.incomplete = true, + }, + Ok(_) => {} + Err(_) => result.incomplete = true, + } + } + if depth == max_depth { + result.incomplete |= !children.is_empty(); + continue; + } + let names = children + .iter() + .filter_map(|path| path.file_name()?.to_str().map(str::to_owned)) + .collect::>(); + context.prepare_candidates(&directory, &names)?; + queue.extend(children.into_iter().map(|path| (path, depth + 1))); + } + context.check()?; + result.directories.sort(); + result.git_ignore_failed = context.git_failed.load(Ordering::Acquire); + Ok(result) +} + /// Immutable policy generation with lazily cached Git directory decisions. pub(crate) struct IndexingContext { pub(crate) revision: u64, @@ -219,7 +344,10 @@ fn configure_with_provider_update( } if let Ok(decisions) = context.decisions.lock() { for (path, excluded) in decisions.iter() { - if *excluded + // Suggestion inspection may fill this cache even while automatic + // exclusion is disabled; status reports only effective decisions. + if context.policy.git_ignore_enabled + && *excluded && !context .policy .included_directories @@ -406,7 +534,8 @@ pub fn refresh_indexing(request: RefreshIndexingRequest) -> Result>(); + if let Some(name) = Path::new(requested) + .file_name() + .and_then(|name| name.to_str()) + { + if !candidates.iter().any(|candidate| candidate == name) { + candidates.push(name.to_owned()); + } + } + self.prepare_candidates(parent, &candidates) + } + + /// Resolves one caller-bounded sibling batch using native ignore and index queries. + fn prepare_candidates(&self, parent: &Path, candidates: &[String]) -> Result<(), CoreError> { + self.check()?; + let cache = self.decisions.lock().map_err(|_| { + CoreError::new(ErrorCode::Unknown, "Indexing decision lock was poisoned") + })?; + let candidates = candidates + .iter() + .filter(|name| !cache.contains_key(&relative_path(&parent.join(name), &self.root))) + .cloned() + .collect::>(); + drop(cache); + if candidates.is_empty() { + return Ok(()); + } let ignored = crate::git::ignored_paths(parent, &candidates)?; let mut decisions = HashMap::new(); if let Some(ignored) = ignored { - // The index is authoritative for tracked descendants; check-ignore on a directory - // alone does not protect tracked files below an otherwise ignored directory. + let ignored = ignored.into_iter().collect::>(); + // The containing repository's index protects tracked descendants and + // submodule gitlinks; an independent ignored checkout is not exempt. + let untracked = crate::git::untracked_directory_candidates(parent, &ignored); + if untracked.is_none() { + self.git_failed.store(true, Ordering::Release); + } for name in candidates { - let excluded = if parent.join(&name).join(".git").exists() { - // A nested repository owns its ignore rules and tracked content. - false - } else if ignored.contains(&name) { - let response = crate::git::execute_git_readonly( - &parent.to_string_lossy(), - &[ - "--literal-pathspecs".into(), - "ls-files".into(), - "-z".into(), - "--".into(), - name.clone(), - ], - None, - ); - self.check()?; - let Ok(response) = response else { - self.git_failed.store(true, Ordering::Release); - continue; - }; - if response.exit_code != 0 { - self.git_failed.store(true, Ordering::Release); - false - } else { - response.stdout.is_empty() - } - } else { - false - }; + let excluded = untracked + .as_ref() + .is_some_and(|paths| paths.contains(&name)); decisions.insert(relative_path(&parent.join(name), &self.root), excluded); } } else { @@ -720,7 +856,12 @@ pub(crate) fn excluded_roots(root: &Path) -> Vec { return Vec::new(); }; let mut roots = context.policy.manual_excluded_directories.clone(); - if let Ok(decisions) = context.decisions.lock() { + if let Some(decisions) = context + .decisions + .lock() + .ok() + .filter(|_| context.policy.git_ignore_enabled) + { roots.extend( decisions .iter() @@ -1306,6 +1447,39 @@ mod tests { .files() .contains(&"ignored/deep/Excluded.java".into())); } + + /// Visible row decoration remains a Git fact when analysis exclusions change. + #[test] + fn directory_listing_keeps_git_ignored_names_independent_of_indexing_policy() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + fs::write(workspace.0.join(".gitignore"), "ignored/\n*.log\n").unwrap(); + fs::write(workspace.0.join("ignored.log"), "ignored").unwrap(); + fs::write(workspace.0.join("tracked.log"), "tracked").unwrap(); + workspace.git(&["add", "-f", "tracked.log"]); + for enabled in [false, true] { + workspace.configure(&[], &[], enabled); + let listing = files::list_directories( + serde_json::from_value(json!({ + "root": workspace.0, "paths": ["", "ignored"] + })) + .unwrap(), + ) + .unwrap(); + assert_eq!( + listing.directories[0].ignored_paths.as_ref().unwrap(), + &["ignored", "ignored.log"] + ); + assert!(listing.directories[0] + .children + .iter() + .any(|node| node.path == "ignored")); + assert_eq!( + listing.directories[1].ignored_paths.as_ref().unwrap(), + &["ignored/deep"] + ); + } + } #[test] fn watcher_refresh_preserves_host_release_and_rejects_both_stale_and_closed_readers() { let workspace = Workspace::new(); @@ -1654,6 +1828,197 @@ mod tests { ); } + #[test] + fn parent_ignore_excludes_nested_checkout_only_from_analysis_inventory() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + workspace.git(&["-C", "ignored", "init", "--quiet"]); + workspace.git(&["-C", "ignored", "add", "deep/Excluded.java"]); + fs::write(workspace.0.join(".gitignore"), "ignored\n").unwrap(); + workspace.configure(&[], &[], true); + assert!(!workspace + .files() + .iter() + .any(|path| path.starts_with("ignored/"))); + let repositories = crate::git::workspace_repositories( + serde_json::from_value(json!({"root": workspace.0})).unwrap(), + ) + .unwrap(); + assert_eq!(repositories.repositories.len(), 2); + assert!( + ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { + root: workspace.0.to_string_lossy().into_owned(), + workspace_roots: Vec::new(), + }) + .unwrap() + .directories + .is_empty() + ); + + workspace.configure(&[], &["ignored"], true); + assert!(workspace + .files() + .contains(&"ignored/deep/Excluded.java".into())); + assert_eq!( + crate::git::workspace_repositories( + serde_json::from_value(json!({"root": workspace.0})).unwrap(), + ) + .unwrap() + .repositories + .len(), + 2 + ); + let suggestions = ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { + root: workspace.0.to_string_lossy().into_owned(), + workspace_roots: Vec::new(), + }) + .unwrap(); + assert_eq!(suggestions.directories, ["ignored"]); + assert!(!suggestions.incomplete && !suggestions.git_ignore_failed); + + workspace.configure(&["ignored"], &["ignored/deep"], false); + assert_eq!( + crate::git::workspace_repositories( + serde_json::from_value(json!({"root": workspace.0})).unwrap(), + ) + .unwrap() + .repositories + .len(), + 2 + ); + } + + #[test] + fn ignored_suggestions_are_read_only_and_keep_tracked_files_and_manual_exclusions() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + workspace.git(&["add", "src/Included.java"]); + fs::write(workspace.0.join(".gitignore"), "ignored/\nsrc/\n").unwrap(); + let original = workspace.configure(&[], &[], false); + let files = workspace.files(); + let suggestions = ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { + root: workspace.0.to_string_lossy().into_owned(), + workspace_roots: Vec::new(), + }) + .unwrap(); + assert_eq!(suggestions.directories, ["ignored"]); + let status = configure( + serde_json::from_value(json!({ + "root": workspace.0, "manualExcludedDirectories": [], + "includedDirectories": [], "gitIgnoreEnabled": false, + "paths": ["ignored"] + })) + .unwrap(), + ) + .unwrap(); + assert!( + status.excluded_directories.is_empty(), + "Suggestion caches must not label included directories as excluded" + ); + assert_eq!(status.revision, original.revision); + assert_eq!(workspace.files(), files); + assert!( + excluded_roots(&workspace.0).is_empty(), + "Inspecting suggestions must not change provider exclusions" + ); + assert_eq!( + context(&workspace.0).unwrap().owner_revision, + original.revision + ); + assert_eq!( + fs::read_to_string(workspace.0.join(".gitignore")).unwrap(), + "ignored/\nsrc/\n" + ); + workspace.configure(&["ignored"], &[], false); + assert!( + ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { + root: workspace.0.to_string_lossy().into_owned(), + workspace_roots: Vec::new(), + }) + .unwrap() + .directories + .is_empty() + ); + } + + #[test] + fn ignored_directory_fixture_handles_gitfiles_and_explicit_root_boundaries() { + let workspace = Workspace::new(); + let metadata = Workspace::new(); + workspace.git(&["init", "--quiet"]); + workspace.git(&[ + "init", + "--quiet", + "--separate-git-dir", + metadata.0.join("metadata").to_str().unwrap(), + "ignored", + ]); + assert!(workspace.0.join("ignored/.git").is_file()); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/ignored-directory-suggestions-v1.json" + )) + .unwrap(); + fs::write( + workspace.0.join(".gitignore"), + fixture["gitignore"].as_str().unwrap(), + ) + .unwrap(); + for scenario in fixture["cases"].as_array().unwrap() { + let mut policy = scenario.clone(); + policy["root"] = json!(workspace.0); + policy["refresh"] = json!(true); + configure(serde_json::from_value(policy).unwrap()).unwrap(); + let repositories = crate::git::workspace_repositories( + serde_json::from_value(json!({"root": workspace.0})).unwrap(), + ) + .unwrap(); + assert_eq!( + repositories.repositories.len(), + scenario["expectedRepositoryCount"].as_u64().unwrap() as usize + ); + let result = ignored_directory_suggestions( + serde_json::from_value(json!({"root": workspace.0})).unwrap(), + ) + .unwrap(); + assert_eq!(json!(result.directories), scenario["expectedSuggestions"]); + } + workspace.configure(&[], &[], false); + let result = ignored_directory_suggestions( + serde_json::from_value(json!({ + "root": workspace.0, "workspaceRoots": [workspace.0, workspace.0.join("ignored")] + })) + .unwrap(), + ) + .unwrap(); + assert!(result.directories.is_empty()); + } + + #[test] + fn manual_exclusion_preserves_untracked_and_tracked_git_candidates() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + workspace.git(&["add", "ignored/deep/Excluded.java"]); + fs::write(workspace.0.join("ignored/deep/new.txt"), "new").unwrap(); + workspace.configure(&["ignored"], &[], false); + for summarized in [false, true] { + let status = crate::git::status( + serde_json::from_value(json!({ + "root": workspace.0, "summarizeUntrackedDirectories": summarized + })) + .unwrap(), + ) + .unwrap(); + assert!(status + .changes + .iter() + .any(|change| change.path == "ignored/deep/Excluded.java")); + assert!(status + .changes + .iter() + .any(|change| change.path == "ignored/deep/new.txt")); + } + } + #[test] fn git_failure_does_not_drop_manual_exclusions() { let workspace = Workspace::new(); @@ -1682,6 +2047,144 @@ mod tests { workspace.configure(&["src"], &[], false); assert!(search().matches.is_empty()); } + #[test] + fn indexing_exclusions_do_not_change_git_summaries_or_expansion() { + let workspace = Workspace::new(); + let repo = workspace.0.join("repo"); + fs::create_dir_all(repo.join("parent/excluded")).unwrap(); + fs::create_dir_all(repo.join("parent/visible")).unwrap(); + workspace.git(&["init", "--quiet", "repo"]); + fs::write(repo.join("parent/excluded/private.txt"), "hidden").unwrap(); + for index in 0..101 { + fs::write(repo.join(format!("parent/visible/{index}.txt")), "visible").unwrap(); + } + workspace.configure(&["repo/parent/excluded"], &[], false); + let status = crate::git::status( + serde_json::from_value(json!({ + "root": repo, "summarizeUntrackedDirectories": true + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(status.changes.len(), 1); + assert_eq!(status.changes[0].path, "parent/"); + assert_eq!(status.changes[0].untracked_file_count, Some(102)); + let expanded = crate::git::untracked_directory( + serde_json::from_value(json!({ + "root": repo, "directory": "parent/" + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(expanded.changes.len(), 2); + assert_eq!(expanded.changes[0].path, "parent/excluded/"); + assert_eq!(expanded.changes[0].untracked_file_count, Some(1)); + assert_eq!(expanded.changes[1].path, "parent/visible/"); + assert_eq!(expanded.changes[1].untracked_file_count, Some(101)); + // Installing a separate analysis policy cannot change Git's projection. + let _repo_cleanup = Workspace(repo.clone()); + configure( + serde_json::from_value(json!({"root": repo, "gitIgnoreEnabled": false, "manualExcludedDirectories": [], "includedDirectories": []})).unwrap(), + ) + .unwrap(); + let own = crate::git::status( + serde_json::from_value(json!({ + "root": repo, "summarizeUntrackedDirectories": true + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(own.changes[0].path, "parent/"); + assert_eq!(own.changes[0].untracked_file_count, Some(102)); + release(ReleaseIndexingRequest { + root: repo.to_string_lossy().into_owned(), + revision: context(&repo).unwrap().owner_revision, + owner_id: None, + }) + .unwrap(); + } + + #[test] + fn git_status_is_independent_of_subdirectory_indexing_policy() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + let project = workspace.0.join("project"); + fs::create_dir_all(project.join("cache")).unwrap(); + fs::write(project.join("cache/hidden.txt"), "hidden").unwrap(); + fs::write(project.join("visible.txt"), "visible").unwrap(); + let policy = configure( + serde_json::from_value(json!({"root": project, "gitIgnoreEnabled": false, + "manualExcludedDirectories": ["cache"], "includedDirectories": []})) + .unwrap(), + ) + .unwrap(); + let result = crate::git::status( + serde_json::from_value(json!({"root": workspace.0, + "summarizeUntrackedDirectories": true})) + .unwrap(), + ); + release(ReleaseIndexingRequest { + root: project.to_string_lossy().into_owned(), + revision: policy.revision, + owner_id: None, + }) + .unwrap(); + let result = result.unwrap(); + assert!(result + .changes + .iter() + .any(|change| change.path == "project/visible.txt")); + assert!(result + .changes + .iter() + .any(|change| change.path == "project/cache/hidden.txt")); + } + + #[test] + fn excluded_ancestor_keeps_all_nested_git_candidates() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + let nested = Workspace(workspace.0.join("parent/nested")); + fs::create_dir_all(&nested.0).unwrap(); + fs::write(nested.0.join("visible.txt"), "visible").unwrap(); + fs::write(workspace.0.join("parent/hidden.txt"), "hidden").unwrap(); + workspace.configure(&["parent"], &[], false); + nested.configure(&[], &[], false); + let result = crate::git::status( + serde_json::from_value(json!({"root": workspace.0, + "summarizeUntrackedDirectories": true})) + .unwrap(), + ) + .unwrap(); + assert!(result + .changes + .iter() + .any(|change| change.path == "parent/nested/visible.txt")); + assert!(result + .changes + .iter() + .any(|change| change.path == "parent/hidden.txt")); + } + + #[test] + fn suggestion_entry_budget_reports_file_heavy_directories_as_incomplete() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + for index in 0..12 { + fs::write(workspace.0.join(format!("plain{index}.txt")), "text").unwrap(); + } + workspace.configure(&[], &[], false); + let result = scan_ignored_directories( + serde_json::from_value(json!({"root": workspace.0})).unwrap(), + 100, + 32, + 5, + ) + .unwrap(); + assert!(result.incomplete); + assert!(result.directories.is_empty()); + } + #[test] fn repository_archive_fixture_reduces_visits_and_resident_index_files() { let workspace = Workspace::new(); diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index d772133ee8..ac575031a9 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -208,6 +208,8 @@ pub enum CoreCommand { WorkspaceConfigureIndexing, /// Refreshes external indexing facts while retaining the owner's lease. WorkspaceRefreshIndexing, + /// Lists ignored directories that still participate in analysis. + WorkspaceIgnoredDirectorySuggestions, /// Releases an indexing generation when its project closes. WorkspaceReleaseIndexing, /// Lists the visible children of project directories (`workspace.listDirectories`). @@ -663,6 +665,9 @@ impl CoreCommand { "workspace.releaseIndexing" => Some(Self::WorkspaceReleaseIndexing), "workspace.configureIndexing" => Some(Self::WorkspaceConfigureIndexing), "workspace.refreshIndexing" => Some(Self::WorkspaceRefreshIndexing), + "workspace.ignoredDirectorySuggestions" => { + Some(Self::WorkspaceIgnoredDirectorySuggestions) + } "workspace.filterChanges" => Some(Self::WorkspaceFilterChanges), "workspace.listDirectories" => Some(Self::WorkspaceListDirectories), "workspace.repositories" => Some(Self::WorkspaceRepositories), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index b821ee654f..a89b28f72c 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -98,6 +98,10 @@ pub struct DirectoryListing { pub path: String, /// Directories first, then case-insensitive names, as in snapshots. pub children: Vec, + /// Git-ignored child paths, relative to the workspace; absent when Git cannot answer. + /// Decoration is independent of analysis exclusions and never filters children. + #[serde(skip_serializing_if = "Option::is_none")] + pub ignored_paths: Option>, /// Nested linked worktrees directly inside this directory, which /// `children` leaves out. pub nested_worktrees: Vec, diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index e022c1ea82..52878ce0a9 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -604,6 +604,23 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::WorkspaceIgnoredDirectorySuggestions => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid ignored directory query") + .with_details(error.to_string()) + }) + .and_then(project::indexing::ignored_directory_suggestions) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("directory suggestions should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::WorkspaceRefreshIndexing => { match serde_json::from_value::( parsed.payload, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 9096370b43..80bd016041 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -989,11 +989,12 @@ package manager owns the download and Lithe does not infer bytes from logs. | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | | `workspace.releaseIndexing` | Retire a policy and clear index/cache data at project close; accepts `root`, optional `ownerId`, and that owner's last observed `revision`, returns `released`; an old close must not retire a new generation | | `workspace.refreshIndexing` | Refresh external Git decisions for a live `root`, `ownerId`, `revision` lease without changing preferences or release tokens; returns lazy `IndexingStatus` | +| `workspace.ignoredDirectorySuggestions` | Read bounded Git-ignored directory candidates still included in analysis; never writes project preferences | | `workspace.configureIndexing` | Install a process-local, canonical-root indexing policy before workspace consumers run; returns a monotonic revision, effective directory reasons and Git failure state; see `shared/fixtures/workspace/indexing-policy-v1.json` | | `network.proxyPolicy` | Normalize the stored proxy settings into the policy a network stack must apply (mode, server, credentials, exclusions) | | `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index; the built-in hidden names are skipped without being read, and include `node_modules`, Python virtual environments (`.venv`, `venv`), `__pycache__` and `*.pyc` files; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | | `workspace.filterChanges` | Filter an array of workspace-relative paths, or `{ paths, hiddenFilePatterns }` whose `!` entries turn default exclusions off, against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | -| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | +| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Optional `ignoredPaths` contains workspace-relative ignored child paths queried in the containing Git repository; omission means Git could not answer. This name decoration neither depends on indexing exclusions nor filters children. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | | `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | | `workspace.search` | Search visible file names and UTF-8 text files | | `workspace.searchEverywhere` | Search visible file names, Java types/methods, and UTF-8 text files | @@ -4271,6 +4272,9 @@ returns `{ changes }`: the directory's immediate untracked files plus each subdirectory as a summary, using the same change shape and nested-root exclusion as `git.status`. +Directory indexing exclusions do not filter Git commit candidates or their summary counts. +Git ignore rules continue to govern untracked candidates through native Git. + `git.workspaceCommitPrepare` owns the complete multi-repository policy. It accepts `repositories: [{ id, root }]`, `message`, `amend`, `push`, `includeParentReferences`, optional `previous` session for retry, and optional @@ -4617,6 +4621,22 @@ more-specific manual exclusion. Ancestors needed to reach an exception remain traversable. Standalone ignored files are not converted to directory exclusions. Git checks use native `check-ignore` and protect tracked descendants with `ls-files`. No `.gitignore`, index, or project configuration files are written by this command. +A nested `.git` directory or gitfile does not bypass its containing repository's ignore +rules; tracked descendants and submodule gitlinks remain protected by the parent index. +Directory indexing exclusions affect analysis and search, not Git repository ownership or status. + +`workspace.ignoredDirectorySuggestions` accepts `{ root, workspaceRoots? }` and requires +an installed indexing policy. It returns `{ directories, incomplete, gitIgnoreFailed }`: +sorted relative directory roots ignored by native Git but still eligible for analysis. +It skips manual/automatic exclusions and roots explicitly delegated to another workspace, +protects tracked descendants, does not follow symlinks, and prunes beneath each candidate. +Scanning is bounded to 10,000 directories, 100,000 entries and 32 levels; partial/error flags never establish +absence. Ignore checks and tracked-directory protection are batched, and retired generations +reject late results. Inspection also works with automatic Git exclusion disabled; its cached +Git decisions do not change the effective policy. Opening `.gitignore` only inspects. Hosts +persist selected exclusions after explicit confirmation and store the optional project-local +`ignoredDirectoryNoticeDismissed` preference in application storage, never in `.gitignore`. +The shared example is `shared/fixtures/workspace/ignored-directory-suggestions-v1.json`. `ownerId` optionally identifies one workspace runtime lifetime. Independent runtimes using the same canonical root hold independent leases. Hosts acquire/release leases diff --git a/shared/fixtures/workspace/ignored-directory-suggestions-v1.json b/shared/fixtures/workspace/ignored-directory-suggestions-v1.json new file mode 100644 index 0000000000..fc1d930226 --- /dev/null +++ b/shared/fixtures/workspace/ignored-directory-suggestions-v1.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "command": "workspace.ignoredDirectorySuggestions", + "request": { "root": "/workspace", "workspaceRoots": ["/workspace"] }, + "gitignore": "ignored\n", + "nestedRepository": "ignored", + "cases": [ + { "gitIgnoreEnabled": true, "manualExcludedDirectories": [], "includedDirectories": [], "expectedRepositoryCount": 2, "expectedSuggestions": [] }, + { "gitIgnoreEnabled": false, "manualExcludedDirectories": [], "includedDirectories": [], "expectedRepositoryCount": 2, "expectedSuggestions": ["ignored"] }, + { "gitIgnoreEnabled": true, "manualExcludedDirectories": [], "includedDirectories": ["ignored"], "expectedRepositoryCount": 2, "expectedSuggestions": ["ignored"] }, + { "gitIgnoreEnabled": false, "manualExcludedDirectories": ["ignored"], "includedDirectories": [], "expectedRepositoryCount": 2, "expectedSuggestions": [] } + ] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index ff8722be35..bffe032bc7 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9879,7 +9879,15 @@ "plugin.terminal.running": "The plugin terminal is still running.", "plugin.terminal.busy": "The plugin terminal is changing state. Try again when it finishes.", "plugin.terminal.permission": "Open and manage its own native terminals, retaining bounded display history for reopening", - "native.downloadingAndVerifyingPlugin": "Downloading and verifying plugin…" + "native.downloadingAndVerifyingPlugin": "Downloading and verifying plugin…", + "indexing.notice": "Some ignored directories are still included in analysis and search.", + "indexing.noticeFailed": "Could not check ignored directories.", + "indexing.noticeIncomplete": "Some directories could not be checked. The list may be incomplete.", + "indexing.viewDirectories": "View Directories", + "indexing.dismissNotice": "Don't Show Again", + "indexing.ignoredDirectories": "Ignored Directories", + "indexing.excludeSelected": "Exclude Selected", + "indexing.noticeHelp": "Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 83ab2d5577..b6345d31b7 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9879,7 +9879,15 @@ "plugin.terminal.running": "插件终端仍在运行。", "plugin.terminal.busy": "插件终端正在切换状态,请完成后重试。", "plugin.terminal.permission": "打开并管理自己的原生终端,保留有限的显示历史用于重新打开", - "native.downloadingAndVerifyingPlugin": "正在下载并校验插件…" + "native.downloadingAndVerifyingPlugin": "正在下载并校验插件…", + "indexing.notice": "部分被忽略的目录未从分析和搜索中排除。", + "indexing.noticeFailed": "无法检查被忽略的目录。", + "indexing.noticeIncomplete": "部分目录无法检查,列表可能不完整。", + "indexing.viewDirectories": "查看目录", + "indexing.dismissNotice": "不再显示", + "indexing.ignoredDirectories": "被忽略的目录", + "indexing.excludeSelected": "排除所选目录", + "indexing.noticeHelp": "排除目录后,Lithe 不再分析和搜索其中的文件,但不会改变 Git 提交候选,项目树仍可浏览文件。语言服务器仍可能扫描构建系统声明的模块。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 0e4c34989b..c9576ff3b8 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -94931,6 +94931,102 @@ "key": "Downloading and verifying plugin…", "arguments": [] } + }, + { + "id": "indexing.notice", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Some ignored directories are still included in analysis and search.", + "arguments": [] + } + }, + { + "id": "indexing.noticeFailed", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Could not check ignored directories.", + "arguments": [] + } + }, + { + "id": "indexing.noticeIncomplete", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Some directories could not be checked. The list may be incomplete.", + "arguments": [] + } + }, + { + "id": "indexing.viewDirectories", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "View Directories", + "arguments": [] + } + }, + { + "id": "indexing.dismissNotice", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Don't Show Again", + "arguments": [] + } + }, + { + "id": "indexing.ignoredDirectories", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Ignored Directories", + "arguments": [] + } + }, + { + "id": "indexing.excludeSelected", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Exclude Selected", + "arguments": [] + } + }, + { + "id": "indexing.noticeHelp", + "description": "Ignored directory editor notice and explicit exclusion actions", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json index 8ebc9e895c..157791c097 100644 --- a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json +++ b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json @@ -4,7 +4,7 @@ "group": "工作区生命周期", "capability": "目录索引排除与 Git 自动排除", "owner": "Workspace", - "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。", + "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。 2026-11-02:新增.gitignore只读候选提示、选择排除和项目级不再显示;修复父忽略嵌套仓库的分析自动排除;忽略仅名称着色,排除整行底色,选择色优先。PyCharm2026.2.3对照后明确分析排除不影响Git提交,撤回此前Git发现/未跟踪候选/汇总过滤,Git请求不携带分析策略。已对照排除后提交项仍在、刷新与重开相同、父.gitignore仍忽略ShipFlow时显式登记它后其独立提交项可见;外层忽略与独立仓库映射分别处理。运行补修源码Core1582、macOS聚焦90项逐项计时通过,含空态提示挂载、规则/新文件颜色刷新、并发创建和展开;Git边界调整后的Bun31及TypeScript检查通过,前端此后未改。旧Core1581/Swift75/Bun57不代替最终源码;最终Lithe-Dev构建2026-11-02T07:41:31Z已替换安装;原生验证ShipFlow自动分析排除、开关及部分手动排除后的真实搜索、提示候选选择与不再显示重启持久化、名称棕色和整行底色、刷新后手动排除及ShipFlow独立Git候选保留。候选查询后的状态缓存泄漏已补修,全量Core1582及最终原生刷新通过;Swift源码此后未变。动态规则改色的界面操作未形成有效证据,以模型回归为依据。1426项bundle内容/模式/链接及签名不变,Dev偏好精确恢复,QA缓存归档,应用与辅助进程清理。固定工具链和Windows原生验收仍pending。", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", @@ -24,7 +24,10 @@ "macos/Tests/LitheTests/GitStatusObservationTests.swift", "macos/Tests/LitheTests/RunEntryPointTests.swift", "shared/fixtures/workspace/directory-actions-v1.json", - "macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift" + "macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift", + "macos/Sources/Lithe/Views/Editor/IgnoredDirectoryNotice.swift", + "macos/Tests/LitheTests/LitheTreeRowStyleTests.swift", + "shared/fixtures/workspace/ignored-directory-suggestions-v1.json" ] }, "windows": { @@ -56,7 +59,11 @@ "windows/tauri/crates/project/src/indexing_refresh.rs", "windows/tauri/crates/project/src/git_watcher.rs", "windows/tauri/src/features/git/runtime/git-metadata-watch-host.tsx", - "windows/tauri/src/features/editor/lsp/mounted-lsp-documents.ts" + "windows/tauri/src/features/editor/lsp/mounted-lsp-documents.ts", + "windows/tauri/src/features/editor/components/ignored-directory-notice.tsx", + "windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx", + "windows/tauri/src/features/git/runtime/git-status-refresh-host.tsx", + "shared/fixtures/workspace/ignored-directory-suggestions-v1.json" ] } } diff --git a/windows/tauri/src-tauri/src/core.rs b/windows/tauri/src-tauri/src/core.rs index b7784f15c9..ffed38ba67 100644 --- a/windows/tauri/src-tauri/src/core.rs +++ b/windows/tauri/src-tauri/src/core.rs @@ -127,6 +127,8 @@ fn execute_for_window(owner: &str, request: &str, execute: impl Fn(&str) -> Stri value["command"].as_str(), Some( "workspace.snapshot" + | "workspace.ignoredDirectorySuggestions" + | "workspace.repositories" | "workspace.search" | "workspace.searchEverywhere" | "workspace.searchIndex.warm" diff --git a/windows/tauri/src/features/editor/components/code-editor.tsx b/windows/tauri/src/features/editor/components/code-editor.tsx index 47c68b9f13..76e150a1c3 100644 --- a/windows/tauri/src/features/editor/components/code-editor.tsx +++ b/windows/tauri/src/features/editor/components/code-editor.tsx @@ -70,6 +70,7 @@ import { import { SvgEditor } from "./svg-editor"; import "../styles/editor-shell.css"; import { ExternalConflictBanner } from "./external-conflict-banner"; +import { IgnoredDirectoryNotice } from "./ignored-directory-notice"; import Breadcrumb, { type BreadcrumbProps } from "./toolbar/breadcrumb"; interface CodeEditorProps { @@ -683,6 +684,7 @@ const CodeEditor = ({ )} {activeBufferId && } + {tooLargeForEditorServices && enableInteractiveServices && !forceExpensiveServices && (
diff --git a/windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx b/windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx new file mode 100644 index 0000000000..5e2ef31e89 --- /dev/null +++ b/windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import * as dialog from "@/ui/dialog"; +import * as core from "@/core/lithe-core-client"; +import * as virtualization from "@tanstack/react-virtual"; +import * as indexing from "@/features/file-explorer/stores/workspace-indexing"; +import { readDirectoryMarks } from "@/features/file-explorer/stores/directory-marks"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { WorkspaceStoreScopeContext } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { IgnoredDirectoryNotice } from "./ignored-directory-notice"; + +const workspaceId = "ignored-directory-notice-fixture"; +const project = "C:/fixture"; +let host: HTMLDivElement; +let root: Root; +let restoreDom: () => void; +let previousAct: boolean | undefined; +const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +const spies: { mockRestore(): void }[] = []; +let query: ReturnType>; + +beforeEach(() => { + restoreDom = installHappyDom(); + previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + spies.push( + spyOn(core, "executeCore").mockResolvedValue({ + id: "fixture", + ok: true, + data: { revision: 1, excludedDirectories: {}, gitIgnoreFailed: false }, + }), + spyOn(dialog, "default").mockImplementation(({ children, footer }) => ( +
+ {children} + {footer} +
+ )), + ); + useFileSystemStore + .getStore(workspaceId) + .setState({ rootFolderPath: project, workspaceFolders: [] }); + workspaceRuntimeRegistry.updateWorkspaceStatus(workspaceId, "ready"); + query = spyOn(indexing, "queryIgnoredDirectorySuggestions").mockResolvedValue({ + directories: ["archive", "cache"], + incomplete: false, + gitIgnoreFailed: false, + }); + spies.push( + query, + spyOn(virtualization, "useVirtualizer").mockImplementation(((options: { count: number }) => ({ + getTotalSize: () => options.count * 28, + getVirtualItems: () => + Array.from({ length: options.count }, (_, index) => ({ + index, + key: index, + start: index * 28, + size: 28, + })), + })) as unknown as typeof virtualization.useVirtualizer), + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + try { + await act(async () => root.unmount()); + } finally { + host.remove(); + spies + .splice(0) + .reverse() + .forEach((spy) => spy.mockRestore()); + workspaceRuntimeRegistry.resetForTests(); + environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + restoreDom(); + } +}); +async function render(path = `${project}/.gitignore`) { + await act(async () => + root.render( + + + + + , + ), + ); +} +const button = (text: string) => + Array.from(document.querySelectorAll("button")).find( + (node) => node.textContent === text, + )!; + +test("opening an ignore file is read-only; confirming excludes only selected directories", async () => { + await render(); + expect(host.textContent).toContain("部分被忽略的目录"); + expect(readDirectoryMarks(project)).toEqual({}); + expect(window.localStorage.getItem(indexing.indexingSettingsStorageKey(project))).toBeNull(); + await act(async () => button("查看目录").click()); + const choices = document.querySelectorAll('[role="checkbox"]'); + await act(async () => choices[1]!.click()); + await act(async () => button("排除所选目录").click()); + expect(readDirectoryMarks(project)).toEqual({ archive: "excluded" }); + expect(indexing.readIndexingSettings(project).includedDirectories).toEqual([]); +}); + +test("dismissal survives remount and normal files never query directories", async () => { + await render(`${project}/readme.md`); + expect(query).not.toHaveBeenCalled(); + await render(); + await act(async () => button("不再显示").click()); + const calls = query.mock.calls.length; + await render(`${project}/readme.md`); + await render(); + expect(query).toHaveBeenCalledTimes(calls); + expect(host.textContent).toBe(""); + expect(indexing.readIndexingSettings(project).ignoredDirectoryNoticeDismissed).toBe(true); + expect(indexing.readIndexingSettings("C:/other").ignoredDirectoryNoticeDismissed).not.toBe(true); +}); + +test("late inspection cannot show another file's banner", async () => { + let finish!: (value: indexing.IgnoredDirectorySuggestions) => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + query.mockReturnValue(pending); + try { + await render(); + await render(`${project}/readme.md`); + await act(async () => + finish({ directories: ["stale"], incomplete: false, gitIgnoreFailed: false }), + ); + expect(host.textContent).toBe(""); + expect(readDirectoryMarks(project)).toEqual({}); + } finally { + finish({ directories: [], incomplete: false, gitIgnoreFailed: false }); + } +}); diff --git a/windows/tauri/src/features/editor/components/ignored-directory-notice.tsx b/windows/tauri/src/features/editor/components/ignored-directory-notice.tsx new file mode 100644 index 0000000000..d06a228a93 --- /dev/null +++ b/windows/tauri/src/features/editor/components/ignored-directory-notice.tsx @@ -0,0 +1,246 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useEffect, useRef, useState } from "react"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { getNativeWorkspaceRootPaths } from "@/features/file-search/utils/file-search-paths"; +import { closestWorkspaceRoot } from "@/features/workspace/types/workspace-root"; +import { useWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { markWorkspaceDirectory } from "@/features/file-explorer/stores/directory-marks"; +import { + queryIgnoredDirectorySuggestions, + readIndexingSettings, + writeIndexingSettings, + useIndexingVersion, + type IgnoredDirectorySuggestions, +} from "@/features/file-explorer/stores/workspace-indexing"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Alert, AlertDescription } from "@/ui/alert"; +import { Button } from "@/ui/button"; +import Dialog from "@/ui/dialog"; +import { Checkbox } from "@/ui/checkbox"; + +function DirectoryChoices({ + paths, + selected, + onChange, +}: { + paths: string[]; + selected: Set; + onChange: (paths: Set) => void; +}) { + const viewport = useRef(null); + const virtualizer = useVirtualizer({ + count: paths.length, + getScrollElement: () => viewport.current, + estimateSize: () => 28, + overscan: 5, + getItemKey: (index) => paths[index], + }); + return ( +
+
+ {virtualizer.getVirtualItems().map((row) => ( +
+ { + const next = new Set(selected); + if (checked) next.add(paths[row.index]); + else next.delete(paths[row.index]); + onChange(next); + }} + /> + + {paths[row.index]} + +
+ ))} +
+
+ ); +} + +/** The editor inspects on open; only the explicit Exclude action writes project preferences. */ +export function IgnoredDirectoryNotice({ filePath }: { filePath?: string | null }) { + const { t } = useTranslation(); + const workspaceId = useWorkspaceId(); + const primaryRoot = useFileSystemStore((state) => state.rootFolderPath); + const folders = useFileSystemStore((state) => state.workspaceFolders); + const roots = getNativeWorkspaceRootPaths(primaryRoot, folders); + const rootsKey = JSON.stringify(roots); + const root = filePath ? closestWorkspaceRoot(filePath, roots) : undefined; + const isIgnoreFile = filePath?.replace(/\\/g, "/").split("/").pop() === ".gitignore"; + const version = useIndexingVersion(root ? [root] : []); + const [retry, setRetry] = useState(0); + const [result, setResult] = useState(null); + const [failed, setFailed] = useState(false); + const [open, setOpen] = useState(false); + const [applying, setApplying] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const identity = `${workspaceId}\0${root}\0${filePath}\0${rootsKey}`; + const currentIdentity = useRef(identity); + currentIdentity.current = identity; + + useEffect(() => { + let current = true; + setResult(null); + setFailed(false); + setOpen(false); + setApplying(false); + if (!root || !isIgnoreFile) return; + void (async () => { + try { + if (readIndexingSettings(root).ignoredDirectoryNoticeDismissed) return; + const suggestions = await queryIgnoredDirectorySuggestions( + root, + workspaceId, + JSON.parse(rootsKey), + ); + if (current) setResult(suggestions); + } catch { + if (current) setFailed(true); + } + })(); + return () => { + current = false; + }; + }, [root, rootsKey, workspaceId, filePath, isIgnoreFile, version, retry]); + + if ( + !root || + !isIgnoreFile || + (!failed && !result?.directories.length && !result?.incomplete && !result?.gitIgnoreFailed) + ) + return null; + const partial = result?.incomplete || result?.gitIgnoreFailed; + const exclude = async () => { + const captured = identity; + setApplying(true); + try { + const fresh = await queryIgnoredDirectorySuggestions(root, workspaceId, roots); + if ( + currentIdentity.current !== captured || + !workspaceRuntimeRegistry.isWorkspaceReady(workspaceId) + ) + return; + for (const path of fresh.directories) { + if (selected.has(path)) + markWorkspaceDirectory([root], `${root}/${path}`, { kind: "exclude" }); + } + setOpen(false); + setRetry((value) => value + 1); + } catch { + if (currentIdentity.current === captured) setFailed(true); + } finally { + if (currentIdentity.current === captured) setApplying(false); + } + }; + + return ( + <> + + + + {t( + failed + ? "indexing.noticeFailed" + : result?.directories.length + ? "indexing.notice" + : "indexing.noticeIncomplete", + )} + + {failed ? ( + + ) : ( + <> + {!!result?.directories.length && ( + + )} + + + ⓘ + + + )} + + + {open && result && ( + { + if (!applying) setOpen(false); + }} + footer={ + <> + + + + } + > + {partial && ( + + {t("indexing.noticeIncomplete")} + + )} + {failed && ( + + {t("indexing.writeFailed")} + + )} +
+ +
+
+ )} + + ); +} diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-tree-item.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-tree-item.tsx index 24dbec3a8c..0276fbfdf5 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-tree-item.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-tree-item.tsx @@ -55,6 +55,7 @@ interface FileExplorerTreeItemProps { semanticKind?: FileIconSemanticKind; directoryMark?: DirectoryMark; exclusionReason?: string; + isManuallyExcluded?: boolean; isCut: boolean; isDragOver: boolean; isDragging: boolean; @@ -104,6 +105,7 @@ function FileExplorerTreeItemComponent({ semanticKind, directoryMark, exclusionReason, + isManuallyExcluded, isCut, isDragOver, isDragging, @@ -222,6 +224,7 @@ function FileExplorerTreeItemComponent({ data-is-dir={file.isDir} data-path={file.path} data-root={isRoot ? "true" : undefined} + data-excluded={isManuallyExcluded ? "true" : undefined} title={ file.isSymlink && file.symlinkTarget ? t("files.symlinkTo", { target: file.symlinkTarget }) @@ -235,7 +238,6 @@ function FileExplorerTreeItemComponent({ "min-w-max", isDragOver && "border-2! border-dashed! border-primary! bg-primary! bg-opacity-20!", isDragging && "cursor-move", - (file.ignored || directoryMark === "excluded") && "opacity-50", directoryMark === "module" && "font-semibold", isCut && "italic opacity-40", )} @@ -256,6 +258,7 @@ function FileExplorerTreeItemComponent({ className={cn( "file-tree-node-label select-none whitespace-nowrap", gitStatusDecoration?.colorClassName, + file.ignored && !gitStatusDecoration && "file-tree-ignored-label", )} > {renderHighlightedLabel(displayName ?? file.name, searchQuery)} @@ -283,6 +286,7 @@ export const FileExplorerTreeItem = memo( prev.semanticKind === next.semanticKind && prev.directoryMark === next.directoryMark && prev.exclusionReason === next.exclusionReason && + prev.isManuallyExcluded === next.isManuallyExcluded && prev.isCut === next.isCut && prev.isDragOver === next.isDragOver && prev.isDragging === next.isDragging && diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx index 8d73957b17..fb6084b009 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx @@ -256,6 +256,17 @@ function FileExplorerTreeComponent({ : undefined; }; const isIndexExcluded = (path: string) => exclusionReason(path) !== undefined; + const isManuallyExcluded = (path: string): boolean => { + const root = Object.keys(indexingStatuses) + .filter((root) => pathStartsWithRoot(path, root)) + .sort((a, b) => b.length - a.length)[0]; + if (!root) return false; + const relative = getRelativePath(path, root).replace(/\\/g, "/") || "."; + return Object.entries(indexingStatuses[root].excludedDirectories).some( + ([directory, reason]) => reason === "manual" && + (directory === "." || relative === directory || relative.startsWith(directory + "/")), + ); + }; const [deleteCandidate, setDeleteCandidate] = useState<{ path: string; isDir: boolean; @@ -1873,6 +1884,7 @@ function FileExplorerTreeComponent({ : highlightedPath === row.file.path } exclusionReason={exclusionReason(row.file.path)} + isManuallyExcluded={isManuallyExcluded(row.file.path)} directoryMark={directoryMark} semanticKind={ markedRole === "sources" diff --git a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts index 540fe7c46f..d74c9f1910 100644 --- a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts +++ b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts @@ -13,6 +13,7 @@ export interface IndexingSettings { gitIgnoreEnabled: boolean; includedDirectories: string[]; previousRoles: Record; + ignoredDirectoryNoticeDismissed?: boolean; } export interface IndexingStatus { revision: number; @@ -48,6 +49,7 @@ export function readIndexingSettings(root: string): IndexingSettings { typeof settings.gitIgnoreEnabled !== "boolean" || !Array.isArray(settings.includedDirectories) || !settings.includedDirectories.every((path: unknown) => typeof path === "string") || + (settings.ignoredDirectoryNoticeDismissed !== undefined && typeof settings.ignoredDirectoryNoticeDismissed !== "boolean") || (settings.previousRoles !== undefined && (!settings.previousRoles || typeof settings.previousRoles !== "object" || @@ -59,6 +61,8 @@ export function readIndexingSettings(root: string): IndexingSettings { gitIgnoreEnabled: settings.gitIgnoreEnabled, includedDirectories: settings.includedDirectories, previousRoles: (settings.previousRoles ?? {}) as Record, + ...(settings.ignoredDirectoryNoticeDismissed !== undefined + ? { ignoredDirectoryNoticeDismissed: settings.ignoredDirectoryNoticeDismissed as boolean } : {}), }; } export function writeIndexingSettings(root: string, settings: IndexingSettings) { @@ -270,3 +274,19 @@ export function useIndexingVersion(roots?: readonly string[]): number { useEffect(() => subscribeToIndexingChanges(rootsKey === undefined ? undefined : JSON.parse(rootsKey), () => setVersion((value) => value + 1)), [rootsKey]); return version; } + +export interface IgnoredDirectorySuggestions { + directories: string[]; + incomplete: boolean; + gitIgnoreFailed: boolean; +} + +export async function queryIgnoredDirectorySuggestions(root: string, workspaceId: string, workspaceRoots: readonly string[] = [root]): Promise { + await configureWorkspaceIndexing(root, [], false, workspaceId); + const response = await executeCore({ + id: crypto.randomUUID(), command: "workspace.ignoredDirectorySuggestions", timeoutMilliseconds: 10000, + payload: { root, workspaceRoots }, + }); + if (!response.ok) throw new IndexingSettingsError("configureFailed", response.error); + return response.data; +} diff --git a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css index 52b1745ef0..0fd49d835f 100644 --- a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css +++ b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css @@ -66,6 +66,16 @@ background-color: transparent !important; } +/* Community 536da82a: NonProjectFilesScope uses FileColor.Yellow (Brown in Darcula). */ +.file-tree-container .file-tree-item > button[data-excluded="true"]:not(.bg-selected), +.file-tree-container .file-tree-item > button[data-excluded="true"]:not(.bg-selected):hover { + background-color: light-dark(#ffffe4, #4f4b41) !important; +} + +.file-tree-ignored-label { + color: light-dark(#727238, #908b25); +} + .file-tree-container .file-tree-item > button.bg-selected, .file-tree-container .file-tree-item > button.bg-selected:hover { border-color: transparent !important; diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index 0aa04f7a11..17a566b010 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -3006,18 +3006,26 @@ export const sharedCatalogs = { "indexing.cancelExclusion": "Cancel Exclusion", "indexing.configureFailed": "Could not apply project indexing exclusions. Try reopening the project.", "indexing.description": "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nNative exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee.", + "indexing.dismissNotice": "Don't Show Again", + "indexing.excludeSelected": "Exclude Selected", "indexing.gitFailed": "Git ignore exclusions could not be fully resolved. Manual exclusions remain active.", "indexing.gitReason": "Excluded from indexing by Git ignore rules", + "indexing.ignoredDirectories": "Ignored Directories", "indexing.incomplete": "Some directories could not be scanned. Automatic project analysis was not refreshed.", "indexing.inheritedReason": "Excluded from indexing by a parent directory", "indexing.invalidSettings": "Project indexing settings are invalid. The saved data has been preserved.", "indexing.languageDescription": "Native exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee.", "indexing.manualReason": "Manually excluded from indexing", + "indexing.notice": "Some ignored directories are still included in analysis and search.", + "indexing.noticeFailed": "Could not check ignored directories.", + "indexing.noticeHelp": "Excluding directories removes them from Lithe analysis and search without changing Git commit candidates. Files remain available in the project tree. Language servers may still scan build-declared modules.", + "indexing.noticeIncomplete": "Some directories could not be checked. The list may be incomplete.", "indexing.permissionDenied": "Lithe cannot access the project folder. Check its permissions, then retry.", "indexing.providerDependent": "Provider-dependent", "indexing.readFailed": "Could not read project indexing settings. Try reopening the project.", "indexing.remoteUnsupported": "Directory indexing exclusions are not supported for remote projects.", "indexing.title": "Project Indexing", + "indexing.viewDirectories": "View Directories", "indexing.workspaceUnavailable": "The project folder is unavailable. Restore it, then retry.", "indexing.writeFailed": "Could not save project indexing settings. Your previous settings remain unchanged.", "inlineEdit.addApiKey": "Add API key", @@ -8035,18 +8043,26 @@ export const sharedCatalogs = { "indexing.cancelExclusion": "取消排除", "indexing.configureFailed": "无法应用项目索引排除策略,请重新打开项目后重试。", "indexing.description": "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。", + "indexing.dismissNotice": "不再显示", + "indexing.excludeSelected": "排除所选目录", "indexing.gitFailed": "无法完整解析 Git 忽略规则,手动排除仍然生效。", "indexing.gitReason": "根据 Git 忽略规则排除索引", + "indexing.ignoredDirectories": "被忽略的目录", "indexing.incomplete": "部分目录无法扫描,未刷新项目自动分析。", "indexing.inheritedReason": "继承父目录的索引排除", "indexing.invalidSettings": "项目索引设置格式无效,原始设置已保留。", "indexing.languageDescription": "已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。", "indexing.manualReason": "手动排除索引", + "indexing.notice": "部分被忽略的目录未从分析和搜索中排除。", + "indexing.noticeFailed": "无法检查被忽略的目录。", + "indexing.noticeHelp": "排除目录后,Lithe 不再分析和搜索其中的文件,但不会改变 Git 提交候选,项目树仍可浏览文件。语言服务器仍可能扫描构建系统声明的模块。", + "indexing.noticeIncomplete": "部分目录无法检查,列表可能不完整。", "indexing.permissionDenied": "Lithe 无法访问项目目录。请检查权限后重试。", "indexing.providerDependent": "取决于语言服务", "indexing.readFailed": "无法读取项目索引设置,请重新打开项目后重试。", "indexing.remoteUnsupported": "远程项目暂不支持目录索引排除。", "indexing.title": "项目索引", + "indexing.viewDirectories": "查看目录", "indexing.workspaceUnavailable": "项目目录不可用。请恢复目录后重试。", "indexing.writeFailed": "无法保存项目索引设置,原有设置未改变。", "inlineEdit.addApiKey": "添加 API 密钥", From 2ad1adec562cceeba9e3c191fb0d6b35889f9b02 Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Mon, 2 Nov 2026 19:26:00 +0800 Subject: [PATCH 02/10] fix(ui): align excluded project rows with Islands colors Use the Islands FileColor.Yellow theme overrides for excluded rows on macOS and Windows: #FFF9EB in light mode and #291F1C in dark mode. Preserve the existing priority of selected row backgrounds. Update the rendered row expectations, upstream theme evidence in the workspace exclusion decision note, and the platform feature matrix. Validation: 16 related Swift rendering/tree interaction tests passed; Release build and isolated static gates passed. Installed Lithe-Dev visually confirms matching folder/descendant colors in Lithe-test; signature and bundle immutability checks passed after launch. Windows native and macOS dark appearance acceptance remain pending. --- .../2026-10-28-workspace-indexing-exclusions.md | 15 +++++++++++---- macos/Sources/Lithe/Theme/LitheTheme.swift | 4 ++-- .../Tests/LitheTests/LitheTreeRowStyleTests.swift | 2 +- .../features/workspace-indexing-exclusions.json | 3 ++- .../file-explorer/styles/file-explorer-tree.css | 4 ++-- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md index f10bc71cc4..c0b0e6983e 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md +++ b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md @@ -439,10 +439,17 @@ macOS 使用最新构建的可执行及 Swift 资源,组装独立 QA bundle, 虽然搜索库存和 provider 仍正确。configure 与 refresh 的状态汇总现在也受自动开关约束; 回归断言候选查询后的重复 configure 不改变有效原因、库存或租约版本。 -显示沿用 JetBrains 的 Non-Project Files 行底色:浅色 `#FFFFE4`,深色 `#4F4B41`; -Git ignored 仍仅修改文件名颜色。选中行先保证既有选中色和文字对比度。出处为 -JetBrains/intellij-community 的 `536da82a2cbce90820cbb0c158d91e78cda75d82` 中 -`NonProjectFilesScope.java` 与 `FileColorManagerImpl.java`。本次没有新增运行时资源目录; +显示沿用 JetBrains Islands 的 Non-Project Files 行底色:浅色 `#FFF9EB`,深色 `#291F1C`; +Git ignored 仍仅修改文件名颜色。选中行先保证既有选中色和文字对比度。出处固定为 +JetBrains/intellij-community 的 `536da82a2cbce90820cbb0c158d91e78cda75d82`: +`NonProjectFilesScope.java` 选择 `FileColor.Yellow`,而 Islands 的 +[`ManyIslandsLight.theme.json`](https://github.com/JetBrains/intellij-community/blob/536da82a2cbce90820cbb0c158d91e78cda75d82/platform/platform-resources/src/themes/islands/ManyIslandsLight.theme.json#L713-L718) +和 [`ManyIslandsDark.theme.json`](https://github.com/JetBrains/intellij-community/blob/536da82a2cbce90820cbb0c158d91e78cda75d82/platform/platform-resources/src/themes/islands/ManyIslandsDark.theme.json#L665-L670) +覆盖该颜色。首次实现误用了 `FileColorManagerImpl.java` 的旧主题回退值 +`#FFFFE4`/`#4F4B41`,导致浅色偏黄;不能只读取作用域默认值而忽略目标主题覆盖。 +用户的 PyCharm 浅色截图背景像素为 `#FFF9EB`,与 Islands 源码一致;两端同步采用这组值, +原有深浅色、选中/未选中及窗口焦点渲染回归验证实际行底色,保持选择色优先。 +本次没有新增运行时资源目录; 查询只读取工作区与 Git,偏好沿用平台存储 adapter,不写发行 bundle,因此不改变签名或 delta 基线。 原生验收补充:macOS 提示必须保留空态也存在的容器,不能把首次查询的 task 挂在 diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index db8c9f48c5..5cdb932feb 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -485,8 +485,8 @@ enum LitheTheme { static let verticalInset: CGFloat = 4 static var text: Color { searchFieldText } static var secondaryText: Color { searchFieldPlaceholder } - // IntelliJ Community 536da82a: NonProjectFilesScope -> FileColor.Yellow. - static var excludedBackground: Color { controlColor(light: 0xFFFFE4, dark: 0x4F4B41) } + // Community 536da82a: Islands overrides NonProjectFilesScope's FileColor.Yellow. + static var excludedBackground: Color { controlColor(light: 0xFFF9EB, dark: 0x291F1C) } static var focusedSelection: Color { activeTheme == .lithe ? controlColor(light: 0xD0DFFE, dark: 0x2A4371) : selection } diff --git a/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift b/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift index e714952f40..301c92794d 100644 --- a/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift +++ b/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift @@ -40,7 +40,7 @@ struct LitheTreeRowStyleTests { context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) } let active = focused && windowState == .key - let expected: UInt32 = !selected ? (scheme == .dark ? 0x4F4B41 : 0xFFFFE4) : scheme == .dark + let expected: UInt32 = !selected ? (scheme == .dark ? 0x291F1C : 0xFFF9EB) : scheme == .dark ? (active ? 0x2A4371 : 0x33353B) : (active ? 0xD0DFFE : 0xE9EAEE) // Sample the row interior beyond text, so actual shared chrome rather diff --git a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json index 157791c097..20cce7e79e 100644 --- a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json +++ b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json @@ -65,5 +65,6 @@ "windows/tauri/src/features/git/runtime/git-status-refresh-host.tsx", "shared/fixtures/workspace/ignored-directory-suggestions-v1.json" ] - } + }, + "notes": "排除目录及后代的行底色采用 JetBrains Islands FileColor.Yellow:浅色 #FFF9EB、深色 #291F1C,覆盖旧主题回退色;选择色仍优先。上游主题来源和浅色截图像素核验见目录索引排除 Agent Note;Swift 渲染回归覆盖两种外观与焦点状态,Windows 原生外观验收仍待完成。" } diff --git a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css index 0fd49d835f..3cc8a41284 100644 --- a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css +++ b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css @@ -66,10 +66,10 @@ background-color: transparent !important; } -/* Community 536da82a: NonProjectFilesScope uses FileColor.Yellow (Brown in Darcula). */ +/* Community 536da82a: Islands overrides NonProjectFilesScope's FileColor.Yellow. */ .file-tree-container .file-tree-item > button[data-excluded="true"]:not(.bg-selected), .file-tree-container .file-tree-item > button[data-excluded="true"]:not(.bg-selected):hover { - background-color: light-dark(#ffffe4, #4f4b41) !important; + background-color: light-dark(#fff9eb, #291f1c) !important; } .file-tree-ignored-label { From c9af947fc8bc78ba4302943e67a857e8e87f8cff Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Mon, 2 Nov 2026 20:36:07 +0800 Subject: [PATCH 03/10] fix(workspace): refresh notice generations and save selections atomically --- ...026-10-28-workspace-indexing-exclusions.md | 7 +- .../Application/WorkspaceFeatureModel.swift | 26 +++++++- .../LazyProjectTreeInventoryTests.swift | 49 +++++++++++++- .../ignored-directory-notice.test.tsx | 36 +++++++++++ .../components/ignored-directory-notice.tsx | 8 +-- .../file-explorer/stores/directory-marks.ts | 64 +++++++++++++++---- 6 files changed, 167 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md index c0b0e6983e..74115d2415 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md +++ b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md @@ -459,9 +459,14 @@ JetBrains/intellij-community 的 `536da82a2cbce90820cbb0c158d91e78cda75d82`: 现在按当前目录的原生 Git 批量返回 ignoredPaths;未知结果省略,Swift 只给名称着色。 该状态随懒加载、包压缩和可见行投影传递,不使用分析排除原因,也不影响 Git 提交候选。 忽略规则变更但库存文件未变时仍发布新的树装饰,保留库存 UUID,避免重新启动项目服务。 -自动分析排除关闭时,Git 元数据事件只重列已加载层;遇到并发文件事件或目录展开时重试, +自动分析排除关闭时,Git 元数据事件先刷新既有 owner 的策略代际,再重列已加载层, +不重建未变化的文件库存。提示条依赖该代际重新查询,避免索引或 `.git/info/exclude` +变化后仍把已跟踪目录列为候选;刷新结果须保留工作区、owner 与策略身份检查。 +遇到并发文件事件或目录展开时重试, 不覆盖后来加载的子节点。新文件先提交库存,再仅查询其可见父目录以补齐 Git 装饰, 不会为每个新文件重新扫描整个工作区;门控回归覆盖在途取消及连续创建。 +Windows 多选排除先在内存准备完整标记和设置,全部写入成功后再广播;任何写入失败 +恢复整组原值,保留选择弹窗,不把一次确认拆成多次已经生效的单目录操作。 PyCharm 2026.2.3 的同项目原生对照确认:手动排除后,普通未跟踪文件及目录仍出现在提交面板, 显式刷新和重新打开项目后结果相同。保持父 `.gitignore` 的 `projects/ShipFlow/` 不变, diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index a20dd38e35..0024715f77 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -1611,11 +1611,35 @@ package final class WorkspaceFeatureModel: ObservableObject { if indexingSettings.gitIgnoreEnabled { await refreshCurrentSnapshot(refreshGitAfterwards: false, indexingOnly: true) } else if let workspaceURL { - await refreshExcludedTree(at: workspaceURL) + await refreshIgnoredDirectoryMetadata(at: workspaceURL) } } } + private func refreshIgnoredDirectoryMetadata(at root: URL) async { + let generation = workspaceGeneration, ownerID = indexingOwnerID + let policy = WorkspaceIndexingPolicy(marks: directoryMarks, settings: indexingSettings) + let isCurrent: @MainActor () -> Bool = { [weak self] in + guard let self else { return false } + return !self.workspaceResourcesReleased && self.workspaceURL == root + && self.workspaceGeneration == generation && self.indexingOwnerID == ownerID + && policy == WorkspaceIndexingPolicy(marks: self.directoryMarks, settings: self.indexingSettings) + } + do { + // Inspection uses Git decisions even when automatic exclusion is off. + // Refresh its generation without rebuilding the unchanged inventory. + let status = try await configureCurrentIndexing(at: root, policy: policy, refresh: true, isCurrent: isCurrent) + guard isCurrent(), !Task.isCancelled else { return } + indexingStatus = status + indexingIssue = nil + } catch { + guard isCurrent(), !Task.isCancelled else { return } + if !(error is CancellationError) { recordIndexingFailure(error) } + } + guard isCurrent(), !Task.isCancelled else { return } + await refreshExcludedTree(at: root) + } + private func applyExternalRefresh(_ paths: [String], at workspaceURL: URL) async { guard self.workspaceURL == workspaceURL else { return } let generation = workspaceGeneration diff --git a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift index 0dc945a022..85bd3ea011 100644 --- a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift +++ b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift @@ -34,6 +34,41 @@ struct LazyProjectTreeInventoryTests { #expect(fixture.model.directoryMarks.isEmpty) } + @Test + func metadataRefreshRequeriesMountedIgnoredNoticeWithoutRescanning() async throws { + let root = URL(fileURLWithPath: "/in-memory/ignored-notice-metadata") + let fixture = InventoryFixture(root: root, files: [], rows: [root.path: []]) + defer { fixture.close() } + fixture.disk.setConfigurationRevision(1) + await fixture.load() + await fixture.model.setGitIgnoreIndexing(false) + fixture.disk.setSuggestions(["archive"]) + let host = NSHostingView(rootView: IgnoredDirectoryNotice( + feature: fixture.model, fileURL: root.appendingPathComponent(".gitignore"))) + host.sizingOptions = [] + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 700, height: 80), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { window.contentView = nil; window.close() } + window.orderFront(nil) + host.layoutSubtreeIfNeeded() + #expect(await fixture.disk.suggestionsRequested.waitUntilOpen()) + let scans = fixture.disk.snapshotCount, publications = fixture.publishedFiles.count + let snapshotID = fixture.model.appliedSnapshot?.id + fixture.disk.setConfigurationRevision(2) + fixture.disk.setSuggestions([]) + let queried = fixture.disk.expectSuggestions() + let source = try #require(fixture.watcher.source) + source.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true, gitMetadataMayHaveChanged: true)) + #expect(await queried.waitUntilOpen()) + #expect(fixture.model.indexingStatus.revision == 2) + #expect(fixture.disk.configurationRefreshes.last == true) + #expect(fixture.disk.snapshotCount == scans) + #expect(fixture.publishedFiles.count == publications) + #expect(fixture.model.appliedSnapshot?.id == snapshotID) + } + @Test func unchangedInventoryStillRefreshesGitIgnoredDecorations() async throws { let root = URL(fileURLWithPath: "/in-memory/ignored-color-rules") @@ -1021,14 +1056,24 @@ private final class InventoryDisk: WorkspaceOperations, WorkspaceFileOperations, private var policy: WorkspaceIndexingPolicy? private var suggestedPaths: [String] = [] let suggestionsRequested = TestGate() + private var nextSuggestionsRequested: TestGate? + func expectSuggestions() -> TestGate { + let gate = TestGate() + lock.withLock { nextSuggestionsRequested = gate } + return gate + } func setSuggestions(_ paths: [String]) { lock.withLock { suggestedPaths = paths } } func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { suggestionsRequested.open() - return lock.withLock { + let (result, gate) = lock.withLock { var result = WorkspaceIgnoredDirectorySuggestions() result.directories = suggestedPaths.filter { !(policy?.manualExcludedDirectories.contains($0) ?? false) } - return result + let gate = nextSuggestionsRequested + nextSuggestionsRequested = nil + return (result, gate) } + gate?.open() + return result } var supportsExclusions = true private var blockConfiguration = false diff --git a/windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx b/windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx index 5e2ef31e89..0d6a68ee15 100644 --- a/windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx +++ b/windows/tauri/src/features/editor/components/ignored-directory-notice.test.tsx @@ -123,6 +123,42 @@ test("dismissal survives remount and normal files never query directories", asyn expect(indexing.readIndexingSettings("C:/other").ignoredDirectoryNoticeDismissed).not.toBe(true); }); +test("failed multi-selection saves nothing, publishes no change and preserves the picker", async () => { + await render(); + await act(async () => button("查看目录").click()); + let publications = 0; + const observe = () => { publications += 1; }; + window.addEventListener(indexing.indexingEvent, observe); + const storage = window.localStorage; + const write = storage.setItem.bind(storage); + const descriptor = Object.getOwnPropertyDescriptor(window, "localStorage"); + let rejected = false; + Object.defineProperty(window, "localStorage", { configurable: true, value: { + getItem: storage.getItem.bind(storage), removeItem: storage.removeItem.bind(storage), + setItem: (key: string, value: string) => { + if (key.includes("directory-marks") && value.includes('"cache":"excluded"')) { + rejected = true; + throw new Error("fixture storage quota exceeded"); + } + write(key, value); + }, + } }); + try { + await act(async () => button("排除所选目录").click()); + expect(rejected).toBe(true); + expect(readDirectoryMarks(project)).toEqual({}); + expect(storage.getItem(indexing.indexingSettingsStorageKey(project))).toBeNull(); + expect(publications).toBe(0); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2); + expect(button("排除所选目录").disabled).toBe(false); + } finally { + if (descriptor) Object.defineProperty(window, "localStorage", descriptor); + else Reflect.deleteProperty(window, "localStorage"); + window.removeEventListener(indexing.indexingEvent, observe); + } +}); + test("late inspection cannot show another file's banner", async () => { let finish!: (value: indexing.IgnoredDirectorySuggestions) => void; const pending = new Promise((resolve) => { diff --git a/windows/tauri/src/features/editor/components/ignored-directory-notice.tsx b/windows/tauri/src/features/editor/components/ignored-directory-notice.tsx index d06a228a93..39e2fa3de0 100644 --- a/windows/tauri/src/features/editor/components/ignored-directory-notice.tsx +++ b/windows/tauri/src/features/editor/components/ignored-directory-notice.tsx @@ -5,7 +5,7 @@ import { getNativeWorkspaceRootPaths } from "@/features/file-search/utils/file-s import { closestWorkspaceRoot } from "@/features/workspace/types/workspace-root"; import { useWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; -import { markWorkspaceDirectory } from "@/features/file-explorer/stores/directory-marks"; +import { markWorkspaceDirectories } from "@/features/file-explorer/stores/directory-marks"; import { queryIgnoredDirectorySuggestions, readIndexingSettings, @@ -128,10 +128,8 @@ export function IgnoredDirectoryNotice({ filePath }: { filePath?: string | null !workspaceRuntimeRegistry.isWorkspaceReady(workspaceId) ) return; - for (const path of fresh.directories) { - if (selected.has(path)) - markWorkspaceDirectory([root], `${root}/${path}`, { kind: "exclude" }); - } + markWorkspaceDirectories([root], fresh.directories + .filter((path) => selected.has(path)).map((path) => `${root}/${path}`), { kind: "exclude" }); setOpen(false); setRetry((value) => value + 1); } catch { diff --git a/windows/tauri/src/features/file-explorer/stores/directory-marks.ts b/windows/tauri/src/features/file-explorer/stores/directory-marks.ts index 405c17bdee..b6bad712c5 100644 --- a/windows/tauri/src/features/file-explorer/stores/directory-marks.ts +++ b/windows/tauri/src/features/file-explorer/stores/directory-marks.ts @@ -135,15 +135,14 @@ export function migrateWorkspaceDirectoryMarks(roots: readonly string[], source: } } -export function markWorkspaceDirectory(roots: readonly string[], path: string, action: DirectoryAction) { - const root = directoryMarkRoot(path, roots); - if (!root) throw new Error("Directory is outside the workspace"); - const marks = readDirectoryMarks(root); +function applyDirectoryMark( + root: string, path: string, action: DirectoryAction, marks: Marks, + settings: ReturnType, +) { const relative = getRelativePath(path, root).replace(/\\/g, "/") || "."; const relativeIdentity = markPathIdentity(root, relative); const samePath = (path: string) => markPathIdentity(root, path) === relativeIdentity; const markKeys = Object.keys(marks).filter(samePath); - const settings = readIndexingSettings(root); const currentKey = markKeys.find((path) => path === relative) ?? markKeys[0]; const current = currentKey === undefined ? undefined : marks[currentKey]; if (action.kind === "cancelExclusion") { @@ -155,7 +154,7 @@ export function markWorkspaceDirectory(roots: readonly string[], path: string, a (path === "." || relativeIdentity.startsWith(markPathIdentity(root, path) + "/")), ) ) - return; + return false; const previousKeys = Object.keys(settings.previousRoles).filter(samePath); const previousKey = previousKeys.find((path) => path === relative) ?? previousKeys[0]; const previous = previousKey === undefined ? undefined : settings.previousRoles[previousKey]; @@ -177,19 +176,56 @@ export function markWorkspaceDirectory(roots: readonly string[], path: string, a (path) => !samePath(path), ); } - let oldSettings: string | null; - try { oldSettings = window.localStorage.getItem(indexingSettingsStorageKey(root)); } + return true; +} + +/** Save the complete selection before publishing any preference change. */ +export function markWorkspaceDirectories(roots: readonly string[], paths: readonly string[], action: DirectoryAction) { + const drafts = new Map; changed: boolean }>(); + for (const path of paths) { + const root = directoryMarkRoot(path, roots); + if (!root) throw new Error("Directory is outside the workspace"); + let draft = drafts.get(root); + if (!draft) { + draft = { marks: readDirectoryMarks(root), settings: readIndexingSettings(root), changed: false }; + drafts.set(root, draft); + } + draft.changed = applyDirectoryMark(root, path, action, draft.marks, draft.settings) || draft.changed; + } + const writes: [string, string][] = []; + const changedRoots: string[] = []; + for (const [root, draft] of drafts) { + if (!draft.changed) continue; + changedRoots.push(root); + writes.push([indexingSettingsStorageKey(root), JSON.stringify(draft.settings)], + [key(root), JSON.stringify({ version: 1, marks: draft.marks })]); + } + let previous: [string, string | null][]; + try { previous = writes.map(([key]) => [key, window.localStorage.getItem(key)]); } catch (cause) { throw new IndexingSettingsError("readFailed", cause); } + let completed = 0; try { - // Publish only after both preference writes succeed. - window.localStorage.setItem(indexingSettingsStorageKey(root), JSON.stringify(settings)); - writeMarks(root, marks); - window.dispatchEvent(new window.CustomEvent(indexingEvent, { detail: root })); + for (const [key, value] of writes) { + window.localStorage.setItem(key, value); + completed += 1; + } } catch (error) { - if (oldSettings === null) window.localStorage.removeItem(indexingSettingsStorageKey(root)); - else window.localStorage.setItem(indexingSettingsStorageKey(root), oldSettings); + // Free all new growth before restoring the previous, quota-safe snapshot. + const saved = previous.slice(0, completed); + for (const [key] of saved) window.localStorage.removeItem(key); + for (const [key, value] of saved) { + if (value !== null) window.localStorage.setItem(key, value); + } throw new IndexingSettingsError("writeFailed", error); } + for (const root of changedRoots) { + window.dispatchEvent(new window.CustomEvent(eventName, { detail: root })); + window.dispatchEvent(new window.CustomEvent(indexingEvent, { detail: root })); + } +} + +export function markWorkspaceDirectory(roots: readonly string[], path: string, action: DirectoryAction) { + markWorkspaceDirectories(roots, [path], action); } export function useDirectoryMarks(roots: readonly string[]) { From 6dee07ecee37e2d391761b49a781926af3b791ed Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Mon, 2 Nov 2026 20:45:38 +0800 Subject: [PATCH 04/10] docs(workspace): record follow-up validation on current preview --- .../features/workspace-indexing-exclusions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json index 20cce7e79e..f7a845fb4b 100644 --- a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json +++ b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json @@ -4,7 +4,7 @@ "group": "工作区生命周期", "capability": "目录索引排除与 Git 自动排除", "owner": "Workspace", - "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。 2026-11-02:新增.gitignore只读候选提示、选择排除和项目级不再显示;修复父忽略嵌套仓库的分析自动排除;忽略仅名称着色,排除整行底色,选择色优先。PyCharm2026.2.3对照后明确分析排除不影响Git提交,撤回此前Git发现/未跟踪候选/汇总过滤,Git请求不携带分析策略。已对照排除后提交项仍在、刷新与重开相同、父.gitignore仍忽略ShipFlow时显式登记它后其独立提交项可见;外层忽略与独立仓库映射分别处理。运行补修源码Core1582、macOS聚焦90项逐项计时通过,含空态提示挂载、规则/新文件颜色刷新、并发创建和展开;Git边界调整后的Bun31及TypeScript检查通过,前端此后未改。旧Core1581/Swift75/Bun57不代替最终源码;最终Lithe-Dev构建2026-11-02T07:41:31Z已替换安装;原生验证ShipFlow自动分析排除、开关及部分手动排除后的真实搜索、提示候选选择与不再显示重启持久化、名称棕色和整行底色、刷新后手动排除及ShipFlow独立Git候选保留。候选查询后的状态缓存泄漏已补修,全量Core1582及最终原生刷新通过;Swift源码此后未变。动态规则改色的界面操作未形成有效证据,以模型回归为依据。1426项bundle内容/模式/链接及签名不变,Dev偏好精确恢复,QA缓存归档,应用与辅助进程清理。固定工具链和Windows原生验收仍pending。", + "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。 2026-11-02:新增.gitignore只读候选提示、选择排除和项目级不再显示;修复父忽略嵌套仓库的分析自动排除;忽略仅名称着色,排除整行底色,选择色优先。PyCharm2026.2.3对照后明确分析排除不影响Git提交,撤回此前Git发现/未跟踪候选/汇总过滤,Git请求不携带分析策略。已对照排除后提交项仍在、刷新与重开相同、父.gitignore仍忽略ShipFlow时显式登记它后其独立提交项可见;外层忽略与独立仓库映射分别处理。运行补修源码Core1582、macOS聚焦90项逐项计时通过,含空态提示挂载、规则/新文件颜色刷新、并发创建和展开;Git边界调整后的Bun31及TypeScript检查通过,前端此后未改。旧Core1581/Swift75/Bun57不代替最终源码;最终Lithe-Dev构建2026-11-02T07:41:31Z已替换安装;原生验证ShipFlow自动分析排除、开关及部分手动排除后的真实搜索、提示候选选择与不再显示重启持久化、名称棕色和整行底色、刷新后手动排除及ShipFlow独立Git候选保留。候选查询后的状态缓存泄漏已补修,全量Core1582及最终原生刷新通过;Swift源码此后未变。动态规则改色的界面操作未形成有效证据,以模型回归为依据。1426项bundle内容/模式/链接及签名不变,Dev偏好精确恢复,QA缓存归档,应用与辅助进程清理。固定工具链和Windows原生验收仍pending。 拆分至 preview 8b1d6669 后,本机补验 Core1583、Swift91(含挂载提示随元数据刷新且不重扫库存)、前端41(含多选存储失败原子回滚)及 TypeScript 通过;共享契约、i18n、边界、稳定性、矩阵与发行目录只读门禁通过。Swift6.4.2/Xcode27.2,固定工具链和 Windows 原生验收仍待完成。", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", From a86910748207d95fdfda92e9c1df0caf30b6b2ca Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Mon, 2 Nov 2026 20:47:36 +0800 Subject: [PATCH 05/10] fix(git): honor exclusions when discovering nested repositories --- ...026-10-28-workspace-indexing-exclusions.md | 18 +- ...-git-repository-discovery-and-selection.md | 41 ++- .../Lithe/Core/Rust/RustCoreBridge.swift | 12 +- .../Lithe/Core/Rust/RustGitOperations.swift | 10 +- .../Models/AppModel/AppModel+GitModule.swift | 8 + .../Application/GitFeatureModel.swift | 63 +++-- .../LitheGitModule/Models/GitModels.swift | 13 + .../LitheGitModule/Services/GitService.swift | 14 + .../Application/WorkspaceFeatureModel.swift | 20 +- .../LitheGitModuleTests/GitModuleTests.swift | 100 +++++++ .../GitStatusObservationTests.swift | 3 + rust/lithe-core/src/git/mod.rs | 247 ++++++++---------- rust/lithe-core/src/git/remote_process.rs | 25 +- .../src/git/repository_discovery.rs | 246 +++++++++++++++++ rust/lithe-core/src/project/indexing.rs | 8 +- .../src/remote/sessions_integration_tests.rs | 56 ++++ rust/lithe-core/src/tests/git.rs | 67 ++--- .../src/tests/git_repository_discovery.rs | 171 +++++++++++- rust/lithe-git-host/src/lib.rs | 24 +- rust/lithe-git-host/tests/process.rs | 41 ++- shared/contracts/rust-core-api.md | 24 +- .../ignored-directory-suggestions-v1.json | 47 +++- .../workspace/repository-visibility-v1.json | 21 +- .../features/git-repository-discovery.json | 10 +- .../src/features/git/api/git-repo-api.test.ts | 54 +++- .../src/features/git/api/git-repo-api.ts | 39 ++- .../src/features/git/components/git-view.tsx | 37 +-- .../src/features/git/events/git-events.ts | 2 + ...e-git-data-controller.integration.test.tsx | 77 +++++- .../git/hooks/use-git-data-controller.ts | 19 +- .../git/runtime/git-metadata-watch-host.tsx | 1 + .../runtime/git-status-refresh-host.test.tsx | 112 ++++++++ .../git/runtime/git-status-refresh-host.tsx | 73 +++++- .../git/stores/git-repository.store.test.ts | 48 +++- .../git/stores/git-repository.store.ts | 98 ++++--- .../src/features/git/stores/git.store.ts | 2 + .../services/workspace-git-bootstrap.test.ts | 23 ++ .../services/workspace-git-bootstrap.ts | 18 +- 38 files changed, 1560 insertions(+), 332 deletions(-) create mode 100644 rust/lithe-core/src/git/repository_discovery.rs diff --git a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md index 74115d2415..9215178cd4 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md +++ b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md @@ -7,7 +7,7 @@ 目录能否在树里浏览,与是否进入 Lithe 自有索引是两种不同的策略。 手动排除及 Git 自动排除由 Rust Core 判断,两端只存储项目偏好并触发刷新。 语言服务拥有自己的扫描器;Java 导入排除不能当作整个 Java 资源扫描已停止的证据。 -目录排除只约束分析和搜索,不改变 Git 仓库发现、提交候选或未跟踪目录汇总。 +手动排除也限制 Git 子仓库自动发现,但不直接过滤已纳入仓库的提交候选或未跟踪目录汇总。 ## 问题 @@ -425,10 +425,11 @@ macOS 使用最新构建的可执行及 Swift 资源,组装独立 QA bundle, 自动排除以包含目录的 Git 仓库为准,不再因子目录带 `.git` 目录或 gitfile 而豁免。 父仓库的原生 `check-ignore` 判定与批量 `ls-files` 共同保护已跟踪后代和 submodule gitlink; 独立打开的嵌套根仍使用自己的策略。复现夹具覆盖自动开关、显式包含、手动排除和 gitfile。 -目录索引策略不参与 Git 仓库发现、提交状态或未跟踪目录展开;不向 Git 请求传递分析策略。 +Git 仓库发现独立接收手动排除快照,并通过原生 Git 判断忽略目录;不读取分析策略注册表, +分析开关和包含例外不影响 Git 根。提交状态与未跟踪目录展开不直接使用任何分析过滤器。 例如手动排除 `cache` 后,它不进入分析和搜索,但其已跟踪改动和未被 Git 忽略的新文件仍能提交。 不要用目录排除隐藏提交候选或改变汇总计数。Git 忽略仍由各仓库自己的原生 Git 状态决定; -父仓库忽略嵌套目录不等于禁用里面的独立仓库。 +父仓库忽略嵌套目录会阻止自动纳入;显式打开其中独立仓库时仍可管理。 打开 `.gitignore` 的横条调用只读 `workspace.ignoredDirectorySuggestions`。查询限目录、条目总数和深度, 按兄弟目录批量调用 Git,不读取文件正文、不跟随符号链接,也不展开已经发现的候选。 @@ -494,6 +495,17 @@ ShipFlow 的已跟踪修改、未跟踪文件均仍存在。最终截图可区 相同的两个平台专有 API 错误。本次本地 release 构建使用隔离安装的官方 Rust 1.95.0; 关闭本机符号裁剪以绕过 macOS 27 的 LINKEDIT 对齐加载问题,不改变仓库发布配置。 +## 2026-11-02:Git 自动发现边界的后续调整 + +同一测试目录在 Lithe-Dev 自动纳入了 `test/test2`,PyCharm 则排除了其父目录 `test`。 +后续修复让手动排除与 Git 忽略限制子仓库自动发现,详细依据和例外见 +[Git 仓库发现与切换边界](../bug-fix/2026-10-04-git-repository-discovery-and-selection.md)。 +这取代了此前“分析排除不影响仓库自动发现”的决定,但保留 Git 文件状态独立: +手动排除父仓库的普通目录后,里面的已跟踪改动与未被 Git 忽略的新文件仍可提交。 +前文中 manual-repo、ShipFlow 的旧原生验收属于当时构建的证据,不能当作这次自动发现规则的验收。 +新的发现请求只携带已保存的手动标记及显式根集合,不携带自动分析结果或包含例外。 +因此关闭自动分析排除不会自动登记一个被 Git 忽略的 checkout,显式打开它才建立独立管理范围。 + ## 适用范围 - `rust/lithe-core/src/project/indexing.rs` diff --git a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md index d903e2ad33..283e3ed7cb 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md +++ b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md @@ -5,7 +5,7 @@ ## 先说结论 工作区的 Git 列表不应自动纳入 `.build/checkouts` 等缓存中的依赖仓库。 -自动发现默认跳过隐藏目录和内置构建目录,但保留工作树容器及用户显式打开的目录。 +自动发现跳过隐藏、构建、被 Git 忽略及手动排除的目录;显式打开的根独立管理。 切换仓库时,历史请求、分支选择和提交选择必须一起切换,不能把旧仓库的引用传给新仓库。 ## 问题 @@ -25,8 +25,20 @@ macOS 点击另一仓库的分支时,先刷新目标仓库,再设置目标 并在容器内部照常跳过构建目录。这里只决定扫描范围,不根据目录名判断是否真是链接工作树。 - 过滤仅作用于递归子目录。直接打开 `.build/checkouts/SwiftTerm` 时仍然管理这个仓库; 打开其源码子目录时也保留 Git 报告的所属仓库。普通 `vendor` 下的源码仓库仍可发现。 -- Git 忽略规则决定文件是否由父仓库跟踪,不决定是否纳入另一个独立仓库。文件树的用户 - 可见性覆盖也不隐式改变 Git 管理范围。本次只统一内置排除规则,不增加持久化设置。 +- 2026-11-02 起,进入子目录前通过原生 Git 批量查询忽略规则,并应用请求携带的手动排除。 + 这是子仓库的自动纳入边界,不是已有仓库的文件过滤器;父仓库已跟踪修改和普通未跟踪文件 + 仍由 Git 状态决定。分析开关、包含例外和树可见性设置不登记 Git 根。 +- 显式工作区根分别传入自己的手动标记和根集合,父扫描不进入另行打开的嵌套根。 + 工作树容器只豁免隐藏名称规则,不能豁免 Git 或手动排除。索引登记的 gitlink(父仓库 + 保存的子模块提交引用)保留依赖发现;普通嵌套 `.git` 不享有这个例外。 +- 忽略语法交给 Git 的批量 `check-ignore`,沿用 `ls-files` 对已跟踪后代的保护。 + Git 2.31 没有按模式筛选 gitlink 的查询开关,因此通过原有本机/远程进程所有者流式 + 读取 NUL 分隔的索引记录,只保留子模块路径,不捕获整个大索引。取消、错误与进程树 + 清理仍由同一所有者负责,标准错误保持有界捕获;临时记录只活到本次查询结束。 + 查询失败传播原始错误分类,不能当作无忽略规则或空根列表。两端保留最后成功快照。 +- macOS 手动标记保存后触发 Git 重发现;Windows 的发现缓存包含各根的排除快照。 + 元数据、忽略文件、标记、根集合和焦点补偿触发更新;快照身份及请求代次共同拒绝旧结果。 + 自动根被移除时回到有效活动根,清理旧引用与历史请求,不增加持久化 VCS 映射系统。 - macOS 在活动仓库真正变化时,先取消旧历史请求并用旧根目录关闭游标,再清理旧引用、 提交、文件详情、比较和过滤结果。目标仓库历史读取失败时,不能继续展示原仓库可操作的提交。 - 跨仓库选分支使用一次 `selectRepository(root, reference:)`。普通仓库切换回到目标的 @@ -39,10 +51,18 @@ macOS 点击另一仓库的分支时,先刷新目标仓库,再设置目标 - 继续全量扫描、仅隐藏 UI 行:后台仍读取依赖状态,提交范围和扫描成本没有修复。 - 无例外地跳过所有隐藏目录:会丢失 `.worktrees` 中用户主动创建的工作树。 -- 完全按 `.gitignore` 排除仓库:用户常用它忽略工作树容器或独立源码 checkout,不能等同于 - 仓库管理设置。这里复用已有扫描和 Git 所属根查询,没有重新实现 Git 忽略语义。 +- 让被忽略的普通 checkout 永远自动纳入:旧策略保留了工作树容器,却让被排除的依赖仓库 + 继续出现在提交面板。现在只保留显式打开和真实子模块依赖的例外;用户仍能主动管理该仓库。 +- 直接用分析策略过滤 Git 状态:会隐藏已跟踪改动,而且分析开关和包含例外会暗中改变提交 + 范围。只共享已保存的手动目录标记,Git 发现独立查询原生忽略,不读取全局分析策略。 - 切换后遇到无效分支再回退:错误请求和控制台噪音已经发生,且旧提交状态仍可能留在界面。 +上游依据固定为 JetBrains/intellij-community 的 +[`536da82a2cbce90820cbb0c158d91e78cda75d82`](https://github.com/JetBrains/intellij-community/commit/536da82a2cbce90820cbb0c158d91e78cda75d82): +`VcsRootScanner` 通过项目内容根策略跳过排除目录,`VcsRootErrorsHandler` 自动注册时分别过滤 +忽略和排除路径,`VcsRootDetectorTest` 的排除目录用例独立于 Git 忽略文件。 +上游已注册映射及独立内容根有例外;本次只对齐自动发现边界,不声称实现完整映射管理。 + ## 后果 两端默认仓库列表不再包含隐藏缓存及常见构建产物,扫描也能在这些目录入口停止。 @@ -55,6 +75,15 @@ macOS 点击另一仓库的分支时,先刷新目标仓库,再设置目标 覆盖共享可见性 fixture、隐藏根目录、所属父仓库、真实链接工作树、扫描深度和符号链接。 - `macos/Tests/LitheGitModuleTests/GitModuleTests.swift` 覆盖普通切仓库、直接选目标分支、 旧游标归属及新历史读取失败后的旧状态清理。 +- 2026-11-02 本地验证通过 Core 1,588 项、Swift 相关组合 284 项、Windows 前端 84 项, + 另有 Git 进程生命周期 9 项和 Swift 桥接 9 项。前端回归包含失效缓存、迟到结果、 + 防抖期间切换仓库、发现失败后的初始加载结束和最后成功快照保留。 +- 隔离 macOS QA 项目已验收提交、分支和日志:Git 忽略及已保存手动标记移除普通 + 子仓库根,父仓库已跟踪修改保留,显式打开被忽略子仓库仍可管理。手动标记通过隔离 + 偏好域预置,未完成菜单操作的自动化验收。QA 实例已关闭,原有应用实例保持; + bundle 的 1,434 项文件/链接及 Git 索引未变化。 +- 本地 Swift 使用 6.4.2/Xcode 27.2,固定 6.3.3/Xcode 26.6 及 Windows 原生验收 + 尚未完成;macOS 上 Windows Rust 检查仍只有基线已有的平台 API 编译错误。 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` - `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` - `./scripts/architecture/verify-shared-contracts.sh` @@ -62,7 +91,7 @@ macOS 点击另一仓库的分支时,先刷新目标仓库,再设置目标 ## 适用范围 -- `rust/lithe-core/src/git/mod.rs` +- `rust/lithe-core/src/git/repository_discovery.rs` - `rust/lithe-core/src/project/files.rs` - `macos/Sources/LitheGitModule/Application/GitFeatureModel.swift` - `macos/Sources/Lithe/Views/Git/GitLogView.swift` diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 269d499360..3b1e707591 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2366,6 +2366,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { private struct WorkspaceRepositoriesRequest: Encodable { let root: String + let manualExcludedDirectories: [String] + let workspaceRoots: [String] } private struct GitWatchContextRequest: Encodable { @@ -3288,11 +3290,13 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } - func workspaceRepositories(at rootURL: URL) -> WorkspaceRepositoriesPayload? { - execute( + func workspaceRepositories(at rootURL: URL, manualExcludedDirectories: [String] = [], workspaceRoots: [URL] = []) throws -> WorkspaceRepositoriesPayload { + try executeResult( command: "workspace.repositories", - payload: WorkspaceRepositoriesRequest(root: rootURL.standardizedFileURL.path) - ) + payload: WorkspaceRepositoriesRequest(root: rootURL.standardizedFileURL.path, + manualExcludedDirectories: manualExcludedDirectories, + workspaceRoots: workspaceRoots.map { $0.standardizedFileURL.path }) + ).get() } func gitWatchContext(at rootURL: URL) -> GitWatchContextPayload? { diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 3af6cc9c77..88ded2f948 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -500,9 +500,15 @@ struct RustGitOperations: GitOperations, Sendable { } func repositories(in workspaceURL: URL) -> [URL] { - core.workspaceRepositories(at: workspaceURL)?.repositories.map { + (try? repositories(GitRepositoryDiscoveryRequest(root: workspaceURL))) ?? [] + } + + func repositories(_ request: GitRepositoryDiscoveryRequest) throws -> [URL] { + try core.workspaceRepositories(at: request.root, + manualExcludedDirectories: request.manualExcludedDirectories, + workspaceRoots: request.workspaceRoots).repositories.map { URL(fileURLWithPath: $0.path).standardizedFileURL - } ?? [] + } } func watchContext(at rootURL: URL) -> GitWatchContext? { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift index 897bad1157..2722ca0bba 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift @@ -37,6 +37,14 @@ extension AppModel { } ) ) + feature.repositoryDiscoveryRequestProvider = { [weak self] root in + guard let self, self.workspaceURL?.standardizedFileURL == root.standardizedFileURL else { + return GitRepositoryDiscoveryRequest(root: root) + } + return GitRepositoryDiscoveryRequest(root: root, + manualExcludedDirectories: try self.workspaceFeature.manualDirectoryExclusions(), + workspaceRoots: [root]) + } feature.fetchOptionsProvider = { [weak self] in self?.settings.gitFetchOptions ?? GitFetchOptions() } return feature } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index b339cbb78a..ebf516bac6 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -412,7 +412,11 @@ package final class GitFeatureModel: ObservableObject { private let stashesProvider: @Sendable (URL) async -> [GitStash] private let operationStateProvider: @Sendable (URL) async -> GitOperationState? private let worktreesProvider: @Sendable (URL) async -> [GitWorktree]? - private let repositoryRootsProvider: @Sendable (URL) async -> [URL] + private let repositoryRootsProvider: @Sendable (GitRepositoryDiscoveryRequest) async throws -> [URL] + package var repositoryDiscoveryRequestProvider: @MainActor (URL) throws -> GitRepositoryDiscoveryRequest = { + GitRepositoryDiscoveryRequest(root: $0) + } + @Published package private(set) var repositoryDiscoveryError: (any Error)? private var requestedRepositoryReference: GitReference? private var requestedRepositoryRoot: URL? private let diffDocumentProvider: @Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument @@ -505,7 +509,8 @@ package final class GitFeatureModel: ObservableObject { stashesProvider: (@Sendable (URL) async -> [GitStash])? = nil, operationStateProvider: (@Sendable (URL) async -> GitOperationState?)? = nil, worktreesProvider: (@Sendable (URL) async -> [GitWorktree]?)? = nil, - repositoryRootsProvider: (@Sendable (URL) async -> [URL])? = nil, + repositoryRootsProvider: (@Sendable (URL) async throws -> [URL])? = nil, + repositoryDiscoveryProvider: (@Sendable (GitRepositoryDiscoveryRequest) async throws -> [URL])? = nil, diffDocumentProvider: (@Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument)? = nil, untrackedDirectoryChildrenProvider: (@Sendable (GitChange) async -> [GitChange]?)? = nil ) { @@ -529,7 +534,13 @@ package final class GitFeatureModel: ObservableObject { self.stashesProvider = stashesProvider ?? { await service.stashes(at: $0) } self.operationStateProvider = operationStateProvider ?? { await service.operationState(at: $0) } self.worktreesProvider = worktreesProvider ?? { await service.worktrees(at: $0) } - self.repositoryRootsProvider = repositoryRootsProvider ?? { await service.repositories(in: $0) } + if let repositoryDiscoveryProvider { + self.repositoryRootsProvider = repositoryDiscoveryProvider + } else if let repositoryRootsProvider { + self.repositoryRootsProvider = { try await repositoryRootsProvider($0.root) } + } else { + self.repositoryRootsProvider = { try await service.repositories($0) } + } self.diffDocumentProvider = diffDocumentProvider ?? { await service.diffDocument(for: $0, whitespace: $1) } @@ -579,7 +590,7 @@ package final class GitFeatureModel: ObservableObject { guard requested.standardizedFileURL == root else { return nil } // Discovered roots exclude nested repositories from status; they // must not become bindings in this checkout's commit plan. - let statusRoots = await repositoryRootsProvider(root) + guard let statusRoots = try? await repositoryRootsProvider(GitRepositoryDiscoveryRequest(root: root)) else { return nil } guard !Task.isCancelled, let snapshot = await snapshotProvider(requested, statusRoots), snapshot.repositoryRoot.standardizedFileURL == root else { return nil } @@ -588,7 +599,7 @@ package final class GitFeatureModel: ObservableObject { worktreesProvider: { _ in [] }, repositoryRootsProvider: { _ in [root] }, untrackedDirectoryChildrenProvider: { [untrackedDirectoryChildrenProvider, repositoryRootsProvider] directory in guard directory.repositoryRoot.standardizedFileURL == root else { return nil } - let statusRoots = await repositoryRootsProvider(root) + guard let statusRoots = try? await repositoryRootsProvider(GitRepositoryDiscoveryRequest(root: root)) else { return nil } guard !Task.isCancelled else { return nil } return await untrackedDirectoryChildrenProvider(directory, statusRoots) }) @@ -700,6 +711,7 @@ package final class GitFeatureModel: ObservableObject { worktreeChangesError = nil worktreeChangesRequestGeneration &+= 1 gitRepositoryRoot = nil + repositoryDiscoveryError = nil availableRepositoryRoots = [] requestedRepositoryRoot = nil requestedRepositoryReference = nil @@ -848,7 +860,28 @@ package final class GitFeatureModel: ObservableObject { let revision = changeRefreshRevision var didChange = false var historyContextChanged = false - let repositoryRoots = await repositoryRootsProvider(workspaceURL) + let request: GitRepositoryDiscoveryRequest + do { request = try repositoryDiscoveryRequestProvider(workspaceURL) } + catch { + guard !Task.isCancelled, revision == changeRefreshRevision, + workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } + repositoryDiscoveryError = error + notify?(L10n.string("Could not refresh Git status. Refresh to verify the changes.")) + return + } + let repositoryRoots: [URL] + do { + repositoryRoots = try await repositoryRootsProvider(request) + } catch { + guard !Task.isCancelled, revision == changeRefreshRevision, + workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL, + (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } + repositoryDiscoveryError = error + notify?(L10n.string("Could not refresh Git status. Refresh to verify the changes.")) + return + } + guard (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } + repositoryDiscoveryError = nil guard !Task.isCancelled, revision == changeRefreshRevision, workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } if availableRepositoryRoots != repositoryRoots { @@ -861,14 +894,14 @@ package final class GitFeatureModel: ObservableObject { requestedRepositoryReference = nil } let observation = await workspaceSnapshot(workspaceURL: workspaceURL, repositoryRoots: repositoryRoots) - guard !Task.isCancelled, revision == changeRefreshRevision, + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request, workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } if let observation { let snapshot = observation.snapshot guard !Task.isCancelled, workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } let changes = await listingExpandedUntrackedDirectories(in: snapshot.changes, isCurrent: { revision == self.changeRefreshRevision }) - guard !Task.isCancelled, revision == changeRefreshRevision else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } loadChangelistsIfNeeded() let changesChanged = gitChanges != changes if gitRepositoryRoot != snapshot.repositoryRoot { @@ -928,25 +961,25 @@ package final class GitFeatureModel: ObservableObject { didChange = didChange || previousFilter != gitConflictFilterPaths } let stashes = await stashesProvider(snapshot.repositoryRoot) - guard !Task.isCancelled, revision == changeRefreshRevision else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } if gitStashes != stashes { gitStashes = stashes didChange = true } let shelves = await shelveService?.entries(for: snapshot.repositoryRoot) ?? [] - guard !Task.isCancelled, revision == changeRefreshRevision else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } if gitShelves != shelves { gitShelves = shelves didChange = true } let operationState = await operationStateProvider(snapshot.repositoryRoot) - guard !Task.isCancelled, revision == changeRefreshRevision else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } if gitOperationState != operationState { gitOperationState = operationState didChange = true } await interactiveRebase.refreshSession(at: snapshot.repositoryRoot) - guard !Task.isCancelled, revision == changeRefreshRevision else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } if let gitOperationState, deferredSavedChanges == nil, let stash = gitStashes.first(where: { $0.message.contains("Lithe auto-stash before") }) { deferredSavedChanges = GitDeferredSavedChanges( @@ -956,11 +989,11 @@ package final class GitFeatureModel: ObservableObject { } for session in allDiffSessions where session.selectedChange != nil { - guard !Task.isCancelled, revision == changeRefreshRevision else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } await refreshWorkingDiff( session, changes: changes, confirmedRoots: observation.successfullyReadRepositoryRoots ) - guard !Task.isCancelled, revision == changeRefreshRevision else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, (try? repositoryDiscoveryRequestProvider(workspaceURL)) == request else { return } } } else { if gitRepositoryRoot != nil { @@ -3277,7 +3310,7 @@ package final class GitFeatureModel: ObservableObject { private func worktreeSnapshot(for root: URL) async -> GitSnapshot? { // A linked checkout outside the open workspace may have its own nested // repositories. Discover exclusions there without changing commit scope. - let statusRoots = await repositoryRootsProvider(root) + guard let statusRoots = try? await repositoryRootsProvider(GitRepositoryDiscoveryRequest(root: root)) else { return nil } guard !Task.isCancelled else { return nil } return await service.snapshot(for: root, repositoryRoots: statusRoots) } diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index dfc796bc84..7025925435 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -3,6 +3,19 @@ import LitheCoreContracts package typealias GitWatchContext = LitheCoreContracts.GitWatchContext +/// Immutable project membership and manual marks captured before repository discovery. +package struct GitRepositoryDiscoveryRequest: Equatable, Sendable { + package let root: URL + package let manualExcludedDirectories: [String] + package let workspaceRoots: [URL] + + package init(root: URL, manualExcludedDirectories: [String] = [], workspaceRoots: [URL] = []) { + self.root = root.standardizedFileURL + self.manualExcludedDirectories = manualExcludedDirectories.sorted() + self.workspaceRoots = workspaceRoots.map(\.standardizedFileURL) + } +} + /// Mirrors the shared Rust refname checks used by tag mutations so the macOS /// dialog can reject the same invalid names before crossing the Core boundary. public enum GitTagNameValidator { diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index f56ec0b436..4098a9a987 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -37,6 +37,7 @@ package protocol GitOperations: Sendable { /// Immediate files and subdirectory summaries of an untracked directory summary. func untrackedDirectoryChildren(of directory: GitChange, repositoryRoots: [URL]) -> [GitChange]? func repositories(in workspaceURL: URL) -> [URL] + func repositories(_ request: GitRepositoryDiscoveryRequest) throws -> [URL] func watchContext(at rootURL: URL) -> GitWatchContext? func worktrees(at rootURL: URL) -> [GitWorktree]? @@ -288,6 +289,10 @@ package extension GitOperations { snapshot(at: workspaceURL).map { [$0.repositoryRoot] } ?? [] } + func repositories(_ request: GitRepositoryDiscoveryRequest) throws -> [URL] { + repositories(in: request.root) + } + func references(at rootURL: URL, operationID: String) -> GitReferenceSnapshot? { guard let snapshot = history(at: rootURL, reference: nil, limit: 1) else { return nil } return GitReferenceSnapshot( @@ -442,6 +447,15 @@ package struct GitService: Sendable { await read(priority: .utility) { $0.repositories(in: workspace) } ?? [] } + func repositories(_ request: GitRepositoryDiscoveryRequest) async throws -> [URL] { + let result: Result<[URL], any Error>? = await read(priority: .utility) { operations in + Result { try operations.repositories(request) } + } + guard let result else { throw CancellationError() } + try Task.checkCancellation() + return try result.get() + } + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) async -> Result { await workspaceCommitOperation { $0.prepareWorkspaceCommit(request) } } diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 0024715f77..ab928489d7 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -183,6 +183,15 @@ package final class WorkspaceFeatureModel: ObservableObject { package var canEditIndexingSettings: Bool { !indexingPreferencesFailed } + /// A failed preference read is unknown, never a valid empty exclusion list. + package func manualDirectoryExclusions() throws -> [String] { + guard !indexingPreferencesFailed else { + if case .failure(let failure) = indexingIssue { throw failure } + throw WorkspaceIndexingFailure.readFailed + } + return directoryMarks.compactMap { $0.value == .excluded ? $0.key : nil }.sorted() + } + package var hasActiveModuleResources: Bool { directoryWatcher != nil || indexingConfigurationTask != nil @@ -708,7 +717,14 @@ package final class WorkspaceFeatureModel: ObservableObject { recordIndexingFailure(WorkspaceIndexingFailure.writeFailed) notify?(indexingError ?? "") await refreshCurrent() - } else if directoryMarkRevision == revision { await refreshCurrent() } + } else if directoryMarkRevision == revision { + // Root discovery reads manual marks independently of analysis policy. + let previousExclusions = Set(previousMarks.compactMap { $0.value == .excluded ? $0.key : nil }) + let currentExclusions = Set(updatedMarks.compactMap { $0.value == .excluded ? $0.key : nil }) + if previousExclusions != currentExclusions { await refreshGit?() } + guard self.workspaceURL == workspaceURL, self.workspaceGeneration == workspaceGeneration else { return } + await refreshCurrent() + } } package func setGitIgnoreIndexing(_ enabled: Bool) async { @@ -1471,7 +1487,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } // These are independent owned debounce lanes: tree/index/project work // must not delay Git or cancel the last chance to publish a file batch. - if batch.gitStateMayHaveChanged || batch.gitMetadataMayHaveChanged { + if batch.gitStateMayHaveChanged || batch.gitMetadataMayHaveChanged || rulesChanged { scheduleGitRefresh() } } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 9969116655..a23f1cdea2 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -7,6 +7,76 @@ import Testing @MainActor struct GitModuleTests { + @Test + func repositoryDiscoveryFailureKeepsPublishedRootsAndChanges() async { + let root = URL(fileURLWithPath: "/workspace") + let probe = GitRepositoryDiscoveryProbe() + let change = GitChange(repositoryRoot: root, path: "tracked.txt", originalPath: nil, + indexStatus: " ", workTreeStatus: "M") + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations()), + snapshotProvider: { url in GitSnapshot(repositoryRoot: url, branch: "main", changes: url == root ? [change] : []) }, + repositoryDiscoveryProvider: { try await probe.read($0) }) + let context = GitRepositoryDiscoveryTestContext() + feature.repositoryDiscoveryRequestProvider = { root in + if context.unavailable { throw GitRepositoryDiscoveryTestFailure.failed } + return GitRepositoryDiscoveryRequest(root: root) + } + var notifications: [String] = [] + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, + notify: { notifications.append($0) }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let roots = feature.availableRepositoryRoots + await probe.setFailure(true) + await feature.refreshGit() + #expect(feature.availableRepositoryRoots == roots) + #expect(feature.gitChanges == [change]) + #expect(feature.repositoryDiscoveryError as? GitRepositoryDiscoveryTestFailure == .failed) + #expect(!notifications.isEmpty) + await probe.setFailure(false) + let reads = await probe.requests.count + context.unavailable = true + await feature.refreshGit() + #expect(await probe.requests.count == reads) + #expect(feature.availableRepositoryRoots == roots && feature.gitChanges == [change]) + #expect(feature.repositoryDiscoveryError as? GitRepositoryDiscoveryTestFailure == .failed) + context.unavailable = false + await feature.refreshGit() + #expect(feature.repositoryDiscoveryError == nil) + } + + @Test + func repositoryDiscoveryRejectsOldExclusionsAndRemovesAutomaticRootSelection() async throws { + let root = URL(fileURLWithPath: "/workspace") + let nested = root.appendingPathComponent("nested") + let probe = GitRepositoryDiscoveryProbe() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations()), + snapshotProvider: { GitSnapshot(repositoryRoot: $0, branch: "main", changes: []) }, + repositoryDiscoveryProvider: { try await probe.read($0) }) + let context = GitRepositoryDiscoveryTestContext() + feature.repositoryDiscoveryRequestProvider = { + GitRepositoryDiscoveryRequest(root: $0, manualExcludedDirectories: context.excluded, workspaceRoots: [root]) + } + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, + notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + await feature.selectRepository(nested) + await probe.blockNext() + let oldRefresh = Task { await feature.refreshGit() } + defer { probe.release.open(); oldRefresh.cancel(); feature.reset() } + try #require(await probe.started.waitUntilOpen()) + context.excluded = ["nested"] + let releaseTask = Task { probe.release.open() } + defer { releaseTask.cancel() } + await feature.refreshGit() + try #require(await waitForGitTaskCompletion(oldRefresh)) + await releaseTask.value + #expect(feature.availableRepositoryRoots == [root]) + #expect(feature.gitRepositoryRoot == root) + #expect(await probe.requests.last?.manualExcludedDirectories == ["nested"]) + #expect(await probe.requests.last?.workspaceRoots == [root]) + } + @Test func independentDiffSessionsKeepBuffersAndRejectClosedReads() async throws { let root = URL(fileURLWithPath: "/workspace") @@ -5473,6 +5543,36 @@ private actor RepositoryDiffDocumentProbe { } } +@MainActor +private final class GitRepositoryDiscoveryTestContext { + var excluded: [String] = [] + var unavailable = false +} + +private enum GitRepositoryDiscoveryTestFailure: Error, Equatable { case failed } + +private actor GitRepositoryDiscoveryProbe { + nonisolated let started = GitModuleTestGate() + nonisolated let release = GitModuleTestGate() + private(set) var requests: [GitRepositoryDiscoveryRequest] = [] + private var fails = false + private var blocks = false + func setFailure(_ value: Bool) { fails = value } + func blockNext() { blocks = true } + func read(_ request: GitRepositoryDiscoveryRequest) async throws -> [URL] { + requests.append(request) + if fails { throw GitRepositoryDiscoveryTestFailure.failed } + let roots = request.manualExcludedDirectories.contains("nested") + ? [request.root] : [request.root, request.root.appendingPathComponent("nested")] + if blocks { + blocks = false + started.open() + guard await release.waitUntilOpen() else { throw GitRepositoryDiscoveryTestFailure.failed } + } + return roots + } +} + private final class GitModuleTestGate: @unchecked Sendable { private let condition = NSCondition() private var isOpen = false diff --git a/macos/Tests/LitheTests/GitStatusObservationTests.swift b/macos/Tests/LitheTests/GitStatusObservationTests.swift index c03eeaaa13..c84e8cbc15 100644 --- a/macos/Tests/LitheTests/GitStatusObservationTests.swift +++ b/macos/Tests/LitheTests/GitStatusObservationTests.swift @@ -550,6 +550,7 @@ struct GitStatusObservationTests { snapshotProvider: { _ in snapshot }, stashesProvider: { _ in [] }, operationStateProvider: { _ in nil }, + repositoryRootsProvider: { [$0] }, diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) } ) var downstreamRefreshCount = 0 @@ -577,6 +578,7 @@ struct GitStatusObservationTests { snapshotProvider: { _ in await snapshotProvider.nextSnapshot() }, stashesProvider: { _ in [] }, operationStateProvider: { _ in nil }, + repositoryRootsProvider: { [$0] }, diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) } ) model.configure( @@ -621,6 +623,7 @@ struct GitStatusObservationTests { snapshotProvider: { _ in await snapshotProvider.nextSnapshot() }, stashesProvider: { _ in [] }, operationStateProvider: { _ in nil }, + repositoryRootsProvider: { [$0] }, diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) } ) model.configure( diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 4ae02ff955..d31fab99d1 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -20,6 +20,8 @@ mod patch_exchange; mod progress; mod rebase_session; mod remote_history; +mod repository_discovery; +pub use repository_discovery::workspace_repositories; mod remote_process; mod rewrite; mod setup; @@ -65,12 +67,11 @@ use crate::protocol::{ GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitPushTargetKind, GitReferenceResponse, GitStashResponse, GitStashesResponse, GitStatusResponse, GitWatchContextResponse, GitWorktreeResponse, GitWorktreesResponse, - WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, }; use crate::remote::workspace_io as fs; use serde::{Deserialize, Serialize}; use std::cell::RefCell; -use std::collections::{HashSet, VecDeque}; +use std::collections::HashSet; use std::io::Read; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -106,6 +107,12 @@ fn append_full_file_context_arguments(arguments: &mut Vec, context_lines /// Request to discover Git repositories belonging to one opened workspace. pub struct WorkspaceRepositoriesRequest { pub root: String, + /// Workspace-relative manual exclusions. Explicitly opened roots retain their own ownership. + #[serde(default)] + pub manual_excluded_directories: Vec, + /// Explicit workspace membership; nested roots are scanned with their own exclusion snapshots. + #[serde(default)] + pub workspace_roots: Vec, /// Optional traversal budget; defaults to all eligible directories below the workspace. #[serde(default = "default_repository_scan_max_directories")] pub max_directories: usize, @@ -877,112 +884,6 @@ fn default_repository_scan_max_depth() -> usize { DEFAULT_REPOSITORY_SCAN_MAX_DEPTH } -/// Discovers Git repositories for an opened workspace using shared traversal rules. -pub fn workspace_repositories( - request: WorkspaceRepositoriesRequest, -) -> Result { - let workspace_root = PathBuf::from(validate_root(&request.root)?); - let max_directories = request - .max_directories - .min(DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES); - let max_depth = request.max_depth.min(DEFAULT_REPOSITORY_SCAN_MAX_DEPTH); - let mut discovered_repositories = HashSet::new(); - let containing_repository = discover_containing_repository(&workspace_root)?; - - if let Some(repository) = &containing_repository { - discovered_repositories.insert(repository.clone()); - } - - let mut queue = VecDeque::from([(workspace_root.clone(), 0usize)]); - let mut visited_directories = HashSet::new(); - - while let Some((directory, depth)) = queue.pop_front() { - crate::protocol::cancellation::check()?; - if visited_directories.len() >= max_directories { - break; - } - - let canonical_directory = match canonicalize_simplified(&directory) { - Ok(path) => path, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => return Err(repository_scan_error(error)), - }; - if !canonical_directory.starts_with(&workspace_root) { - continue; - } - if !visited_directories.insert(canonical_directory.clone()) { - continue; - } - - let entries = fs::read_dir(&canonical_directory).map_err(repository_scan_error)?; - let mut child_directories = Vec::new(); - for entry in entries { - crate::protocol::cancellation::check()?; - let entry = entry.map_err(repository_scan_error)?; - let name = entry.file_name().to_string_lossy().to_string(); - if name == ".git" { - // Only openable repositories join the workspace list; a dangling gitfile - // entry must not poison status and reference loads for sibling roots. - if git_entry_opens_as_repository(&entry.path()) { - discovered_repositories.insert(canonical_directory.clone()); - } - continue; - } - if !repository_scan_directory_is_visible(&name) { - continue; - } - let file_type = entry.file_type().map_err(repository_scan_error)?; - if file_type.is_dir() { - child_directories.push(entry.path()); - } - } - child_directories.sort_by(|left, right| { - path_sort_key(left) - .cmp(&path_sort_key(right)) - .then_with(|| left.cmp(right)) - }); - - if depth >= max_depth { - continue; - } - for child_directory in child_directories { - queue.push_back((child_directory, depth + 1)); - } - } - - let mut repositories = discovered_repositories - .into_iter() - .collect::>(); - sort_workspace_repository_paths(&mut repositories, &workspace_root); - if let Some(repository) = containing_repository { - repositories.retain(|path| path != &repository); - repositories.insert(0, repository); - } - - Ok(WorkspaceRepositoriesResponse { - repositories: repositories - .into_iter() - .map(|path| WorkspaceRepositoryResponse { - path: path.to_string_lossy().replace('\\', "/"), - }) - .collect(), - }) -} - -fn repository_scan_directory_is_visible(name: &str) -> bool { - // These intentional checkout containers are hidden in the file tree but - // remain discoverable in Git. Apply exclusions to their children as usual. - if name.eq_ignore_ascii_case(".worktree") || name.eq_ignore_ascii_case(".worktrees") { - return true; - } - // Filter descendants, never the explicitly opened root or its containing - // repository. Git ignore rules govern tracked files, not repository ownership. - !name.starts_with('.') - && !crate::project::BUILT_IN_HIDDEN_DIRECTORIES - .iter() - .any(|hidden| name.eq_ignore_ascii_case(hidden)) -} - /// Executes an argument-based Git command after validating the workspace root. pub fn command(request: GitCommandRequest) -> Result { with_git_invocation_trace(|| { @@ -1764,6 +1665,26 @@ fn capture_git_process( disable_optional_locks: bool, environment: &[(String, String)], visible: bool, +) -> Result { + capture_git_process_streaming( + root, + arguments, + input, + disable_optional_locks, + environment, + visible, + None, + ) +} + +fn capture_git_process_streaming( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], + visible: bool, + mut stdout_consumer: Option<&mut dyn FnMut(&[u8]) -> Result<(), CoreError>>, ) -> Result { validate_windows_git_path(Path::new(root)).map_err(git_path_error)?; crate::protocol::cancellation::check()?; @@ -1774,6 +1695,9 @@ fn capture_git_process( disable_optional_locks, environment, visible, + stdout_consumer + .as_mut() + .map(|consumer| &mut **consumer as &mut dyn FnMut(&[u8]) -> Result<(), CoreError>), ) { return result; } @@ -1841,10 +1765,16 @@ fn capture_git_process( None }; let mut cancellation_error = None; - let output = lithe_git_host::run( + let stdout_error = RefCell::new(None); + let capture_stdout = stdout_consumer.is_none(); + let output = lithe_git_host::run_with_stdout_capture( &mut process, input.as_deref().map(str::as_bytes), + capture_stdout, || { + if stdout_error.borrow().is_some() { + return true; + } cancellation_error = crate::protocol::cancellation::check().err(); if cancellation_error.is_none() { if let Some(session) = &mut authentication { @@ -1878,11 +1808,21 @@ fn capture_git_process( } }, |stream, bytes| { + if matches!(stream, lithe_git_host::Stream::Stdout) { + if let Some(consumer) = stdout_consumer.as_deref_mut() { + if let Err(error) = consumer(bytes) { + *stdout_error.borrow_mut() = Some(error); + } + } + } if let Some(invocation) = invocation.borrow_mut().as_mut() { invocation.output(stream, bytes); } }, ); + if let Some(error) = stdout_error.into_inner() { + return Err(error); + } let error = output.failure.map(|failure| match failure { lithe_git_host::Failure::Start(error) => { CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git") @@ -4314,15 +4254,15 @@ fn resolve_gitfile_gitdir(gitfile: &Path, contents: &str) -> Option { /// be listed as a workspace repository and fail every status or reference load /// for the healthy sibling repositories. fn git_entry_opens_as_repository(git_path: &Path) -> bool { - if git_path.is_dir() { - return git_path.join("HEAD").is_file(); + if fs::metadata(git_path).is_ok_and(|metadata| metadata.is_dir()) { + return fs::metadata(git_path.join("HEAD")).is_ok_and(|metadata| metadata.is_file()); } - let contents = match std::fs::read_to_string(git_path) { + let contents = match fs::read_to_string(git_path) { Ok(contents) => contents, Err(_) => return false, }; resolve_gitfile_gitdir(git_path, &contents) - .map(|gitdir| gitdir.join("HEAD").is_file()) + .map(|gitdir| fs::metadata(gitdir.join("HEAD")).is_ok_and(|metadata| metadata.is_file())) .unwrap_or(false) } @@ -4333,6 +4273,18 @@ fn discover_containing_repository(root: &Path) -> Result, CoreEr None, )?; if output.exit_code != 0 { + // Absence is expected outside Git; an existing ancestor checkout that + // Git cannot inspect is a failure, not an empty ownership snapshot. + if root + .ancestors() + .any(|path| git_entry_opens_as_repository(&path.join(".git"))) + { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect containing repository", + ) + .with_details(output.output)); + } return Ok(None); } let repository_root = output.stdout.trim_end_matches(['\r', '\n']); @@ -8059,29 +8011,38 @@ pub(crate) fn ignored_paths( directory: &Path, paths: &[String], ) -> Result>, CoreError> { + let result = ignored_paths_checked(directory, paths); + crate::protocol::cancellation::check()?; + Ok(result.ok()) +} + +/// Strict ignore query for callers that cannot publish incomplete repository ownership. +pub(crate) fn ignored_paths_checked( + directory: &Path, + paths: &[String], +) -> Result, CoreError> { if paths.is_empty() { - return Ok(Some(HashSet::new())); + return Ok(HashSet::new()); } let response = execute_git_readonly( &directory.to_string_lossy(), &["check-ignore".into(), "--stdin".into(), "-z".into()], Some(nul_pathspec_input(paths)), - ); + )?; crate::protocol::cancellation::check()?; - let Ok(response) = response else { - return Ok(None); - }; if ![0, 1].contains(&response.exit_code) { - return Ok(None); + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect repository ignore rules", + ) + .with_details(response.output)); } - Ok(Some( - response - .stdout - .split('\0') - .filter(|path| !path.is_empty()) - .map(str::to_string) - .collect(), - )) + Ok(response + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_owned) + .collect()) } /// Upper bound on the pathspec bytes passed to one `git ls-files` call. @@ -8129,24 +8090,42 @@ pub(crate) fn untracked_directory_candidates( directory: &Path, candidates: &[String], ) -> Option> { + untracked_directory_candidates_checked(directory, candidates).ok() +} + +/// Strict tracked-directory query preserving native failure and cancellation categories. +pub(crate) fn untracked_directory_candidates_checked( + directory: &Path, + candidates: &[String], +) -> Result, CoreError> { let mut untracked = candidates.iter().cloned().collect::>(); for batch in pathspec_batches(candidates, LS_FILES_PATHSPEC_BUDGET) { let mut arguments = vec!["--literal-pathspecs", "ls-files", "-z", "--"]; arguments.extend(batch.iter().map(String::as_str)); - let output = run_git(directory, &arguments).ok()?; - if !output.status.success() { - return None; + let output = execute_git_readonly( + &directory.to_string_lossy(), + &arguments + .iter() + .map(|value| (*value).to_owned()) + .collect::>(), + None, + )?; + if output.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect tracked repository directories", + ) + .with_details(output.stderr)); } for record in output .stdout - .split(|byte| *byte == 0) + .split('\0') .filter(|record| !record.is_empty()) { - let path = String::from_utf8_lossy(record); - untracked.remove(path.split('/').next().unwrap_or(&path)); + untracked.remove(record.split('/').next().unwrap_or(record)); } } - Some(untracked) + Ok(untracked) } /// Splits `paths` into consecutive non-empty batches whose summed byte length diff --git a/rust/lithe-core/src/git/remote_process.rs b/rust/lithe-core/src/git/remote_process.rs index 4c64675546..06d940dbe0 100644 --- a/rust/lithe-core/src/git/remote_process.rs +++ b/rust/lithe-core/src/git/remote_process.rs @@ -18,6 +18,7 @@ pub(super) fn capture( disable_optional_locks: bool, environment: &[(String, String)], visible: bool, + mut stdout_consumer: Option<&mut dyn FnMut(&[u8]) -> Result<(), CoreError>>, ) -> Option> { workspace::current().map(|host| { let session = &host.connection.session; @@ -59,18 +60,22 @@ pub(super) fn capture( ) .with_details(error.to_string()) })?; - let target = if event.stream == OutputStream::Stdout { - &mut stdout + if event.stream == OutputStream::Stdout && stdout_consumer.is_some() { + stdout_consumer.as_deref_mut().unwrap()(&bytes)?; } else { - &mut stderr - }; - if target.len() + bytes.len() > MAX_CAPTURE_BYTES { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Git output exceeded the capture limit", - )); + let target = if event.stream == OutputStream::Stdout { + &mut stdout + } else { + &mut stderr + }; + if target.len() + bytes.len() > MAX_CAPTURE_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git output exceeded the capture limit", + )); + } + target.extend_from_slice(&bytes); } - target.extend_from_slice(&bytes); if let Some(invocation) = &mut invocation { invocation.output( if event.stream == OutputStream::Stdout { diff --git a/rust/lithe-core/src/git/repository_discovery.rs b/rust/lithe-core/src/git/repository_discovery.rs new file mode 100644 index 0000000000..2657fe63e7 --- /dev/null +++ b/rust/lithe-core/src/git/repository_discovery.rs @@ -0,0 +1,246 @@ +//! Workspace-scoped Git discovery with native ignore rules and explicit root ownership. + +use super::{ + canonicalize_simplified, capture_git_process_streaming, discover_containing_repository, + git_entry_opens_as_repository, ignored_paths_checked, is_safe_pathspec, path_sort_key, + repository_scan_error, sort_workspace_repository_paths, untracked_directory_candidates_checked, + validate_root, WorkspaceRepositoriesRequest, +}; +use crate::project::files::VisibilityRules; +use crate::protocol::{ + CoreError, ErrorCode, WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, +}; +use crate::remote::workspace_io as fs; +use std::collections::{HashSet, VecDeque}; +use std::path::{Path, PathBuf}; + +/// Discovers eligible automatic roots plus openable, index-declared submodule dependencies. +/// Exclusions are request-local and never filter files reported by an existing repository. +pub fn workspace_repositories( + request: WorkspaceRepositoriesRequest, +) -> Result { + let workspace = PathBuf::from(validate_root(&request.root)?); + let excluded = manual_exclusions(&workspace, &request.manual_excluded_directories)?; + let boundaries = VisibilityRules::new(Vec::new(), Vec::new()) + .with_workspace_roots(&workspace, &request.workspace_roots); + let containing = discover_containing_repository(&workspace)?; + let mut repositories = HashSet::new(); + if let Some(root) = &containing { + repositories.insert(root.clone()); + } + let mut queue = VecDeque::from([(workspace.clone(), 0usize, containing.clone())]); + let mut visited = HashSet::new(); + let mut inspected_dependencies = HashSet::new(); + + while let Some((directory, depth, mut owner)) = queue.pop_front() { + crate::protocol::cancellation::check()?; + if visited.len() >= request.max_directories { + break; + } + let directory = match canonicalize_simplified(&directory) { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(repository_scan_error(error)), + }; + if !directory.starts_with(&workspace) + || boundaries.is_delegated(&directory) + || !visited.insert(directory.clone()) + { + continue; + } + + let mut children = Vec::new(); + for entry in fs::read_dir(&directory).map_err(repository_scan_error)? { + crate::protocol::cancellation::check()?; + let entry = entry.map_err(repository_scan_error)?; + let name = entry.file_name().to_string_lossy().to_string(); + if name == ".git" { + if git_entry_opens_as_repository(&entry.path()) { + repositories.insert(directory.clone()); + owner = Some(directory.clone()); + } + } else if directory_is_visible(&name) + && entry.file_type().map_err(repository_scan_error)?.is_dir() + && !boundaries.is_delegated(&entry.path()) + && !excluded.iter().any(|path| entry.path().starts_with(path)) + { + children.push(name); + } + } + + // A real gitlink remains a dependency even when its containing directory + // is excluded. Never infer this exception from a nested .git marker. + if let Some(root) = owner + .as_ref() + .filter(|root| inspected_dependencies.insert((*root).clone())) + { + for path in submodule_paths(root)? { + let candidate = root.join(path); + if !candidate.starts_with(&workspace) || boundaries.is_delegated(&candidate) { + continue; + } + let relative = candidate.strip_prefix(&workspace).unwrap(); + let dependency_depth = relative.components().count(); + if dependency_depth <= request.max_depth + && !has_symlink_component(&workspace, relative)? + && git_entry_opens_as_repository(&candidate.join(".git")) + { + queue.push_back((candidate, dependency_depth, Some(root.clone()))); + } + } + } + if depth >= request.max_depth { + continue; + } + children.sort_by(|left, right| { + path_sort_key(Path::new(left)) + .cmp(&path_sort_key(Path::new(right))) + .then_with(|| left.cmp(right)) + }); + let ignored = if owner.is_some() { + ignored_directories(&directory, &children)? + } else { + HashSet::new() + }; + for child in children.into_iter().filter(|name| !ignored.contains(name)) { + queue.push_back((directory.join(child), depth + 1, owner.clone())); + } + } + + let mut repositories = repositories.into_iter().collect::>(); + sort_workspace_repository_paths(&mut repositories, &workspace); + if let Some(root) = containing { + repositories.retain(|path| path != &root); + repositories.insert(0, root); + } + Ok(WorkspaceRepositoriesResponse { + repositories: repositories + .into_iter() + .map(|path| WorkspaceRepositoryResponse { + path: path.to_string_lossy().replace('\\', "/"), + }) + .collect(), + }) +} + +fn manual_exclusions(root: &Path, paths: &[String]) -> Result, CoreError> { + paths + .iter() + .map(|value| { + let value = value.trim_end_matches('/'); + if value != "." + && (!is_safe_pathspec(value) + || value.contains(['\\', '\0', ':']) + || value.split('/').any(|part| part.is_empty() || part == ".")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid repository exclusion directory", + )); + } + Ok(if value == "." { + root.to_path_buf() + } else { + root.join(value) + }) + }) + .collect() +} + +fn directory_is_visible(name: &str) -> bool { + if name.eq_ignore_ascii_case(".worktree") || name.eq_ignore_ascii_case(".worktrees") { + return true; + } + !name.starts_with('.') + && !crate::project::BUILT_IN_HIDDEN_DIRECTORIES + .iter() + .any(|hidden| name.eq_ignore_ascii_case(hidden)) +} + +fn ignored_directories(directory: &Path, paths: &[String]) -> Result, CoreError> { + let ignored = ignored_paths_checked(directory, paths)? + .into_iter() + .collect::>(); + if ignored.is_empty() { + return Ok(HashSet::new()); + } + // Tracked descendants and gitlinks keep their ancestor traversable. Status + // remains Git-owned; manual exclusions only control automatic root discovery. + untracked_directory_candidates_checked(directory, &ignored) +} + +fn submodule_paths(root: &Path) -> Result, CoreError> { + // Git 2.31 has no mode-only ls-files query. Stream NUL records through the + // existing native/remote process owners instead of capturing the whole index. + let mut record = Vec::new(); + let mut paths = Vec::new(); + let mut consume = |bytes: &[u8]| { + for part in bytes.split_inclusive(|byte| *byte == 0) { + if record.len() + part.len() > 1024 * 1024 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git index record exceeded the capture limit", + )); + } + record.extend_from_slice(part); + if record.last() != Some(&0) { + continue; + } + if let Some(separator) = record.iter().position(|byte| *byte == b'\t') { + let header = String::from_utf8_lossy(&record[..separator]); + let mut fields = header.split_whitespace(); + if fields.next() == Some("160000") && fields.nth(1) == Some("0") { + let path = String::from_utf8_lossy(&record[separator + 1..record.len() - 1]); + if !path.is_empty() + && Path::new(path.as_ref()) + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))) + { + paths.push(path.into_owned()); + } + } + } + record.clear(); + } + Ok(()) + }; + let arguments = + super::execution_policy::arguments(&["ls-files".into(), "--stage".into(), "-z".into()]); + let response = capture_git_process_streaming( + &root.to_string_lossy(), + &arguments, + None, + true, + &[], + false, + Some(&mut consume), + )?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect repository dependencies", + ) + .with_details(String::from_utf8_lossy(&response.stderr))); + } + if !record.is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Incomplete Git index record", + )); + } + Ok(paths) +} + +fn has_symlink_component(root: &Path, relative: &Path) -> Result { + let mut path = root.to_path_buf(); + for component in relative.components() { + path.push(component); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true), + Err(error) => return Err(repository_scan_error(error)), + } + } + Ok(false) +} diff --git a/rust/lithe-core/src/project/indexing.rs b/rust/lithe-core/src/project/indexing.rs index ad4bb716d0..35df3cf814 100644 --- a/rust/lithe-core/src/project/indexing.rs +++ b/rust/lithe-core/src/project/indexing.rs @@ -1829,7 +1829,7 @@ mod tests { } #[test] - fn parent_ignore_excludes_nested_checkout_only_from_analysis_inventory() { + fn analysis_includes_do_not_register_ignored_nested_repositories() { let workspace = Workspace::new(); workspace.git(&["init", "--quiet"]); workspace.git(&["-C", "ignored", "init", "--quiet"]); @@ -1844,7 +1844,7 @@ mod tests { serde_json::from_value(json!({"root": workspace.0})).unwrap(), ) .unwrap(); - assert_eq!(repositories.repositories.len(), 2); + assert_eq!(repositories.repositories.len(), 1); assert!( ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { root: workspace.0.to_string_lossy().into_owned(), @@ -1866,7 +1866,7 @@ mod tests { .unwrap() .repositories .len(), - 2 + 1 ); let suggestions = ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { root: workspace.0.to_string_lossy().into_owned(), @@ -1884,7 +1884,7 @@ mod tests { .unwrap() .repositories .len(), - 2 + 1 ); } diff --git a/rust/lithe-core/src/remote/sessions_integration_tests.rs b/rust/lithe-core/src/remote/sessions_integration_tests.rs index 146c4ebf49..cd9156ab08 100644 --- a/rust/lithe-core/src/remote/sessions_integration_tests.rs +++ b/rust/lithe-core/src/remote/sessions_integration_tests.rs @@ -343,6 +343,62 @@ fn remote_parent_repository_discovery_keeps_identity_for_status_and_stage() { discovered_repository_keeps_host_identity(false); } +#[test] +fn remote_discovery_keeps_excluded_gitlink_dependencies_on_their_host() { + let host = HostFixture::new(); + host.git(&["init", "--template=", "-q", "-b", "main"]); + let repository = host.root.join("host"); + let module = repository.join("excluded/module:one"); + std::fs::create_dir_all(&module).unwrap(); + host.git(&[ + "-C", + module.to_str().unwrap(), + "init", + "--template=", + "-q", + "-b", + "main", + ]); + host.git(&[ + "-C", + module.to_str().unwrap(), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "initial", + ]); + let revision = host.git(&["-C", module.to_str().unwrap(), "rev-parse", "HEAD"]); + let entry = format!( + "160000,{},excluded/module:one", + revision["stdout"].as_str().unwrap().trim() + ); + host.git(&["update-index", "--add", "--cacheinfo", &entry]); + let result = host.call( + "workspace.repositories", + json!({"manualExcludedDirectories": ["excluded"]}), + ); + let root = result["repositories"][0]["path"].as_str().unwrap(); + assert_eq!( + result["repositories"].as_array().unwrap().len(), + 2, + "{result}" + ); + assert_eq!( + result["repositories"][1]["path"], + format!("{root}/excluded/module:one") + ); + assert!( + !std::path::Path::new(result["repositories"][1]["path"].as_str().unwrap()).exists(), + "The returned identity must not accidentally resolve on the client" + ); +} + #[test] fn remote_symlink_repository_discovery_keeps_identity_for_status_and_stage() { discovered_repository_keeps_host_identity(true); diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index c11180cd6f..974ee42ac1 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -61,37 +61,42 @@ fn workspace_repositories_discovers_multiple_child_repositories() { // and beyond the former depth limit, even below an existing repository. let nested = (0..40).fold(first.join("vendor"), |path, _| path.join("d")); fs::create_dir_all(&nested).expect("nested worktree directory should be creatable"); - // The gitdir must really exist: discovery opens the gitfile pointer instead of - // trusting the marker alone, so the worktree fixture needs a gitdir with HEAD. - let nested_gitdir = root - .join("fixture") - .join("git") - .join("worktrees") - .join("nested"); - fs::create_dir_all(&nested_gitdir).expect("worktree gitdir should be creatable"); - fs::write(nested_gitdir.join("HEAD"), "ref: refs/heads/main\n") - .expect("worktree HEAD should be writable"); - fs::write( - nested.join(".git"), - format!("gitdir: {}\n", nested_gitdir.display()), - ) - .expect("worktree marker should be writable"); - fs::create_dir_all(&first).expect("first repository directory should be creatable"); - fs::create_dir_all(&second).expect("second repository directory should be creatable"); - assert!(Command::new("git") - .args(["init", "-q"]) - .current_dir(&first) - .output() - .expect("git should be available") - .status - .success()); - assert!(Command::new("git") - .args(["init", "-q"]) - .current_dir(&second) - .output() - .expect("git should be available") - .status - .success()); + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let git = |directory: &Path, arguments: &[&str]| { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "command": "git.command", "timeoutMilliseconds": 5000, + "payload": {"root": directory, "arguments": arguments} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{response}"); + }; + // Queries now inspect real Git rules/index data, so a HEAD-only fake gitdir + // cannot stand in for an openable linked checkout. + fs::create_dir_all(&first).unwrap(); + fs::create_dir_all(&second).unwrap(); + git(&first, &["init", "--template=", "-q"]); + git(&second, &["init", "--template=", "-q"]); + let nested_gitdir = root.join(".fixture-gitdir"); + git( + &nested, + &[ + "init", + "--template=", + "-q", + "--separate-git-dir", + nested_gitdir.to_str().unwrap(), + ], + ); let request = serde_json::json!({ "id": "workspace-repositories", diff --git a/rust/lithe-core/src/tests/git_repository_discovery.rs b/rust/lithe-core/src/tests/git_repository_discovery.rs index 9b3f81cd51..1fdb794fe5 100644 --- a/rust/lithe-core/src/tests/git_repository_discovery.rs +++ b/rust/lithe-core/src/tests/git_repository_discovery.rs @@ -51,7 +51,16 @@ fn git(root: &Path, arguments: &[&str]) { } fn discover(root: &Path) -> Vec { - data("workspace.repositories", json!({"root": root}))["repositories"] + discover_with(root, &[], &[]) +} + +fn discover_with(root: &Path, excluded: &[&str], roots: &[&Path]) -> Vec { + data( + "workspace.repositories", + json!({ + "root": root, "manualExcludedDirectories": excluded, "workspaceRoots": roots, + }), + )["repositories"] .as_array() .unwrap() .iter() @@ -80,13 +89,26 @@ fn discovery_excludes_hidden_and_generated_repositories_before_status_aggregatio for path in fixture["repositoryPaths"].as_array().unwrap() { workspace.initialize(path.as_str().unwrap()); } - fs::write(workspace.0.join(".gitignore"), ".build/\nmacos/.build/\n").unwrap(); + let patterns = fixture["gitIgnorePatterns"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>() + .join("\n"); + fs::write(workspace.0.join(".gitignore"), patterns).unwrap(); fs::write( workspace.0.join(".build/checkouts/SwiftTerm/local.txt"), "generated change", ) .unwrap(); - let actual = discover(&workspace.0); + let excluded = fixture["manualExcludedDirectories"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>(); + let actual = discover_with(&workspace.0, &excluded, &[]); let expected: Vec<_> = fixture["expectedRepositoryPaths"] .as_array() .unwrap() @@ -160,6 +182,149 @@ fn discovery_preserves_linked_worktrees_but_excludes_their_build_checkouts() { workspace.initialize(&format!("{container}/topic/.build/checkouts/dependency")); } assert_eq!(discover(&root), expected); + fs::write(root.join(".gitignore"), ".worktrees/\n").unwrap(); + assert_eq!(discover(&root), expected[..2]); + assert_eq!( + discover_with(&root, &[".worktree"], &[]), + vec![canonical(&root)] + ); + let explicit = root.join(".worktrees/topic"); + assert_eq!(discover(&explicit), vec![canonical(&explicit)]); +} + +#[test] +fn discovery_filters_ignored_and_manual_roots_without_filtering_parent_changes() { + let workspace = Workspace::new(); + let root = workspace.initialize("."); + workspace.initialize("test/test2"); + workspace.initialize("manual/project"); + let visible = workspace.initialize("visible"); + workspace.initialize("visible/ignored-child"); + fs::write(root.join(".gitignore"), "test\n").unwrap(); + fs::write(visible.join(".gitignore"), "ignored-child/\n").unwrap(); + fs::write(root.join("manual/tracked.txt"), "tracked").unwrap(); + git(&root, &["add", "--", "manual/tracked.txt"]); + fs::write(root.join("manual/tracked.txt"), "modified").unwrap(); + fs::write(root.join("manual/untracked.txt"), "untracked").unwrap(); + let roots = discover_with(&root, &["manual"], &[]); + assert_eq!(roots, vec![canonical(&root), canonical(&visible)]); + let status = data( + "git.status", + json!({"root": root, "repositoryRoots": roots}), + ); + let paths = status["changes"] + .as_array() + .unwrap() + .iter() + .map(|change| change["path"].as_str().unwrap()) + .collect::>(); + assert!(paths.contains(&"manual/tracked.txt"), "{status}"); + assert!(paths.contains(&"manual/untracked.txt"), "{status}"); + assert_eq!(discover(&root).len(), 3); + fs::write(root.join(".gitignore"), "").unwrap(); + assert_eq!(discover(&root).len(), 4); +} + +#[test] +fn discovery_uses_native_negations_local_excludes_and_independent_explicit_roots() { + let workspace = Workspace::new(); + let root = workspace.initialize("."); + let included = workspace.initialize("projects/keep"); + let ignored = workspace.initialize("projects/skip"); + let local = workspace.initialize("local"); + fs::write(root.join(".gitignore"), "projects/*\n!projects/keep/\n").unwrap(); + fs::create_dir_all(root.join(".git/info")).unwrap(); + fs::write(root.join(".git/info/exclude"), "local/\n").unwrap(); + assert_eq!( + discover(&root), + vec![canonical(&root), canonical(&included)] + ); + assert_eq!(discover(&ignored), vec![canonical(&ignored)]); + assert_eq!(discover(&local), vec![canonical(&local)]); +} + +#[test] +fn discovery_delegates_nested_workspace_roots_to_their_own_policy() { + let workspace = Workspace::new(); + let root = workspace.initialize("."); + let nested = workspace.initialize("nested"); + let child = workspace.initialize("nested/child"); + let roots = [&root as &Path, &nested as &Path]; + assert_eq!(discover_with(&root, &[], &roots), vec![canonical(&root)]); + assert_eq!( + discover_with(&nested, &["child"], &roots), + vec![canonical(&nested)] + ); + assert_eq!( + discover_with(&nested, &[], &roots), + vec![canonical(&nested), canonical(&child)] + ); + assert_eq!( + discover_with(&root, &["nested"], &roots), + vec![canonical(&root)] + ); + assert_eq!(discover(&root).len(), 3); +} + +#[test] +fn discovery_keeps_index_registered_submodules_inside_excluded_containers() { + let workspace = Workspace::new(); + let root = workspace.initialize("."); + let module = workspace.initialize("excluded/module"); + workspace.initialize("excluded/ordinary"); + git( + &module, + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "initial", + ], + ); + let revision = data( + "git.command", + json!({"root": module, "arguments": ["rev-parse", "HEAD"]}), + ); + let entry = format!( + "160000,{},excluded/module", + revision["stdout"].as_str().unwrap().trim() + ); + git(&root, &["update-index", "--add", "--cacheinfo", &entry]); + fs::write(root.join(".gitignore"), "excluded/\n").unwrap(); + assert_eq!( + discover_with(&root, &["excluded"], &[]), + vec![canonical(&root), canonical(&module)] + ); +} + +#[test] +fn discovery_reports_invalid_rules_and_git_failures_instead_of_empty_success() { + let workspace = Workspace::new(); + let root = workspace.initialize("."); + for path in ["../outside", "/outside", "bad\0path"] { + let response: Value = serde_json::from_str(&execute_json( + &json!({ + "command": "workspace.repositories", "timeoutMilliseconds": 5000, + "payload": {"root": root, "manualExcludedDirectories": [path]}, + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "invalid_request", "{response}"); + } + fs::write(root.join(".git/config"), "[invalid\n").unwrap(); + let response: Value = serde_json::from_str(&execute_json(&json!({ + "command": "workspace.repositories", "timeoutMilliseconds": 5000, "payload": {"root": root}, + }).to_string())).unwrap(); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "process_failed", "{response}"); } #[test] diff --git a/rust/lithe-git-host/src/lib.rs b/rust/lithe-git-host/src/lib.rs index effb7592df..1ef58694ad 100644 --- a/rust/lithe-git-host/src/lib.rs +++ b/rust/lithe-git-host/src/lib.rs @@ -54,6 +54,19 @@ pub enum Failure { pub fn run( command: &mut Command, input: Option<&[u8]>, + cancelled: impl FnMut() -> bool, + started: impl FnMut(), + output: impl FnMut(Stream, &[u8]), +) -> Outcome { + run_with_stdout_capture(command, input, true, cancelled, started, output) +} + +/// Uses the same process ownership while optionally leaving stdout exclusively +/// to the streaming callback. Stderr retains the ordinary bounded capture. +pub fn run_with_stdout_capture( + command: &mut Command, + input: Option<&[u8]>, + capture_stdout: bool, mut cancelled: impl FnMut() -> bool, mut started: impl FnMut(), mut output: impl FnMut(Stream, &[u8]), @@ -105,6 +118,7 @@ pub fn run( &mut stdout_eof, &mut result.stdout, Stream::Stdout, + capture_stdout, &mut output, ) .and_then(|_| { @@ -113,6 +127,7 @@ pub fn run( &mut stderr_eof, &mut result.stderr, Stream::Stderr, + true, &mut output, ) }); @@ -157,6 +172,7 @@ fn drain( eof: &mut bool, captured: &mut Vec, stream: Stream, + capture: bool, output: &mut impl FnMut(Stream, &[u8]), ) -> Result<(), Failure> { if *eof { @@ -176,10 +192,12 @@ fn drain( Err(error) => return Err(Failure::Io(error)), }; output(stream, &buffer[..count]); - if captured.len() + count > MAX_CAPTURE_BYTES { - return Err(Failure::OutputLimit); + if capture { + if captured.len() + count > MAX_CAPTURE_BYTES { + return Err(Failure::OutputLimit); + } + captured.extend_from_slice(&buffer[..count]); } - captured.extend_from_slice(&buffer[..count]); } Ok(()) } diff --git a/rust/lithe-git-host/tests/process.rs b/rust/lithe-git-host/tests/process.rs index c1b442e17d..dec4d17c07 100644 --- a/rust/lithe-git-host/tests/process.rs +++ b/rust/lithe-git-host/tests/process.rs @@ -1,5 +1,5 @@ //! Native integration fixtures execute only this test binary, never installed tools. -use lithe_git_host::{run, Failure, ProcessTree}; +use lithe_git_host::{run, run_with_stdout_capture, Failure, ProcessTree, Stream}; use std::io::{BufRead, BufReader, Read, Write}; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -18,6 +18,16 @@ fn process_fixture() { eprintln!("fixture error"); std::process::exit(23); } + if mode == "large-output" { + let block = [b'x'; 8192]; + for _ in 0..(33 * 128) { + if std::io::stdout().write_all(&block).is_err() { + return; + } + } + eprintln!("stderr retained"); + return; + } if mode == "tree-parent" || mode == "tree-child" { run_tree_fixture(&mode); return; @@ -32,6 +42,35 @@ fn process_fixture() { } } +#[test] +fn streaming_stdout_keeps_bounded_process_ownership_without_capturing_the_index() { + // The adapter drains 64 KiB per poll; 33 MiB takes over five seconds even + // without contention. This deadline stays below the 15-second case budget. + let deadline = Instant::now() + Duration::from_secs(12); + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command + .args(["--exact", "process_fixture", "--nocapture"]) + .env("LITHE_GIT_PROCESS_FIXTURE", "large-output"); + let mut received = 0; + let outcome = run_with_stdout_capture( + &mut command, + None, + false, + || Instant::now() > deadline, + || {}, + |stream, bytes| { + if matches!(stream, Stream::Stdout) { + received += bytes.len(); + } + }, + ); + assert!(outcome.failure.is_none(), "{:?}", outcome.failure); + assert_eq!(outcome.status.and_then(|status| status.code()), Some(0)); + assert!(received > 32 * 1024 * 1024); + assert!(outcome.stdout.is_empty()); + assert!(String::from_utf8_lossy(&outcome.stderr).contains("stderr retained")); +} + #[test] fn incremental_output_can_cancel_before_the_child_exits() { let began = Instant::now(); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 80bd016041..51455f24ea 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1313,11 +1313,21 @@ directory names (including `.build`, `node_modules`, `.venv`, `venv`, `__pycache `target`, `build`, `DerivedData`, `dist` and `coverage`). `.worktree` and `.worktrees` remain traversable checkout containers; the same exclusions apply inside them. Traversal continues below eligible discovered repositories, including ordinary -`vendor` source checkouts. An explicitly opened root and its containing repository -are never excluded by their names. These are automatic discovery rules, not Git -tracking rules: `.gitignore` and file-tree visibility overrides do not configure -repository ownership. The rules apply before repository status/reference -aggregation on both hosts. Examples live in +`vendor` source checkouts. Before entering descendants, native Git ignore rules +(including local/global excludes and negations) and optional `manualExcludedDirectories` +prune automatic discovery. Manual paths are workspace-relative directories; ancestor +exclusions cover their descendants. Tracked descendants keep ignored ancestors traversable. +An openable index-declared gitlink remains a submodule dependency even below an excluded +container; an independent nested `.git` marker grants no exception. `.worktree` and +`.worktrees` bypass only the hidden-name rule, not Git/manual exclusions. +An explicitly opened root and its containing repository remain managed. Optional +`workspaceRoots` delegates strict descendant roots to separate host queries with their +own manual snapshots. Hosts merge those results; no process-global analysis policy is read. +Analysis include exceptions and the Git analysis-exclusion switch do not register roots. +Git query failures return structured errors; hosts retain their last successful roots +and status, invalidate discovery on rule/root changes, and reject superseded results. +File-tree visibility overrides do not configure repository ownership. These rules apply +before repository status/reference aggregation on both hosts. Examples live in `shared/fixtures/workspace/repository-visibility-v1.json`. Git metadata itself is not traversed. Symbolic directory links are not followed, preventing cycles and traversal @@ -4623,7 +4633,9 @@ Git checks use native `check-ignore` and protect tracked descendants with `ls-fi No `.gitignore`, index, or project configuration files are written by this command. A nested `.git` directory or gitfile does not bypass its containing repository's ignore rules; tracked descendants and submodule gitlinks remain protected by the parent index. -Directory indexing exclusions affect analysis and search, not Git repository ownership or status. +Manual directory exclusions also constrain automatic Git root discovery when supplied to +`workspace.repositories`; they never filter an included repository's status or commit candidates. +Automatic analysis exclusions and include exceptions do not configure Git root membership. `workspace.ignoredDirectorySuggestions` accepts `{ root, workspaceRoots? }` and requires an installed indexing policy. It returns `{ directories, incomplete, gitIgnoreFailed }`: diff --git a/shared/fixtures/workspace/ignored-directory-suggestions-v1.json b/shared/fixtures/workspace/ignored-directory-suggestions-v1.json index fc1d930226..6c9d3e541d 100644 --- a/shared/fixtures/workspace/ignored-directory-suggestions-v1.json +++ b/shared/fixtures/workspace/ignored-directory-suggestions-v1.json @@ -1,13 +1,50 @@ { "schemaVersion": 1, "command": "workspace.ignoredDirectorySuggestions", - "request": { "root": "/workspace", "workspaceRoots": ["/workspace"] }, + "request": { + "root": "/workspace", + "workspaceRoots": [ + "/workspace" + ] + }, "gitignore": "ignored\n", "nestedRepository": "ignored", "cases": [ - { "gitIgnoreEnabled": true, "manualExcludedDirectories": [], "includedDirectories": [], "expectedRepositoryCount": 2, "expectedSuggestions": [] }, - { "gitIgnoreEnabled": false, "manualExcludedDirectories": [], "includedDirectories": [], "expectedRepositoryCount": 2, "expectedSuggestions": ["ignored"] }, - { "gitIgnoreEnabled": true, "manualExcludedDirectories": [], "includedDirectories": ["ignored"], "expectedRepositoryCount": 2, "expectedSuggestions": ["ignored"] }, - { "gitIgnoreEnabled": false, "manualExcludedDirectories": ["ignored"], "includedDirectories": [], "expectedRepositoryCount": 2, "expectedSuggestions": [] } + { + "gitIgnoreEnabled": true, + "manualExcludedDirectories": [], + "includedDirectories": [], + "expectedRepositoryCount": 1, + "expectedSuggestions": [] + }, + { + "gitIgnoreEnabled": false, + "manualExcludedDirectories": [], + "includedDirectories": [], + "expectedRepositoryCount": 1, + "expectedSuggestions": [ + "ignored" + ] + }, + { + "gitIgnoreEnabled": true, + "manualExcludedDirectories": [], + "includedDirectories": [ + "ignored" + ], + "expectedRepositoryCount": 1, + "expectedSuggestions": [ + "ignored" + ] + }, + { + "gitIgnoreEnabled": false, + "manualExcludedDirectories": [ + "ignored" + ], + "includedDirectories": [], + "expectedRepositoryCount": 1, + "expectedSuggestions": [] + } ] } diff --git a/shared/fixtures/workspace/repository-visibility-v1.json b/shared/fixtures/workspace/repository-visibility-v1.json index 21a5260077..873b92a007 100644 --- a/shared/fixtures/workspace/repository-visibility-v1.json +++ b/shared/fixtures/workspace/repository-visibility-v1.json @@ -9,8 +9,25 @@ "build/package", "DerivedData/package", "visible", + "vendor/source", + "test/test2", + "manual/project" + ], + "expectedRepositoryPaths": [ + ".", + "visible", "vendor/source" ], - "expectedRepositoryPaths": [".", "visible", "vendor/source"], - "explicitRepositoryPaths": [".build/checkouts/SwiftTerm", ".cache/package"] + "explicitRepositoryPaths": [ + ".build/checkouts/SwiftTerm", + ".cache/package" + ], + "gitIgnorePatterns": [ + ".build/", + "macos/.build/", + "test/" + ], + "manualExcludedDirectories": [ + "manual" + ] } diff --git a/shared/platform-feature-matrix/features/git-repository-discovery.json b/shared/platform-feature-matrix/features/git-repository-discovery.json index 7ccef3117c..ea0798ae17 100644 --- a/shared/platform-feature-matrix/features/git-repository-discovery.json +++ b/shared/platform-feature-matrix/features/git-repository-discovery.json @@ -2,12 +2,12 @@ "id": "git-repository-discovery", "area": "版本控制", "group": "Git", - "capability": "仓库自动发现排除隐藏目录与构建缓存", + "capability": "仓库自动发现遵循 Git 忽略、手动排除及显式根边界", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ - "rust/lithe-core/src/git/mod.rs", + "rust/lithe-core/src/git/repository_discovery.rs", "rust/lithe-core/src/tests/git_repository_discovery.rs", "macos/Sources/Lithe/Core/Rust/RustGitOperations.swift" ] @@ -16,13 +16,13 @@ "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ - "rust/lithe-core/src/git/mod.rs", + "rust/lithe-core/src/git/repository_discovery.rs", "rust/lithe-core/src/tests/git_repository_discovery.rs", "windows/tauri/src-tauri/src/platform.rs", "windows/tauri/src/features/git/api/git-repo-api.ts" ] }, "owner": "Git", - "verification": "打开含 .build/checkouts、macos/.build/checkouts、.cache、node_modules 与可见嵌套仓库的项目,确认 Git 状态和引用只包含主仓库及可见源码仓库;.worktrees 和 .worktree 中的真实工作树仍可发现,其构建缓存仍被排除。直接打开隐藏仓库及其源码子目录应能使用 Git。", - "notes": "两端复用共享扫描规则和 fixture;Linux Core 集成验证不能代替 macOS、Windows 原生界面验收。" + "verification": "打开含被 Git 忽略目录、仅手动排除目录、构建缓存和普通可见子仓库的项目,确认提交、分支和日志只自动纳入合格仓库;改变规则后及时更新且旧请求不回填。显式打开被排除根仍可管理;多根分别使用自己的排除快照,真实 gitlink 子模块保留依赖发现。验证父仓库已跟踪改动和普通未跟踪文件不被手动排除过滤、查询失败保留旧快照,以及链接工作树、失效 gitfile 和取消。", + "notes": "两端使用请求局部的共享发现规则;分析开关和包含例外不注册 Git 根。隔离 macOS QA 已验证提交、分支、日志及显式打开例外,手动标记通过隔离偏好域预置。固定 Swift 工具链及 Windows 原生验收尚未完成;Core、Swift 模型和前端组件测试不代替最终原生验收。" } diff --git a/windows/tauri/src/features/git/api/git-repo-api.test.ts b/windows/tauri/src/features/git/api/git-repo-api.test.ts index 11fe11762c..df38257d00 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.test.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.test.ts @@ -1,4 +1,6 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { directoryMarksStorageKey } from "@/features/file-explorer/stores/directory-marks-storage"; import pathCases from "../../../../../../shared/fixtures/git/windows-paths.json"; @@ -19,9 +21,12 @@ mock.module("@/features/file-system/controllers/platform", () => ({ const { clearRepositoryDiscoveryCache, discoverWorkspaceRepositories, + invalidateWorkspaceRepositoryDiscovery, normalizeRepositoryPath, resolveRepositoryForFile, } = await import("./git-repo-api"); +let restoreDom: (() => void) | undefined; +afterEach(() => { restoreDom?.(); restoreDom = undefined; }); beforeEach(() => { invoke.mockReset(); @@ -62,6 +67,7 @@ describe("discoverWorkspaceRepositories", () => { expect(invoke).toHaveBeenCalledWith("git_discover_workspace_repos", { workspacePath: "D:/work", + manualExcludedDirectories: [], workspaceRoots: ["D:/work"], }); expect(readDirectory).not.toHaveBeenCalled(); expect(result).toEqual(["D:/work/service-b", "D:/work/service-a"]); @@ -96,14 +102,60 @@ describe("discoverWorkspaceRepositories", () => { expect(invoke).toHaveBeenNthCalledWith(1, "git_discover_workspace_repos", { workspacePath: "D:/work-a", + manualExcludedDirectories: [], workspaceRoots: ["D:/work-a", "D:/work-b"], }); expect(invoke).toHaveBeenNthCalledWith(2, "git_discover_workspace_repos", { workspacePath: "D:/work-b", + manualExcludedDirectories: [], workspaceRoots: ["D:/work-a", "D:/work-b"], }); expect(result).toEqual(["D:/work-a/repo-a", "D:/work-b/repo-b"]); }); }); +test("discovery binds each root's manual exclusions and changes cache identity with preferences", async () => { + restoreDom = installHappyDom(); + const roots = ["D:/parent", "D:/parent/nested"]; + const mark = (root: string, marks: Record) => window.localStorage.setItem( + directoryMarksStorageKey(root), JSON.stringify({version: 1, marks})); + mark(roots[0], {nested: "excluded", source: "sources"}); + mark(roots[1], {cache: "excluded"}); + invoke.mockResolvedValue({repositories: [{path: roots[0]}]}); + await discoverWorkspaceRepositories(roots); + expect(invoke.mock.calls.map(([, args]) => args)).toEqual([ + {workspacePath: roots[0], manualExcludedDirectories: ["nested"], workspaceRoots: roots}, + {workspacePath: roots[1], manualExcludedDirectories: ["cache"], workspaceRoots: roots}, + ]); + await discoverWorkspaceRepositories(roots); + expect(invoke).toHaveBeenCalledTimes(2); + mark(roots[1], {}); + await discoverWorkspaceRepositories(roots); + expect(invoke).toHaveBeenCalledTimes(4); + await discoverWorkspaceRepositories([roots[1]]); + expect(invoke).toHaveBeenCalledTimes(5); +}); + +test("failed forced discovery cannot turn a stale cached snapshot into a successful retry", async () => { + invoke.mockResolvedValue({repositories: [{path: "D:/root"}]}); + expect(await discoverWorkspaceRepositories("D:/root")).toEqual(["D:/root"]); + invoke.mockRejectedValue(new Error("query failed")); + await expect(discoverWorkspaceRepositories("D:/root", {force: true})).rejects.toThrow("query failed"); + invoke.mockResolvedValue({repositories: [{path: "D:/root/new"}]}); + expect(await discoverWorkspaceRepositories("D:/root")).toEqual(["D:/root/new"]); + expect(invoke).toHaveBeenCalledTimes(3); +}); + +test("rule invalidation retires only the affected workspace discovery cache", async () => { + invoke.mockResolvedValue({repositories: [{path: "D:/root"}]}); + await discoverWorkspaceRepositories("D:/root"); + invoke.mockResolvedValue({repositories: [{path: "D:/other"}]}); + await discoverWorkspaceRepositories("D:/other"); + invalidateWorkspaceRepositoryDiscovery(["D:/root"]); + invoke.mockResolvedValue({repositories: [{path: "D:/root/new"}]}); + expect(await discoverWorkspaceRepositories("D:/root")).toEqual(["D:/root/new"]); + expect(await discoverWorkspaceRepositories("D:/other")).toEqual(["D:/other"]); + expect(invoke).toHaveBeenCalledTimes(3); +}); + describe("normalizeRepositoryPath", () => { test("preserves drive roots and remote schemes while collapsing separators", () => { expect(normalizeRepositoryPath("C://")).toBe("C:/"); diff --git a/windows/tauri/src/features/git/api/git-repo-api.ts b/windows/tauri/src/features/git/api/git-repo-api.ts index c201596573..dfe517fccb 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.ts @@ -1,5 +1,6 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; import { normalizeGitOperationPath as normalizePath, UnsupportedGitPathError } from "./git-repository-path"; +import { readDirectoryMarks } from "@/features/file-explorer/stores/directory-marks-storage"; export { normalizeRepositoryPath } from "./git-repository-path"; interface RepositoryDiscoveryCacheEntry { @@ -29,6 +30,30 @@ const WORKSPACE_REPO_CACHE_TTL_MS = 5 * 60_000; const REPO_CACHE_TTL_MS = 5 * 60_000; const NEGATIVE_REPO_CACHE_TTL_MS = 5_000; +function discoverySnapshot(roots: readonly string[]) { + return roots.map((root) => ({ + workspacePath: normalizePath(root), + manualExcludedDirectories: typeof window === "undefined" ? [] : Object.entries(readDirectoryMarks(root)) + .filter(([, role]) => role === "excluded").map(([path]) => path).sort(), + })); +} + +/** Request identity includes only manual exclusions, never analysis settings or automatic reasons. */ +export function workspaceRepositoryDiscoveryPolicyKey(roots: readonly string[]): string { + return JSON.stringify(discoverySnapshot(roots)); +} + +/** Retire cached and in-flight automatic discovery for these workspace roots. */ +export function invalidateWorkspaceRepositoryDiscovery(roots: readonly string[]): void { + const targets = new Set(roots.map(normalizePath)); + for (const key of new Set([...workspaceRepoDiscoveryCache.keys(), ...inFlightWorkspaceDiscoveries.keys()])) { + const snapshot = JSON.parse(key) as ReturnType; + if (!snapshot.some(({workspacePath}) => targets.has(workspacePath))) continue; + workspaceRepoDiscoveryCache.delete(key); + inFlightWorkspaceDiscoveries.delete(key); + } +} + function isAbsolutePath(path: string): boolean { return ( path.startsWith("/") || @@ -205,9 +230,11 @@ export async function discoverWorkspaceRepositories( .map((path) => normalizePath(path)) .filter(Boolean))]; if (normalizedWorkspacePaths.length === 0) return []; - const normalizedWorkspacePath = [...new Set(normalizedWorkspacePaths)].join("\0"); + const snapshot = discoverySnapshot(normalizedWorkspacePaths); + const normalizedWorkspacePath = JSON.stringify(snapshot); const force = options?.force ?? false; + if (force) workspaceRepoDiscoveryCache.delete(normalizedWorkspacePath); if (!force) { const cached = workspaceRepoDiscoveryCache.get(normalizedWorkspacePath); if (cached && Date.now() - cached.discoveredAt < WORKSPACE_REPO_CACHE_TTL_MS) { @@ -221,9 +248,10 @@ export async function discoverWorkspaceRepositories( } const generation = discoveryGeneration; - const request = discoverWorkspaceRepositoriesFromCore(normalizedWorkspacePaths).then((repos) => { + const request = discoverWorkspaceRepositoriesFromCore(normalizedWorkspacePaths, snapshot).then((repos) => { // An older forced scan must not overwrite the newest result in the cache. - if (generation === discoveryGeneration && inFlightWorkspaceDiscoveries.get(normalizedWorkspacePath) === request) { + if (generation === discoveryGeneration && inFlightWorkspaceDiscoveries.get(normalizedWorkspacePath) === request && + workspaceRepositoryDiscoveryPolicyKey(normalizedWorkspacePaths) === normalizedWorkspacePath) { workspaceRepoDiscoveryCache.set(normalizedWorkspacePath, { discoveredAt: Date.now(), repos }); } return repos; @@ -238,14 +266,17 @@ export async function discoverWorkspaceRepositories( async function discoverWorkspaceRepositoriesFromCore( normalizedWorkspacePaths: readonly string[], + snapshot: ReturnType, ): Promise { const repositoryPaths = new Set(); const repositoriesByWorkspace = await Promise.all( - normalizedWorkspacePaths.map(async (workspacePath) => { + snapshot.map(async ({ workspacePath, manualExcludedDirectories }) => { const response = await tauriInvoke( "git_discover_workspace_repos", { workspacePath, + manualExcludedDirectories, + workspaceRoots: normalizedWorkspacePaths, }, ); const paths = Array.isArray(response.repositories) diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index 897319bfb9..726c26fd39 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -687,6 +687,24 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { return branchDiffBranches.filter((branch) => matchesSearchQuery(query, [branch])); }, [branchDiffBranches, branchDiffSearchQuery]); + const showLoadError = hasLoadError; + const loadError = ( +
+ {t("git.statusLoadFailed")} + +
+ ); + if (!activeRepoPath) { return ( <> @@ -727,7 +745,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { > {renderActionsButton()} - {repoPath ? ( + {hasLoadError ? loadError : repoPath ? ( { ); } - const showLoadError = hasLoadError; - const loadError = ( -
- {t("git.statusLoadFailed")} - -
- ); if (!gitStatus) { return ( diff --git a/windows/tauri/src/features/git/events/git-events.ts b/windows/tauri/src/features/git/events/git-events.ts index b64c433aef..64ec0ae90d 100644 --- a/windows/tauri/src/features/git/events/git-events.ts +++ b/windows/tauri/src/features/git/events/git-events.ts @@ -15,6 +15,8 @@ export interface GitChange { filePath?: string; scopes?: GitChangeScope[]; source?: string; + /** Ignore rules, gitlinks or checkout metadata can change automatic root membership. */ + repositoryRootsMayHaveChanged?: boolean; } const PASSIVE_GIT_CHANGE_SOURCES = new Set(["save", "auto-save", "external-file-change", "external-git-change"]); diff --git a/windows/tauri/src/features/git/hooks/use-git-data-controller.integration.test.tsx b/windows/tauri/src/features/git/hooks/use-git-data-controller.integration.test.tsx index 67020b8dd5..0b471ad778 100644 --- a/windows/tauri/src/features/git/hooks/use-git-data-controller.integration.test.tsx +++ b/windows/tauri/src/features/git/hooks/use-git-data-controller.integration.test.tsx @@ -13,6 +13,7 @@ import * as events from "../events/git-events"; import * as fileSystem from "@/features/file-system/stores/file-system.store"; import * as settings from "@/features/settings/stores/settings.store"; import { useRepositoryStore } from "../stores/git-repository.store"; +import { GitStatusRefreshHost, type GitStatusRefreshScheduler } from "../runtime/git-status-refresh-host"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { WorkspaceStoreScopeContext } from "@/features/workspace/stores/create-workspace-scoped-store"; @@ -48,9 +49,10 @@ const getWorkspaceGitStatus = mock(async (): Promise => status const getGitHistory = mock(async (): Promise => history); const getBranches = mock(async (): Promise => ["main"]); const clearRepositoryDiscoveryCache = mock(() => {}); +const refreshWorkspaceRepositories = mock(async () => {}); const repositoryActions = { syncWorkspaceRepositories: async () => {}, - refreshWorkspaceRepositories: async () => {}, + refreshWorkspaceRepositories, }; const folders: never[] = []; const spies: Array<{ mockRestore: () => void }> = []; @@ -89,6 +91,7 @@ beforeEach(() => { workspaceRuntimeRegistry.resetForTests(); workspaceRuntimeRegistry.updateWorkspaceStatus("workspace:welcome", "ready"); clearRepositoryDiscoveryCache.mockClear(); + refreshWorkspaceRepositories.mockReset().mockResolvedValue(); useRepositoryStore.getState().actions.reset(); useRepositoryStore.getState().actions.setManualRepository("C:/repo"); spies.push( @@ -134,6 +137,78 @@ afterEach(async () => { } }); +test("discovery failures keep status visible and retry roots before reading status", async () => { + await mount(); + const previous = useGitStore.getState().repositoryStatuses; + const reads = getWorkspaceGitStatus.mock.calls.length; + refreshWorkspaceRepositories.mockImplementation(async () => { + useRepositoryStore.setState({error: "ignore query failed"}); + }); + await act(async () => { await controller.refresh(); }); + expect(controller.hasLoadError).toBe(true); + expect(getWorkspaceGitStatus.mock.calls.length).toBe(reads); + expect(useGitStore.getState().repositoryStatuses).toBe(previous); + const order: string[] = []; + refreshWorkspaceRepositories.mockImplementation(async () => { + order.push("roots"); useRepositoryStore.setState({error: null}); + }); + getWorkspaceGitStatus.mockImplementation(async () => { order.push("status"); return status; }); + await act(async () => { await controller.refresh(); }); + expect(order[0]).toBe("roots"); + expect(order).toContain("status"); + expect(controller.hasLoadError).toBe(false); +}); + +test("the debounce window blocks new status reads against invalidated repository roots", async () => { + let scheduled: (() => void) | undefined; + const scheduler: GitStatusRefreshScheduler = { + setTimer: (callback) => { scheduled = callback; return 1 as unknown as ReturnType; }, + clearTimer: () => { scheduled = undefined; }, + }; + spies.push( + spyOn(fileSystem, "useFileSystemStore").mockImplementation(((selector: (state: unknown) => unknown) => + selector({rootFolderPath: "C:/repo", workspaceFolders: folders})) as typeof fileSystem.useFileSystemStore), + spyOn(useRepositoryStore.getState().actions, "syncWorkspaceRepositories").mockResolvedValue(), + spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(async () => { + useRepositoryStore.setState({error: "ignore query failed", isDiscovering: false}); + }), + ); + await mount(<>); + const reads = getWorkspaceGitStatus.mock.calls.length; + await act(async () => { + window.dispatchEvent(new window.CustomEvent("file-external-change", { + detail: {path: "C:/repo/.gitignore", event_type: "reloaded"}, + })); + await controller.refreshWorkingTree(); + }); + expect(useRepositoryStore.getState().isDiscovering).toBe(true); + expect(getWorkspaceGitStatus.mock.calls.length).toBe(reads); + await act(async () => { const callback = scheduled; scheduled = undefined; callback?.(); }); + expect(controller.hasLoadError).toBe(true); + expect(getWorkspaceGitStatus.mock.calls.length).toBe(reads); +}); + +test("discovery invalidation ends initial loading and exposes failure before a late status returns", async () => { + const pendingStatus = deferred(); + getWorkspaceGitStatus.mockImplementationOnce(() => pendingStatus.promise); + await mount(); + try { + expect(useGitStore.getState().isLoadingGitData).toBe(true); + await act(async () => { useRepositoryStore.setState({isDiscovering: true}); }); + expect(useGitStore.getState().isLoadingGitData).toBe(false); + await act(async () => { + useRepositoryStore.setState({isDiscovering: false, error: "ignore query failed"}); + }); + expect(controller.hasLoadError).toBe(true); + expect(useGitStore.getState().gitStatus).toBeNull(); + expect(useGitStore.getState().isLoadingGitData).toBe(false); + } finally { + await act(async () => { pendingStatus.resolve(status); await pendingStatus.promise; }); + } + expect(useGitStore.getState().gitStatus).toBeNull(); + expect(useGitStore.getState().isLoadingGitData).toBe(false); +}); + test("starts Git loading when the workspace becomes ready", async () => { workspaceRuntimeRegistry.updateWorkspaceStatus("workspace:welcome", "opening"); await mount(); diff --git a/windows/tauri/src/features/git/hooks/use-git-data-controller.ts b/windows/tauri/src/features/git/hooks/use-git-data-controller.ts index 666f8fcf07..77714fae42 100644 --- a/windows/tauri/src/features/git/hooks/use-git-data-controller.ts +++ b/windows/tauri/src/features/git/hooks/use-git-data-controller.ts @@ -42,7 +42,9 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl const autoRefreshGitStatus = useSettingsStore((state) => state.settings.autoRefreshGitStatus); const workspaceFolders = useFileSystemStore((state) => state.workspaceFolders); const [failedRepoPath, setFailedRepoPath] = useState(null); - const hasLoadError = activeRepoPath !== null && failedRepoPath === activeRepoPath; + const discoveryError = useRepositoryStore.use.error(); + const isDiscovering = useRepositoryStore.use.isDiscovering(); + const hasLoadError = !!discoveryError || (activeRepoPath !== null && failedRepoPath === activeRepoPath); const requestIdRef = useRef(0); const refreshQueueRef = useRef(createGitRefreshQueue()); const changeRefreshTimerRef = useRef | null>(null); @@ -53,6 +55,8 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl if (!workspaceRuntimeRegistry.isWorkspaceReady(workspaceId)) { return; } + const repositories = useRepositoryStore.getState(); + if (repositories.error || repositories.isDiscovering) return; const repoPath = activeRepoPath; if (!repoPath) { return; @@ -128,13 +132,14 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl gitActions.setIsLoadingGitData(false); } } - }, [activeRepoPath, availableRepoPaths, gitActions, workspaceId]); + }, [activeRepoPath, availableRepoPaths, gitActions, workspaceId, discoveryError, isDiscovering]); const refreshGitData = useCallback( async (scopes?: GitChangeScope[], throwOnError = false, source: "user" | "background" = "user") => { if (!workspaceRuntimeRegistry.isWorkspaceReady(workspaceId)) return; + const repositories = useRepositoryStore.getState(); const repoPath = activeRepoPath; - if (!repoPath) return; + if (!repoPath || repositories.error || repositories.isDiscovering || repositories.activeRepoPath !== repoPath) return; const refreshKey = `${repoPath}\0${source}\0${scopes?.slice().sort().join(",") || "*"}`; const requestId = requestIdRef.current; @@ -142,6 +147,9 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl // The queue starts on a later microtask and may execute a trailing // refresh after the workspace lifecycle has changed. if (!workspaceRuntimeRegistry.isWorkspaceReady(workspaceId)) return; + const currentRepositories = useRepositoryStore.getState(); + if (currentRepositories.error || currentRepositories.isDiscovering || + currentRepositories.activeRepoPath !== repoPath) return; // Allocate per actual read, including trailing reads, rather than per // caller joining a coalesced request. const workingTreeVersion = gitActions.beginWorkingTreeRefresh(); @@ -225,7 +233,9 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl if (hasLoadError) clearRepositoryDiscoveryCache(); gitActions.setIsRefreshing(true); try { - await Promise.all([refreshGitData(), refreshWorkspaceRepositories()]); + gitActions.invalidateRepositoryReads(); + await refreshWorkspaceRepositories(); + if (!useRepositoryStore.getState().error) await refreshGitData(); } finally { gitActions.setIsRefreshing(false); } @@ -247,6 +257,7 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl return () => { requestIdRef.current += 1; + gitActions.setIsLoadingGitData(false); }; }, [loadInitialGitData, workspaceReady]); diff --git a/windows/tauri/src/features/git/runtime/git-metadata-watch-host.tsx b/windows/tauri/src/features/git/runtime/git-metadata-watch-host.tsx index a1aa38d781..71b46a5242 100644 --- a/windows/tauri/src/features/git/runtime/git-metadata-watch-host.tsx +++ b/windows/tauri/src/features/git/runtime/git-metadata-watch-host.tsx @@ -29,6 +29,7 @@ export function gitChangeForMetadata( ? ["working-tree"] : ["working-tree", "history", "refs", "stashes", "repository"], source: "external-git-change", + repositoryRootsMayHaveChanged: true, }; } diff --git a/windows/tauri/src/features/git/runtime/git-status-refresh-host.test.tsx b/windows/tauri/src/features/git/runtime/git-status-refresh-host.test.tsx index 8baa5960bf..5e7fba8acd 100644 --- a/windows/tauri/src/features/git/runtime/git-status-refresh-host.test.tsx +++ b/windows/tauri/src/features/git/runtime/git-status-refresh-host.test.tsx @@ -77,6 +77,9 @@ beforeEach(() => { spies.push( spyOn(statusApi, "getRepositoryGitStatuses").mockImplementation(readStatuses), spyOn(useRepositoryStore.getState().actions, "syncWorkspaceRepositories").mockResolvedValue(), + spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(async () => { + useRepositoryStore.setState({isDiscovering: false}); + }), spyOn(fileSystem, "useFileSystemStore").mockImplementation((( selector: (state: { rootFolderPath: string; workspaceFolders: typeof folders }) => unknown, ) => @@ -153,6 +156,115 @@ test("unrelated repository changes do not query status and unmount cancels pendi expect(readStatuses).toHaveBeenCalledTimes(1); }, 1000); +test("manual marks, ignore files and focus rediscover roots before refreshing status", async () => { + const refreshRoots = mock(async () => { useRepositoryStore.setState({isDiscovering: false}); }); + spies.push(spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(refreshRoots)); + await mount(); + for (const event of [ + new window.CustomEvent("lithe:directory-marks-changed", {detail: repo}), + new window.CustomEvent("file-external-change", {detail: {path: `${repo}/.gitignore`, event_type: "reloaded"}}), + new window.Event("focus"), + ]) { + const statusCalls = readStatuses.mock.calls.length; + await act(async () => { window.dispatchEvent(event); }); + await act(async () => { timer.fire(); }); + expect(readStatuses.mock.calls.length).toBe(statusCalls + 1); + } + expect(refreshRoots).toHaveBeenCalledTimes(3); +}, 1000); + +test("a failed root refresh preserves the published status snapshot", async () => { + spies.push(spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(async () => { + useRepositoryStore.setState({error: "Git ignore query failed", isDiscovering: false}); + })); + await mount(); + const previous = useGitStore.getState().repositoryStatuses; + emitGitChanged({repoPath: repo, scopes: ["repository"]}); + await act(async () => { timer.fire(); }); + expect(readStatuses).toHaveBeenCalledTimes(1); + expect(useGitStore.getState().repositoryStatuses).toBe(previous); +}, 1000); + +test("a status read started before rule changes cannot publish after discovery fails", async () => { + spies.push(spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(async () => { + useRepositoryStore.setState({error: "Git ignore query failed", isDiscovering: false}); + })); + await mount(); + const previous = useGitStore.getState().repositoryStatuses; + let release!: (value: { [repo]: GitStatus }) => void; + const delayed = new Promise<{ [repo]: GitStatus }>((resolve) => { release = resolve; }); + readStatuses.mockImplementationOnce(() => delayed); + const older = useGitStore.getState().actions.refreshRepositoryStatuses([repo], [repo]); + try { + emitGitChanged({repoPath: repo, scopes: ["repository"]}); + await act(async () => { timer.fire(); }); + } finally { + await act(async () => { release({[repo]: snapshot("stale", 20)}); await older; }); + } + expect(useGitStore.getState().repositoryStatuses).toBe(previous); + expect(useGitStore.getState().repositoryStatuses[repo]?.branch).toBe("old"); +}, 1000); + +test("rule changes during discovery drain the newer request after the older read ends", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const rootQueries = mock(async () => { useRepositoryStore.setState({isDiscovering: false}); }); + rootQueries.mockImplementationOnce(() => gate); + spies.push(spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(rootQueries)); + await mount(); + try { + emitGitChanged({repoPath: repo, scopes: ["repository"]}); + await act(async () => { timer.fire(); }); + expect(rootQueries).toHaveBeenCalledTimes(1); + emitGitChanged({repoPath: repo, scopes: ["repository"]}); + await act(async () => { timer.fire(); }); + expect(rootQueries).toHaveBeenCalledTimes(1); + } finally { await act(async () => { release(); await gate; }); } + expect(rootQueries).toHaveBeenCalledTimes(2); + expect(useRepositoryStore.getState().isDiscovering).toBe(false); + expect(readStatuses).toHaveBeenCalledTimes(2); +}, 1000); + +test("selection changes preserve discovery queued before the debounce fires", async () => { + const child = `${repo}/child`; + useRepositoryStore.setState({availableRepoPaths: [repo, child]}); + const refreshRoots = mock(async () => { useRepositoryStore.setState({isDiscovering: false}); }); + spies.push(spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(refreshRoots)); + await mount(); + await act(async () => { emitGitChanged({repoPath: repo, scopes: ["repository"]}); }); + expect(useRepositoryStore.getState().isDiscovering).toBe(true); + expect(timer.callbacks.size).toBe(1); + await act(async () => { useRepositoryStore.getState().actions.selectRepository(child); }); + expect(timer.callbacks.size).toBe(0); + expect(refreshRoots).toHaveBeenCalledTimes(1); + expect(useRepositoryStore.getState().isDiscovering).toBe(false); +}, 1000); + +test("selection changes during discovery still refresh unchanged repository paths", async () => { + const child = `${repo}/child`; + const paths = [repo, child]; + useRepositoryStore.setState({availableRepoPaths: paths}); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + spies.push(spyOn(useRepositoryStore.getState().actions, "refreshWorkspaceRepositories").mockImplementation(async () => { + await gate; + useRepositoryStore.setState({isDiscovering: false}); + })); + await mount(); + let selectedPaths: string[] | undefined; + try { + emitGitChanged({repoPath: repo, scopes: ["repository"]}); + await act(async () => { timer.fire(); }); + await act(async () => { useRepositoryStore.getState().actions.selectRepository(child); }); + selectedPaths = useRepositoryStore.getState().availableRepoPaths; + nextStatus = snapshot("after-discovery", 0); + expect(readStatuses).toHaveBeenCalledTimes(1); + } finally { await act(async () => { release(); await gate; }); } + expect(useRepositoryStore.getState().availableRepoPaths).toBe(selectedPaths); + expect(useGitStore.getState().repositoryStatuses[repo]?.branch).toBe("after-discovery"); + expect(readStatuses).toHaveBeenCalledTimes(2); +}, 1000); + test("metadata watches cover every repository, replace links and release all identifiers", async () => { const child = `${repo}/child`; useRepositoryStore.setState({ availableRepoPaths: [repo, child] }); diff --git a/windows/tauri/src/features/git/runtime/git-status-refresh-host.tsx b/windows/tauri/src/features/git/runtime/git-status-refresh-host.tsx index b5d32cd479..88009c5597 100644 --- a/windows/tauri/src/features/git/runtime/git-status-refresh-host.tsx +++ b/windows/tauri/src/features/git/runtime/git-status-refresh-host.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { normalizeWorkspaceFolders } from "@/features/file-system/controllers/workspace-session"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { @@ -9,6 +9,7 @@ import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace import { isGitChangeRelevant, subscribeToGitChanges } from "../events/git-events"; import { useRepositoryStore } from "../stores/git-repository.store"; import { useGitStore } from "../stores/git.store"; +import { subscribeToIndexingChanges } from "@/features/file-explorer/stores/workspace-indexing"; export interface GitStatusRefreshScheduler { setTimer: (callback: () => void, delay: number) => ReturnType; @@ -34,11 +35,16 @@ export function GitStatusRefreshHost({ const repoPaths = useRepositoryStore((state) => state.availableRepoPaths); const repositoryActions = useRepositoryStore((state) => state.actions); const actions = useGitStore((state) => state.actions); + // Selection changes recreate the subscription effect; keep queued discovery + // owned by the workspace rather than by its cancelled debounce timer. + const rediscover = useRef(false); useEffect(() => { + rediscover.current = false; if (!ready) return; void repositoryActions.syncWorkspaceRepositories( normalizeWorkspaceFolders(rootPath, folders).map((folder) => folder.path), + { force: true }, ); }, [ready, rootPath, folders, repositoryActions]); @@ -46,29 +52,74 @@ export function GitStatusRefreshHost({ if (!rootPath || !ready) return; let current = true; let timer: ReturnType | null = null; + let running = false; + let pending = false; + const roots = normalizeWorkspaceFolders(rootPath, folders).map((folder) => folder.path); + const repositoryStore = useRepositoryStore.getStore(workspaceId); const refresh = async () => { if (!current || !workspaceRuntimeRegistry.isWorkspaceReady(workspaceId)) return; - const paths = repoPaths.length ? repoPaths : useGitStore.getState().statusRepoPaths; + if (running) { pending = true; return; } + running = true; try { - await actions.refreshRepositoryStatuses([...paths, rootPath], paths); + do { + pending = false; + if (rediscover.current) { + rediscover.current = false; + await repositoryActions.refreshWorkspaceRepositories(); + } + if (!current || !workspaceRuntimeRegistry.isWorkspaceReady(workspaceId)) return; + const repositories = repositoryStore.getState(); + if (repositories.error || repositories.isDiscovering) { + if (pending) continue; + return; + } + const paths = repositories.availableRepoPaths; + await actions.refreshRepositoryStatuses([...paths, rootPath], paths); + } while (pending && current); } catch (error) { if (current) console.error("Failed to refresh workspace Git status:", error); + } finally { + running = false; } }; - if (activeRepoPath) void refresh(); - const unsubscribe = subscribeToGitChanges((change) => { - if (![rootPath, ...repoPaths].some((path) => isGitChangeRelevant(change, path))) return; + const schedule = (discover = false) => { + rediscover.current ||= discover; + if (discover) { + actions.invalidateRepositoryReads(); + repositoryActions.invalidateWorkspaceRepositories(); + } if (timer !== null) scheduler.clearTimer(timer); - timer = scheduler.setTimer(() => { - timer = null; + timer = scheduler.setTimer(() => { timer = null; void refresh(); }, 100); + }; + if (activeRepoPath || rediscover.current) void refresh(); + // A discovery started by an earlier subscription may finish after a + // selection change. Its owner is gone, but this workspace still needs status. + const unsubscribeDiscovery = repositoryStore.subscribe((state, previous) => { + if (previous.isDiscovering && !state.isDiscovering && !state.error && !running) { void refresh(); - }, 100); + } }); + const unsubscribe = subscribeToGitChanges((change) => { + if (![...roots, ...repoPaths].some((path) => isGitChangeRelevant(change, path))) return; + schedule(change.repositoryRootsMayHaveChanged || change.scopes?.includes("repository") || + /(^|[/\\])\.gitignore$/.test(change.filePath ?? "")); + }); + const unsubscribePreferences = subscribeToIndexingChanges(roots, () => schedule(true), true); + const externalFile = (event: Event) => { + const { path, event_type: kind } = (event as CustomEvent<{path: string; event_type: string}>).detail; + if (!roots.some((root) => isGitChangeRelevant({repoPath: path}, root))) return; + if (kind === "rescan" || /(^|[/\\])\.(gitignore$|git([/\\]|$))/.test(path)) schedule(true); + }; + const focus = () => schedule(true); + window.addEventListener("file-external-change", externalFile); + window.addEventListener("focus", focus); return () => { current = false; - unsubscribe(); + unsubscribe(); unsubscribePreferences(); unsubscribeDiscovery(); + window.removeEventListener("file-external-change", externalFile); + window.removeEventListener("focus", focus); if (timer !== null) scheduler.clearTimer(timer); }; - }, [ready, workspaceId, rootPath, activeRepoPath, repoPaths, actions, scheduler]); + }, [ready, workspaceId, rootPath, folders, activeRepoPath, repoPaths, actions, repositoryActions, scheduler]); return null; } diff --git a/windows/tauri/src/features/git/stores/git-repository.store.test.ts b/windows/tauri/src/features/git/stores/git-repository.store.test.ts index 2569020c65..b20cae5560 100644 --- a/windows/tauri/src/features/git/stores/git-repository.store.test.ts +++ b/windows/tauri/src/features/git/stores/git-repository.store.test.ts @@ -1,15 +1,61 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; import * as repoApi from "../api/git-repo-api"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { directoryMarksStorageKey } from "@/features/file-explorer/stores/directory-marks-storage"; const discoverWorkspaceRepositories = mock(async (): Promise => ["C:/repo"]); const realDiscoverWorkspaceRepositories = repoApi.discoverWorkspaceRepositories; const { createGitRepositoryStore } = await import("./git-repository.store"); let discoverySpy: ReturnType>; +let restoreDom: (() => void) | undefined; beforeEach(() => { discoverWorkspaceRepositories.mockReset().mockImplementation(async () => ["C:/repo"]); discoverySpy = spyOn(repoApi, "discoverWorkspaceRepositories").mockImplementation(discoverWorkspaceRepositories); }); -afterEach(() => discoverySpy.mockRestore()); +afterEach(() => { discoverySpy.mockRestore(); restoreDom?.(); restoreDom = undefined; }); + +test("manual exclusions supersede an in-flight scan and remove its automatic active root", async () => { + restoreDom = installHappyDom(); + const store = createGitRepositoryStore(); + const child = "C:/repo/child"; + discoverWorkspaceRepositories.mockResolvedValue(["C:/repo", child]); + await store.getState().actions.syncWorkspaceRepositories("C:/repo"); + store.getState().actions.selectRepository(child); + let release!: (roots: string[]) => void; + const olderResult = new Promise((resolve) => { release = resolve; }); + discoverWorkspaceRepositories.mockImplementationOnce(() => olderResult); + const older = store.getState().actions.refreshWorkspaceRepositories(); + try { + window.localStorage.setItem(directoryMarksStorageKey("C:/repo"), JSON.stringify({version: 1, marks: {child: "excluded"}})); + discoverWorkspaceRepositories.mockResolvedValue(["C:/repo"]); + await store.getState().actions.syncWorkspaceRepositories("C:/repo"); + } finally { release(["C:/repo", child]); await older; } + expect(store.getState().availableRepoPaths).toEqual(["C:/repo"]); + expect(store.getState().activeRepoPath).toBe("C:/repo"); +}); + +test("discovery or preference failures retain published repository bindings", async () => { + restoreDom = installHappyDom(); + const store = createGitRepositoryStore(); + await store.getState().actions.syncWorkspaceRepositories("C:/repo"); + const previous = store.getState().availableRepoPaths; + discoverWorkspaceRepositories.mockRejectedValue(new Error("Git failed")); + await store.getState().actions.refreshWorkspaceRepositories(); + expect(store.getState().availableRepoPaths).toBe(previous); + expect(store.getState().error).toBe("Git failed"); + store.getState().actions.selectRepository("C:/repo"); + expect(store.getState().error).toBe("Git failed"); + store.getState().actions.setManualRepository("C:/manual"); + expect(store.getState().error).toBe("Git failed"); + store.getState().actions.clearManualRepository(); + expect(store.getState().error).toBe("Git failed"); + const afterSelection = store.getState().availableRepoPaths; + window.localStorage.setItem(directoryMarksStorageKey("C:/repo"), "{"); + await store.getState().actions.refreshWorkspaceRepositories(); + expect(store.getState().availableRepoPaths).toBe(afterSelection); + expect(store.getState().error).not.toBeNull(); + expect(store.getState().isDiscovering).toBe(false); +}); test("unsupported Windows roots report discovery errors without breaking repository state", async () => { discoverySpy.mockImplementation(realDiscoverWorkspaceRepositories); diff --git a/windows/tauri/src/features/git/stores/git-repository.store.ts b/windows/tauri/src/features/git/stores/git-repository.store.ts index 0ef6781981..a5b34d1bba 100644 --- a/windows/tauri/src/features/git/stores/git-repository.store.ts +++ b/windows/tauri/src/features/git/stores/git-repository.store.ts @@ -1,11 +1,12 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { createSelectors } from "@/utils/zustand-selectors"; -import { discoverWorkspaceRepositories, normalizeRepositoryPath } from "../api/git-repo-api"; +import { discoverWorkspaceRepositories, invalidateWorkspaceRepositoryDiscovery, normalizeRepositoryPath, workspaceRepositoryDiscoveryPolicyKey } from "../api/git-repo-api"; interface RepositoryState { workspaceRootPath: string | null; workspaceDiscoveryKey: string | null; + workspaceDiscoveryPolicyKey: string | null; workspaceRepoPaths: string[]; manualRepoPath: string | null; manualRepoPaths: string[]; @@ -22,6 +23,7 @@ interface RepositoryState { options?: { force?: boolean }, ) => Promise; refreshWorkspaceRepositories: () => Promise; + invalidateWorkspaceRepositories: () => void; selectRepository: (repoPath: string | null) => void; setManualRepository: (repoPath: string) => void; clearManualRepository: () => void; @@ -55,6 +57,7 @@ const getWorkspaceDefaultRepo = (workspaceRepos: string[]): string | null => { const initialState = { workspaceRootPath: null, workspaceDiscoveryKey: null, + workspaceDiscoveryPolicyKey: null, workspaceRepoPaths: [], manualRepoPath: null, manualRepoPaths: [], @@ -88,6 +91,7 @@ export const createGitRepositoryStore = () => return { workspaceRootPath: null, workspaceDiscoveryKey: null, + workspaceDiscoveryPolicyKey: null, workspaceRepoPaths: [], availableRepoPaths, activeRepoPath, @@ -100,67 +104,52 @@ export const createGitRepositoryStore = () => return; } - const current = get(); - if ( - !force && - current.workspaceDiscoveryKey === workspaceDiscoveryKey && - (current.hasDiscoveredWorkspace || current.isDiscovering) - ) { - return; - } - - const requestId = current.discoveryRequestId + 1; - set({ - workspaceRootPath: normalizedRoot, - workspaceDiscoveryKey, - isDiscovering: true, - discoveryRequestId: requestId, - error: null, - }); - + const requestId = get().discoveryRequestId + 1; + let started = false; try { + const policyKey = workspaceRepositoryDiscoveryPolicyKey(normalizedRoots); + const current = get(); + if (!force && current.workspaceDiscoveryKey === workspaceDiscoveryKey && + current.workspaceDiscoveryPolicyKey === policyKey && + (current.hasDiscoveredWorkspace || current.isDiscovering)) return; + set({ workspaceRootPath: normalizedRoot, workspaceDiscoveryKey, + workspaceDiscoveryPolicyKey: policyKey, isDiscovering: true, + discoveryRequestId: requestId, error: null }); + started = true; const discoveredRepos = await discoverWorkspaceRepositories(normalizedRoots, { force }); - + if (get().discoveryRequestId !== requestId) return; + if (workspaceRepositoryDiscoveryPolicyKey(normalizedRoots) !== policyKey) { + set({ isDiscovering: false, hasDiscoveredWorkspace: false }); + await get().actions.syncWorkspaceRepositories(normalizedRoots, { force: true }); + return; + } set((state) => { - if ( - state.discoveryRequestId !== requestId || - state.workspaceDiscoveryKey !== workspaceDiscoveryKey - ) { - return state; - } - + if (state.discoveryRequestId !== requestId || state.workspaceDiscoveryKey !== workspaceDiscoveryKey) return state; const availableRepoPaths = mergeRepositoryPaths(discoveredRepos, state.manualRepoPaths); const availableRepoPathSet = new Set(availableRepoPaths); const previousActive = state.activeRepoPath; - const hasPreviousActive = !!previousActive && availableRepoPathSet.has(previousActive); - const nextActiveRepoPath = hasPreviousActive + const nextActiveRepoPath = previousActive && availableRepoPathSet.has(previousActive) ? previousActive : state.manualRepoPath && availableRepoPathSet.has(state.manualRepoPath) - ? state.manualRepoPath - : getWorkspaceDefaultRepo(discoveredRepos); - + ? state.manualRepoPath : getWorkspaceDefaultRepo(discoveredRepos); return { - workspaceRootPath: normalizedRoot, - workspaceDiscoveryKey, workspaceRepoPaths: reuseRepositoryPaths(state.workspaceRepoPaths, discoveredRepos), availableRepoPaths: reuseRepositoryPaths(state.availableRepoPaths, availableRepoPaths), - activeRepoPath: nextActiveRepoPath, - isDiscovering: false, - hasDiscoveredWorkspace: true, - error: null, + activeRepoPath: nextActiveRepoPath, isDiscovering: false, + hasDiscoveredWorkspace: true, error: null, }; }); } catch (error) { - set((state) => - state.discoveryRequestId === requestId && - state.workspaceDiscoveryKey === workspaceDiscoveryKey - ? { - isDiscovering: false, - hasDiscoveredWorkspace: true, - error: error instanceof Error ? error.message : String(error), - } - : state, - ); + // A failed preference read must also supersede an older scan. Keep + // every successfully published binding until a new discovery succeeds. + set((state) => (started + ? state.discoveryRequestId !== requestId || state.workspaceDiscoveryKey !== workspaceDiscoveryKey + : state.discoveryRequestId >= requestId) ? state : { + workspaceRootPath: normalizedRoot, workspaceDiscoveryKey, + discoveryRequestId: requestId, isDiscovering: false, + hasDiscoveredWorkspace: false, + error: error instanceof Error ? error.message : String(error), + }); } }, @@ -169,6 +158,16 @@ export const createGitRepositoryStore = () => await actions.syncWorkspaceRepositories(workspaceDiscoveryKey?.split("\0"), { force: true }); }, + invalidateWorkspaceRepositories: () => { + invalidateWorkspaceRepositoryDiscovery(get().workspaceDiscoveryKey?.split("\0") ?? []); + set((state) => ({ + discoveryRequestId: state.discoveryRequestId + 1, + workspaceDiscoveryPolicyKey: null, + hasDiscoveredWorkspace: false, + isDiscovering: true, + })); + }, + selectRepository: (repoPath) => { const normalizedRepoPath = repoPath ? normalizeRepositoryPath(repoPath) : null; set((state) => { @@ -193,7 +192,6 @@ export const createGitRepositoryStore = () => manualRepoPaths: nextManualRepoPaths, activeRepoPath: normalizedRepoPath, availableRepoPaths, - error: null, }; }); }, @@ -214,7 +212,6 @@ export const createGitRepositoryStore = () => manualRepoPaths, activeRepoPath: normalizedRepoPath, availableRepoPaths, - error: null, }; }); }, @@ -237,7 +234,6 @@ export const createGitRepositoryStore = () => manualRepoPaths: [], activeRepoPath: nextActiveRepoPath, availableRepoPaths, - error: null, }; }); }, diff --git a/windows/tauri/src/features/git/stores/git.store.ts b/windows/tauri/src/features/git/stores/git.store.ts index 75d54c2ef6..c30766cdb1 100644 --- a/windows/tauri/src/features/git/stores/git.store.ts +++ b/windows/tauri/src/features/git/stores/git.store.ts @@ -37,6 +37,7 @@ interface GitState { actions: { beginWorkingTreeRefresh: () => number; + invalidateRepositoryReads: () => void; prepareRepositoryLoad: (repoPath: string) => void; loadFreshGitData: (data: { repositoryStatuses: GitRepositoryStatuses; @@ -168,6 +169,7 @@ export const createGitStore = () => { actions: { beginWorkingTreeRefresh: () => ++nextWorkingTreeVersion, + invalidateRepositoryReads: invalidatePendingReads, prepareRepositoryLoad: (repoPath) => { const state = get(); diff --git a/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts b/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts index ade67beee4..a3defbb46c 100644 --- a/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts +++ b/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; import * as tauriCore from "@/platform/tauri-core"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { clearRepositoryDiscoveryCache } from "@/features/git/api/git-repo-api"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { directoryMarksStorageKey } from "@/features/file-explorer/stores/directory-marks-storage"; import { useGitStore } from "@/features/git/stores/git.store"; import { createFileTreeGitStatusLookup, getFileTreeEntryGitStatusDecoration } from "@/features/file-explorer/lib/file-tree-git-status"; import { resolveEditorLspLaunch } from "@/features/editor/lsp/resolve-editor-lsp-launch"; @@ -39,6 +41,7 @@ const dependencies = { resolveDeclarativePluginLaunch: async () => null, }; let invokeSpy: ReturnType>; +let restoreDom: (() => void) | undefined; function openRuntime() { workspaceRuntimeRegistry.ensureWorkspace({ id: scope.workspaceId, name: "Test", path: scope.root }); @@ -70,8 +73,28 @@ afterEach(() => { invokeSpy.mockRestore(); clearRepositoryDiscoveryCache(); workspaceRuntimeRegistry.resetForTests(); + restoreDom?.(); restoreDom = undefined; }); +test("changed manual exclusions supersede bootstrap results before publication", async () => { + restoreDom = installHappyDom(); + const started = deferred(); + const release = deferred(); + interceptStatus = async (path) => { + if (path === childRepo) { started.resolve(); await release.promise; } + }; + const older = ensureWorkspaceGitBootstrap(scope); + try { + await started.promise; + window.localStorage.setItem(directoryMarksStorageKey(scope.root), JSON.stringify({version: 1, marks: {nested: "excluded"}})); + } finally { release.resolve(); } + expect(await older).toBe("superseded"); + expect(useGitStore.getStore(scope.workspaceId).getState().workspaceGitStatus).toBeNull(); + discoveredRepos = [scope.root]; + expect(await ensureWorkspaceGitBootstrap(scope)).toBe("published"); + expect(invoke.mock.calls.filter(([command]) => command === "git_discover_workspace_repos")).toHaveLength(2); +}, 1000); + test("restored Java documents and background startup share Git readiness and root decorations", async () => { const childStarted = deferred(); const releaseChild = deferred(); diff --git a/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.ts b/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.ts index 3e87a6d9e2..bd00cf88e4 100644 --- a/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.ts +++ b/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.ts @@ -1,5 +1,5 @@ import { normalizeWorkspaceFolders } from "@/features/file-system/controllers/workspace-session"; -import { discoverWorkspaceRepositories } from "@/features/git/api/git-repo-api"; +import { discoverWorkspaceRepositories, workspaceRepositoryDiscoveryPolicyKey } from "@/features/git/api/git-repo-api"; import { getRepositoryGitStatuses } from "@/features/git/api/git-status-api"; import { useGitStore } from "@/features/git/stores/git.store"; import { frontendTrace } from "@/utils/frontend-trace"; @@ -41,7 +41,12 @@ export async function ensureWorkspaceGitBootstrap( if (!workspaceScopeMatchesRoot(scope, state.rootFolderPath)) return "superseded"; const rootPaths = normalizeWorkspaceFolders(state.rootFolderPath, state.workspaceFolders) .map((folder) => folder.path); - const rootsKey = JSON.stringify(rootPaths); + let rootsKey: string; + try { rootsKey = workspaceRepositoryDiscoveryPolicyKey(rootPaths); } + catch (error) { + console.error("Failed to read workspace Git discovery preferences:", error); + return "failed"; + } const existing = bootstraps.get(runtime); if (existing?.rootsKey === rootsKey && (!options.refresh || !existing.settled)) { return existing.task; @@ -58,11 +63,14 @@ export async function ensureWorkspaceGitBootstrap( }; const isCurrent = () => { const current = fileSystemStore.getState(); + let currentPolicyKey: string; + try { currentPolicyKey = workspaceRepositoryDiscoveryPolicyKey( + normalizeWorkspaceFolders(current.rootFolderPath, current.workspaceFolders).map((folder) => folder.path)); } + catch { return false; } return workspaceRuntimeRegistry.getWorkspace(scope.workspaceId) === runtime && bootstraps.get(runtime) === bootstrap && workspaceScopeMatchesRoot(scope, current.rootFolderPath) && - JSON.stringify(normalizeWorkspaceFolders(current.rootFolderPath, current.workspaceFolders) - .map((folder) => folder.path)) === rootsKey; + currentPolicyKey === rootsKey; }; bootstraps.set(runtime, bootstrap); const startedAt = performance.now(); @@ -70,7 +78,7 @@ export async function ensureWorkspaceGitBootstrap( frontendTrace("info", "workspace-open", "gitBootstrap:start", traceContext); bootstrap.task = bootstrapWorkspaceGit({ workspaceRootPaths: rootPaths, - discoverRepositories: discoverWorkspaceRepositories, + discoverRepositories: (roots) => discoverWorkspaceRepositories(roots, { force: options.refresh }), loadStatus: (repoPaths) => { repositoryPaths = repoPaths; return getRepositoryGitStatuses([...repoPaths, scope.root], "background", repoPaths); From 186eebe930be34b75b2de55238e7e8153158c4e6 Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Mon, 2 Nov 2026 20:49:46 +0800 Subject: [PATCH 06/10] fix(git): validate exclusion paths by native components --- ...04-git-repository-discovery-and-selection.md | 2 ++ rust/lithe-core/src/git/repository_discovery.rs | 17 +++++++++++------ .../src/tests/git_repository_discovery.rs | 13 +++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md index 283e3ed7cb..f962d93111 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md +++ b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md @@ -31,6 +31,8 @@ macOS 点击另一仓库的分支时,先刷新目标仓库,再设置目标 - 显式工作区根分别传入自己的手动标记和根集合,父扫描不进入另行打开的嵌套根。 工作树容器只豁免隐藏名称规则,不能豁免 Git 或手动排除。索引登记的 gitlink(父仓库 保存的子模块提交引用)保留依赖发现;普通嵌套 `.git` 不享有这个例外。 +- 手动排除按相对路径组件校验,拒绝绝对根、前缀、空段及上级跳转;不能把 Windows + 文件名限制施加到 Unix,例如合法目录 `cache:old` 不应令整次 Git 发现失败。 - 忽略语法交给 Git 的批量 `check-ignore`,沿用 `ls-files` 对已跟踪后代的保护。 Git 2.31 没有按模式筛选 gitlink 的查询开关,因此通过原有本机/远程进程所有者流式 读取 NUL 分隔的索引记录,只保留子模块路径,不捕获整个大索引。取消、错误与进程树 diff --git a/rust/lithe-core/src/git/repository_discovery.rs b/rust/lithe-core/src/git/repository_discovery.rs index 2657fe63e7..e535e4b2cf 100644 --- a/rust/lithe-core/src/git/repository_discovery.rs +++ b/rust/lithe-core/src/git/repository_discovery.rs @@ -2,9 +2,9 @@ use super::{ canonicalize_simplified, capture_git_process_streaming, discover_containing_repository, - git_entry_opens_as_repository, ignored_paths_checked, is_safe_pathspec, path_sort_key, - repository_scan_error, sort_workspace_repository_paths, untracked_directory_candidates_checked, - validate_root, WorkspaceRepositoriesRequest, + git_entry_opens_as_repository, ignored_paths_checked, path_sort_key, repository_scan_error, + sort_workspace_repository_paths, untracked_directory_candidates_checked, validate_root, + WorkspaceRepositoriesRequest, }; use crate::project::files::VisibilityRules; use crate::protocol::{ @@ -129,9 +129,14 @@ fn manual_exclusions(root: &Path, paths: &[String]) -> Result, Core .map(|value| { let value = value.trim_end_matches('/'); if value != "." - && (!is_safe_pathspec(value) - || value.contains(['\\', '\0', ':']) - || value.split('/').any(|part| part.is_empty() || part == ".")) + && (value.is_empty() + || value.contains(['\\', '\0']) + || value + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || !Path::new(value) + .components() + .all(|part| matches!(part, std::path::Component::Normal(_)))) { return Err(CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/lithe-core/src/tests/git_repository_discovery.rs b/rust/lithe-core/src/tests/git_repository_discovery.rs index 1fdb794fe5..1409c3a3bc 100644 --- a/rust/lithe-core/src/tests/git_repository_discovery.rs +++ b/rust/lithe-core/src/tests/git_repository_discovery.rs @@ -327,6 +327,19 @@ fn discovery_reports_invalid_rules_and_git_failures_instead_of_empty_success() { assert_eq!(response["error"]["code"], "process_failed", "{response}"); } +#[cfg(unix)] +#[test] +fn discovery_accepts_colons_in_literal_unix_exclusion_components() { + let workspace = Workspace::new(); + let root = workspace.initialize("."); + workspace.initialize("cache:old/nested"); + let visible = workspace.initialize("visible"); + assert_eq!( + discover_with(&root, &["cache:old"], &[]), + vec![canonical(&root), canonical(&visible)] + ); +} + #[test] fn discovery_respects_explicit_depth_limits() { let workspace = Workspace::new(); From 993a32c3efd00c5c14a3071f2bcaaa997d17c976 Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Mon, 2 Nov 2026 21:07:38 +0800 Subject: [PATCH 07/10] docs(git): record discovery validation on AutoBuild baseline --- .../features/git-repository-discovery.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/platform-feature-matrix/features/git-repository-discovery.json b/shared/platform-feature-matrix/features/git-repository-discovery.json index ea0798ae17..0fe415b015 100644 --- a/shared/platform-feature-matrix/features/git-repository-discovery.json +++ b/shared/platform-feature-matrix/features/git-repository-discovery.json @@ -24,5 +24,5 @@ }, "owner": "Git", "verification": "打开含被 Git 忽略目录、仅手动排除目录、构建缓存和普通可见子仓库的项目,确认提交、分支和日志只自动纳入合格仓库;改变规则后及时更新且旧请求不回填。显式打开被排除根仍可管理;多根分别使用自己的排除快照,真实 gitlink 子模块保留依赖发现。验证父仓库已跟踪改动和普通未跟踪文件不被手动排除过滤、查询失败保留旧快照,以及链接工作树、失效 gitfile 和取消。", - "notes": "两端使用请求局部的共享发现规则;分析开关和包含例外不注册 Git 根。隔离 macOS QA 已验证提交、分支、日志及显式打开例外,手动标记通过隔离偏好域预置。固定 Swift 工具链及 Windows 原生验收尚未完成;Core、Swift 模型和前端组件测试不代替最终原生验收。" + "notes": "两端使用请求局部的共享发现规则;分析开关和包含例外不注册 Git 根。隔离 macOS QA 已验证提交、分支、日志及显式打开例外,手动标记通过隔离偏好域预置。固定 Swift 工具链及 Windows 原生验收尚未完成;Core、Swift 模型和前端组件测试不代替最终原生验收。 基于 AutoBuild 0.3.0(20261102.105732 / 8b1d6669)整合后,本机 Core1590逐项、Swift317、前端84、TypeScript、Git Host9及桥接9通过;Unix合法冒号目录回归通过。完整Rust crates与边界/契约/i18n/稳定性/矩阵/包只读检查通过。应用AskPass握手首轮5秒超时,按原预算复核全部通过;未改变门限。大型目录树的逐层Git查询耗时仍未实测,Windows原生与固定Swift工具链仍待验证。" } From 885db50ea7d50bd5dcd3b1ccc0ce81e20238142c Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Tue, 3 Nov 2026 10:50:51 +0800 Subject: [PATCH 08/10] fix(workspace): preserve effective indexing settings and bounded Git queries --- ...026-10-28-workspace-indexing-exclusions.md | 21 +++ .../references/windows-and-rust.md | 6 + .../scripts/run-rust-tests-with-timing.mjs | 21 ++- .../scripts/test-rust-test-selection.mjs | 34 ++++ .../scripts/test-verify-test-stability.mjs | 2 + .github/workflows/ci-windows.yml | 1 + macos/Resources/en.lproj/Localizable.strings | 6 +- .../zh-Hans.lproj/Localizable.strings | 6 +- .../Lithe/Core/Rust/RustCoreBridge.swift | 2 +- .../App/WorkspaceIndexingSettingsView.swift | 6 +- .../Workspace/WorkspaceIndexingPolicy.swift | 19 ++- .../Application/WorkspaceFeatureModel.swift | 2 +- .../LazyProjectTreeInventoryTests.swift | 22 ++- .../WorkspaceDirectoryMarkStoreTests.swift | 20 ++- rust/lithe-core/src/git/mod.rs | 31 ++-- rust/lithe-core/src/project/indexing.rs | 152 +++++++++++++++++- shared/contracts/rust-core-api.md | 6 +- .../workspace/indexing-policy-v1.json | 17 +- shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 12 ++ .../workspace-indexing-exclusions.json | 2 +- .../stores/directory-marks.test.ts | 2 +- .../stores/workspace-indexing.test.ts | 15 ++ .../stores/workspace-indexing.ts | 9 +- .../components/git-indexing-settings.test.tsx | 22 ++- .../workspace-indexing-settings.tsx | 18 ++- windows/tauri/src/i18n/shared.generated.ts | 6 +- 28 files changed, 414 insertions(+), 52 deletions(-) create mode 100644 .agents/skills/write-stable-tests/scripts/test-rust-test-selection.mjs diff --git a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md index 74115d2415..83f5c8dfe8 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md +++ b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md @@ -166,6 +166,22 @@ buffer、挂载状态及 attachment;不重启整个语言服务。 全局 Git 忽略文件仍通过激活、焦点和显式刷新补偿,不持续监听任意外部配置。 本轮不新增“恢复跟随 Git”菜单,也不迁移已丢失的历史角色和例外。 +### 2026-11-03:缺省关闭与受控查询 + +未保存 Git 自动排除偏好的项目默认关闭,由 Core 在配置请求缺字段时解析。 +两端只保存用户明确选择的布尔值;读取、打开项目、设置页和普通目录标记不补写默认值。 +已有保存的 true/false 保持原意,不执行覆盖用户设置的一次性迁移。 +配置和刷新返回实际生效值,设置控件在配置完成后呈现该值;没有生效值时禁用, +不能把暂存偏好或旧 Core 响应当作已确认的新状态。Git 元数据刷新也读取生效值。 + +忽略目录的批量 ls-files 必须经过已有 Git Host,保留选定可执行文件、请求超时、 +取消与进程树清理。普通 Git 失败可显示部分结果,取消和超时必须原样终止请求。 +说明提示保留作用范围、目录仍可浏览及取消排除例外;语言服务限制只写 +“部分语言服务仍可能扫描已排除的目录”。前文 provider 适配和配置优先级仍属技术边界。 + +Windows 真实 JDT CI 只筛选真实 JDT 模块,普通 Core 全量由原 SharedRust 车道覆盖。 +筛选为显式测试名称前缀,零命中失败并输出范围与计数;不增加时间预算。 + ## 考虑过的备选方案 - 复用隐藏路径:会隐藏目录,不能满足浏览与编辑需求。 @@ -494,6 +510,11 @@ ShipFlow 的已跟踪修改、未跟踪文件均仍存在。最终截图可区 相同的两个平台专有 API 错误。本次本地 release 构建使用隔离安装的官方 Rust 1.95.0; 关闭本机符号裁剪以绕过 macOS 27 的 LINKEDIT 对齐加载问题,不改变仓库发布配置。 +2026-11-03 第一组修复通过完整 Core 1586 项及 Rust↔Swift 桥接、索引 37 项逐项计时、 +本机 Swift 6.4.2 的 63 项及前端 61 项。设置首次未取得生效值时明确显示未确认, +已成功配置后的保存失败保留最后确认值;中英回归覆盖失败和重试。固定工具链、 +Windows 原生及新源码界面截图仍须按最终提交核对,不沿用历史验收结论。 + ## 适用范围 - `rust/lithe-core/src/project/indexing.rs` diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index d5d7731a17..729ff5d995 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -168,3 +168,9 @@ Run the PowerShell harness in a real Windows environment. A macOS boundary check does not verify Bun timers, Windows process termination, or native Rust test execution. When using the Parallels guest, establish the user toolchain paths before invoking the script as required by `debug-windows-on-parallels`. + +The real-JDT Windows lane selects `lsp::interface::engine::real_jdt_tests::` via +`--test-prefix`. SharedRust retains the ordinary full Core suite. The repeatable +prefix option matches raw test names, defaults to all tests, reports discovery and +selection counts, and fails closed when no case matches. Suite, case and cleanup +deadlines remain unchanged. diff --git a/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs b/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs index cc994657df..755d2c75cf 100755 --- a/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs +++ b/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs @@ -25,6 +25,7 @@ export function parseArguments(arguments_) { warnMs: 1000, maxMs: 15000, testBudgets: [], + testPrefixes: [], buildTimeoutMs: 1200000, suiteTimeoutMs: 1200000, report: null, @@ -41,7 +42,11 @@ export function parseArguments(arguments_) { else if (argument === "--no-default-features") options.noDefaultFeatures = true; else if (argument === "--warn-ms") options.warnMs = positiveInteger(arguments_[++index], "--warn-ms"); else if (argument === "--max-ms") options.maxMs = positiveInteger(arguments_[++index], "--max-ms"); - else if (argument === "--test-budget") { + else if (argument === "--test-prefix") { + const prefix = arguments_[++index]; + if (!prefix || prefix.startsWith("--")) throw new Error("--test-prefix requires a nonempty test-name prefix."); + options.testPrefixes.push(prefix); + } else if (argument === "--test-budget") { const value = arguments_[++index] ?? ""; const match = value.match(/^([^=]+)=(\d+)$/); if (!match) throw new Error("--test-budget requires a nonempty test-name prefix followed by =milliseconds."); @@ -106,6 +111,8 @@ export async function run( writeFileSync(logPath, ""); const artifacts = new Map(); const records = []; + let discoveredTests = 0; + let selectedTests = 0; const compilerMessages = []; const suiteStartedAt = now(); const suiteDeadline = suiteStartedAt + options.suiteTimeoutMs; @@ -144,6 +151,9 @@ export async function run( warnMs: options.warnMs, maxMs: options.maxMs, testBudgets: options.testBudgets ?? [], + testPrefixes: options.testPrefixes ?? [], + discoveredTests, + selectedTests, suiteTimeoutMs: options.suiteTimeoutMs, buildDurationMs, suite: { @@ -248,8 +258,11 @@ export async function run( .split(/\r?\n/) .map((line) => line.match(/^(.*): test$/)?.[1]) .filter(Boolean); + discoveredTests += tests.length; for (const testName of tests) { + if (options.testPrefixes?.length && !options.testPrefixes.some((prefix) => testName.startsWith(prefix))) continue; + selectedTests++; console.log(`RUN ${artifact.target}::${testName}`); // The most specific integration-test prefix wins. The shared suite // deadline still caps every subprocess, including longer native tests. @@ -306,7 +319,10 @@ export async function run( if (shouldStop) break; } + const noSelection = (options.testPrefixes?.length ?? 0) > 0 && selectedTests === 0; + if (noSelection) records.push({target: options.package ?? "rust-suite", name: "Rust test selection", status: "failed", durationMs: 0, details: "No Rust tests matched the requested prefixes."}); writeReport(); + if (noSelection) throw new Error("No Rust tests matched the requested prefixes."); console.log(`Recorded ${records.length} Rust test duration(s) in ${options.report}`); for (const record of records .filter((value) => value.durationMs >= options.warnMs) @@ -319,7 +335,8 @@ export async function run( `Rust test suite exceeded ${options.suiteTimeoutMs}ms during ${suiteTimedOutStage}.`, ); } - if (records.length === 0) throw new Error("No Rust tests were enumerated."); + if (records.length === 0) throw new Error(options.testPrefixes?.length + ? "No Rust tests matched the requested prefixes." : "No Rust tests were enumerated."); const failures = records.filter((record) => !["passed", "skipped"].includes(record.status) || record.durationMs >= (record.maxMs ?? options.maxMs)); if (failures.length > 0) { diff --git a/.agents/skills/write-stable-tests/scripts/test-rust-test-selection.mjs b/.agents/skills/write-stable-tests/scripts/test-rust-test-selection.mjs new file mode 100644 index 0000000000..4540e1b41b --- /dev/null +++ b/.agents/skills/write-stable-tests/scripts/test-rust-test-selection.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { parseArguments, run } from "./run-rust-tests-with-timing.mjs"; + +test("Rust prefix selection preserves default coverage and rejects zero matches", { timeout: 5000 }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "lithe-rust-selection-")); + try { + for (const prefixes of [[], ["real_jdt::"], ["missing::"]]) { + const report = path.join(root, "report.json"); + const options = parseArguments(["--manifest", path.join(root, "Cargo.toml"), "--report", report, + ...prefixes.flatMap((prefix) => ["--test-prefix", prefix])]); + const executed = []; + const execute = async ({ command, args, onStdoutLine }) => { + if (command === "cargo") onStdoutLine(JSON.stringify({ reason: "compiler-artifact", profile: { test: true }, + executable: path.join(root, "fixture"), manifest_path: options.manifest, target: { name: "fixture" } })); + else if (args[0] === "--list") return { code: 0, durationMs: 1, stderr: "", stdout: "ordinary::case: test\nreal_jdt::case: test\n" }; + else executed.push(args[1]); + return { code: 0, durationMs: 1, stdout: "test fixture ... ok\n", stderr: "" }; + }; + const result = run(options, { runProcessImpl: execute }); + if (prefixes[0] === "missing::") await assert.rejects(result, /No Rust tests matched/); + else await result; + assert.deepEqual(executed, prefixes.length === 0 ? ["ordinary::case", "real_jdt::case"] : prefixes[0] === "missing::" ? [] : ["real_jdt::case"]); + const saved = JSON.parse(readFileSync(report, "utf8")); + assert.deepEqual(saved.testPrefixes, prefixes); + assert.equal(saved.discoveredTests, 2); + assert.equal(saved.selectedTests, executed.length); + } + assert.throws(() => parseArguments(["--manifest", "Cargo.toml", "--test-prefix", ""]), /nonempty/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs index aa4dfadd69..ea474ba756 100755 --- a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs +++ b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs @@ -889,3 +889,5 @@ try { } console.log("Test stability verifier tests passed."); + +await import("./test-rust-test-selection.mjs"); diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index fb6830c1eb..81ffea2b14 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -870,6 +870,7 @@ jobs: --test-budget tests::git_patch_exchange::patch_metadata_can_list_an_oversized_export_before_selecting_a_small_subset=30000 ` --build-timeout-ms 600000 ` --suite-timeout-ms 1200000 ` + --test-prefix lsp::interface::engine::real_jdt_tests:: ` --report .artifacts/test-stability/windows-real-jdt.json - name: Upload real JDT test report diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 6450635eab..b52463a308 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -375,7 +375,7 @@ "Applies immediately" = "Applies immediately"; "Applies immediately to source editors in all projects." = "Applies immediately to source editors in all projects."; "Applies the next time the language server starts." = "Applies the next time the language server starts."; -"Applies to the current project. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules." = "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nNative exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee."; +"Applies to the current project. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules." = "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nSome language services may still scan excluded directories."; "Apply" = "Apply"; "Apply %@ to the current branch." = "Apply %@ to the current branch."; "Apply AI-generated content?" = "Apply AI-generated content?"; @@ -1878,6 +1878,7 @@ "Indent guides" = "Indent guides"; "Indentation" = "Indentation"; "Index and working tree" = "Index and working tree"; +"Indexing preference not confirmed" = "Not confirmed"; "Info" = "Info"; "Information" = "Information"; "Inherit" = "Inherit"; @@ -5027,8 +5028,9 @@ "indexing.autoGit" = "Automatically exclude Git-ignored directories"; "indexing.cancelExclusion" = "Cancel Exclusion"; "indexing.configureFailed" = "Could not apply project indexing exclusions. Try reopening the project."; -"indexing.description" = "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nNative exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee."; +"indexing.description" = "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nSome language services may still scan excluded directories."; "indexing.dismissNotice" = "Don't Show Again"; +"indexing.effectiveUnknown" = "Not confirmed"; "indexing.excludeSelected" = "Exclude Selected"; "indexing.gitFailed" = "Git ignore exclusions could not be fully resolved. Manual exclusions remain active."; "indexing.gitReason" = "Excluded from indexing by Git ignore rules"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 63b983cfcd..4446dc4d09 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -375,7 +375,7 @@ "Applies immediately" = "立即生效"; "Applies immediately to source editors in all projects." = "立即应用于所有项目的源码编辑器。"; "Applies the next time the language server starts." = "下次启动语言服务器时生效。"; -"Applies to the current project. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules." = "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。"; +"Applies to the current project. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules." = "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n部分语言服务仍可能扫描已排除的目录。"; "Apply" = "应用"; "Apply %@ to the current branch." = "将提交 %@ 应用到当前分支。"; "Apply AI-generated content?" = "要应用 AI 生成的内容吗?"; @@ -1878,6 +1878,7 @@ "Indent guides" = "缩进引导线"; "Indentation" = "缩进"; "Index and working tree" = "暂存区和工作树"; +"Indexing preference not confirmed" = "未确认"; "Info" = "信息"; "Information" = "信息"; "Inherit" = "继承"; @@ -5027,8 +5028,9 @@ "indexing.autoGit" = "根据 Git 忽略规则自动排除目录"; "indexing.cancelExclusion" = "取消排除"; "indexing.configureFailed" = "无法应用项目索引排除策略,请重新打开项目后重试。"; -"indexing.description" = "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。"; +"indexing.description" = "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n部分语言服务仍可能扫描已排除的目录。"; "indexing.dismissNotice" = "不再显示"; +"indexing.effectiveUnknown" = "未确认"; "indexing.excludeSelected" = "排除所选目录"; "indexing.gitFailed" = "无法完整解析 Git 忽略规则,手动排除仍然生效。"; "indexing.gitReason" = "根据 Git 忽略规则排除索引"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 269d499360..423217a234 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2540,7 +2540,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let ownerId: String? let manualExcludedDirectories: [String] let includedDirectories: [String] - let gitIgnoreEnabled: Bool + let gitIgnoreEnabled: Bool? let refresh: Bool } return try executeResult(command: "workspace.configureIndexing", payload: Request( diff --git a/macos/Sources/Lithe/Views/App/WorkspaceIndexingSettingsView.swift b/macos/Sources/Lithe/Views/App/WorkspaceIndexingSettingsView.swift index 45e349b2ce..dae9568d80 100644 --- a/macos/Sources/Lithe/Views/App/WorkspaceIndexingSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/WorkspaceIndexingSettingsView.swift @@ -7,14 +7,18 @@ struct WorkspaceIndexingSettingsView: View { var body: some View { Group { LitheSettingsRow("Automatically exclude Git-ignored directories", info: "indexing.description") { + if feature.indexingStatus.effectiveGitIgnoreEnabled != nil { Toggle("Automatically exclude Git-ignored directories", isOn: Binding( - get: { feature.indexingSettings.gitIgnoreEnabled }, + get: { feature.indexingStatus.effectiveGitIgnoreEnabled == true }, set: { enabled in Task { await feature.setGitIgnoreIndexing(enabled) } } )) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .disabled(feature.rootNode == nil || !feature.indexingStatus.supportsExclusions || !feature.canEditIndexingSettings) + } else { + Text("Indexing preference not confirmed").foregroundStyle(LitheTheme.secondaryText) + } } if !feature.indexingStatus.supportsExclusions { Text("Directory indexing exclusions are not supported for remote projects.") diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift index da97c6d98e..aaea0d44af 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift @@ -14,11 +14,23 @@ package enum WorkspaceIndexingFailure: Error, Equatable, Sendable { /// Project preferences are separate from roles and from tree-hiding rules. package struct WorkspaceIndexingSettings: Codable, Equatable, Sendable { - package var gitIgnoreEnabled = true + package var gitIgnoreEnabled: Bool? package var includedDirectories: [String] = [] package var previousRoles: [String: WorkspaceDirectoryMark] = [:] package var ignoredDirectoryNoticeDismissed: Bool? package init() {} + + private enum CodingKeys: String, CodingKey { + case gitIgnoreEnabled, includedDirectories, previousRoles, ignoredDirectoryNoticeDismissed + } + + package init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + gitIgnoreEnabled = values.contains(.gitIgnoreEnabled) ? try values.decode(Bool.self, forKey: .gitIgnoreEnabled) : nil + includedDirectories = try values.decode([String].self, forKey: .includedDirectories) + previousRoles = try values.decode([String: WorkspaceDirectoryMark].self, forKey: .previousRoles) + ignoredDirectoryNoticeDismissed = try values.decodeIfPresent(Bool.self, forKey: .ignoredDirectoryNoticeDismissed) + } } package struct WorkspaceIgnoredDirectorySuggestions: Decodable, Sendable { @@ -31,7 +43,7 @@ package struct WorkspaceIgnoredDirectorySuggestions: Decodable, Sendable { package struct WorkspaceIndexingPolicy: Encodable, Equatable, Sendable { package let manualExcludedDirectories: [String] package let includedDirectories: [String] - package let gitIgnoreEnabled: Bool + package let gitIgnoreEnabled: Bool? package init(marks: [String: WorkspaceDirectoryMark], settings: WorkspaceIndexingSettings) { manualExcludedDirectories = marks.filter { $0.value == .excluded }.map(\.key).sorted() includedDirectories = settings.includedDirectories.sorted() @@ -40,12 +52,13 @@ package struct WorkspaceIndexingPolicy: Encodable, Equatable, Sendable { } package struct WorkspaceIndexingStatus: Decodable, Sendable { + package var effectiveGitIgnoreEnabled: Bool? package var revision: UInt64 = 0 package var excludedDirectories: [String: String] = [:] // Local transport capability; deliberately absent from the Core JSON contract. package var supportsExclusions = true private enum CodingKeys: String, CodingKey { - case revision, excludedDirectories, gitIgnoreFailed + case revision, excludedDirectories, gitIgnoreFailed, effectiveGitIgnoreEnabled } package var gitIgnoreFailed = false package init() {} diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 0024715f77..6c2a83c60a 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -1608,7 +1608,7 @@ package final class WorkspaceFeatureModel: ObservableObject { guard !Task.isCancelled, workspaceGeneration == generation, activeGitRefreshID == refreshID else { return } if pendingIndexingRuleRefresh, !isLoadingWorkspace, !isRefreshingWorkspace { pendingIndexingRuleRefresh = false - if indexingSettings.gitIgnoreEnabled { + if indexingStatus.effectiveGitIgnoreEnabled == true { await refreshCurrentSnapshot(refreshGitAfterwards: false, indexingOnly: true) } else if let workspaceURL { await refreshIgnoredDirectoryMetadata(at: workspaceURL) diff --git a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift index 85bd3ea011..eba7fcd164 100644 --- a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift +++ b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift @@ -246,7 +246,8 @@ struct LazyProjectTreeInventoryTests { #expect((fixture.model.directoryMarks["archive"] ?? .plain).rawValue == step.role, "\(scenario.id)") #expect(fixture.model.indexingSettings.includedDirectories == (step.included ? ["archive"] : [])) } - let saved = fixture.model.indexingSettings + var saved = fixture.model.indexingSettings + saved.gitIgnoreEnabled = true await fixture.model.setGitIgnoreIndexing(false) await fixture.model.setGitIgnoreIndexing(true) #expect(fixture.model.indexingSettings == saved) @@ -439,7 +440,7 @@ struct LazyProjectTreeInventoryTests { #expect(fixture.model.directoryMarks["b"] == .sources) #expect(fixture.model.directoryMarks == store.marks) } else { - #expect(!fixture.model.indexingSettings.gitIgnoreEnabled) + #expect(fixture.model.indexingSettings.gitIgnoreEnabled == false) #expect(fixture.model.directoryMarks["a"] == nil) } } @@ -629,7 +630,7 @@ struct LazyProjectTreeInventoryTests { await fixture.load() await fixture.model.setGitIgnoreIndexing(false) await fixture.model.markDirectory(root.appendingPathComponent("src"), action: .exclude) - #expect(fixture.model.indexingSettings.gitIgnoreEnabled) + #expect(fixture.model.indexingSettings.gitIgnoreEnabled == nil) #expect(fixture.model.directoryMarks.isEmpty) #expect(fixture.model.projectFiles == [file]) #expect(fixture.disk.snapshotCount == 1) @@ -642,7 +643,7 @@ struct LazyProjectTreeInventoryTests { let file = directory.appendingPathComponent("Candidate.java") let fixture = InventoryFixture(root: root, files: [file], rows: [ root.path: [FileNode(url: directory, isDirectory: true, children: nil)], directory.path: [], - ]) + ], directoryMarkStore: InitiallyEnabledInventoryMarks()) defer { fixture.close() } fixture.disk.blockNextListing() let loading = Task { await fixture.load() } @@ -1158,9 +1159,10 @@ private final class InventoryDisk: WorkspaceOperations, WorkspaceFileOperations, self.policy = policy var status = WorkspaceIndexingStatus() status.supportsExclusions = supportsExclusions + status.effectiveGitIgnoreEnabled = policy.gitIgnoreEnabled ?? false status.revision = blocks ? 1 : configurationRevision status.excludedDirectories = Dictionary(uniqueKeysWithValues: policy.manualExcludedDirectories.map { ($0, "manual") }) - if policy.gitIgnoreEnabled { status.excludedDirectories["ignored"] = "git" } + if policy.gitIgnoreEnabled == true { status.excludedDirectories["ignored"] = "git" } return status } completion?.open() @@ -1287,3 +1289,13 @@ private final class FailingInventoryMarks: WorkspaceDirectoryMarkStoring, @unche lock.withLock { savedMarks = marks } } } + +private struct InitiallyEnabledInventoryMarks: WorkspaceDirectoryMarkStoring { + func loadIndexingSettings(for workspaceURL: URL) throws -> WorkspaceIndexingSettings { + var settings = WorkspaceIndexingSettings() + settings.gitIgnoreEnabled = true + return settings + } + func loadDirectoryMarks(for workspaceURL: URL) throws -> [String: WorkspaceDirectoryMark] { [:] } + func saveDirectoryMarks(_ marks: [String: WorkspaceDirectoryMark], for workspaceURL: URL) throws {} +} diff --git a/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift b/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift index 07b5753f12..0fd8e02c4b 100644 --- a/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift +++ b/macos/Tests/LitheTests/WorkspaceDirectoryMarkStoreTests.swift @@ -48,11 +48,29 @@ struct WorkspaceDirectoryMarkStoreTests { try store.loadDirectoryMarks(for: URL(fileURLWithPath: "/projects/other")).isEmpty ) } + @Test(arguments: [true, false]) + func savedGitPreferencesSurviveWithoutMaterializingDefaults(enabled: Bool) throws { + let preferences = DirectoryMarkKeyValueStore() + let store = WorkspaceDirectoryMarkStore(store: preferences) + let root = URL(fileURLWithPath: "/projects/preferences") + let key = "lithe.workspace-indexing." + root.path + #expect(try store.loadIndexingSettings(for: root).gitIgnoreEnabled == nil) + #expect(preferences.data(forKey: key) == nil) + var settings = WorkspaceIndexingSettings() + try store.saveIndexingSettings(settings, for: root) + let encoded = try #require(preferences.data(forKey: key)) + let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect(object["gitIgnoreEnabled"] == nil) + settings.gitIgnoreEnabled = enabled + try store.saveIndexingSettings(settings, for: root) + #expect(try store.loadIndexingSettings(for: root).gitIgnoreEnabled == enabled) + } + @Test func indexingSettingsPreserveManualMarksAndProjectIsolation() throws { let store = WorkspaceDirectoryMarkStore(store: DirectoryMarkKeyValueStore()) let root = URL(fileURLWithPath: "/projects/example") - #expect(try store.loadIndexingSettings(for: root).gitIgnoreEnabled) + #expect(try store.loadIndexingSettings(for: root).gitIgnoreEnabled == nil) try store.saveDirectoryMarks(["vendor": .excluded], for: root) var settings = WorkspaceIndexingSettings() settings.gitIgnoreEnabled = false diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 4ae02ff955..dea2aec4ba 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -8123,30 +8123,39 @@ pub(crate) fn untracked_candidates(directory: &Path, candidates: &[String]) -> O ) } -/// Returns immediate child directories without any tracked descendant or gitlink. -/// Native literal pathspec batches keep process count proportional to argument bytes. -pub(crate) fn untracked_directory_candidates( +/// Strict tracked-directory query preserving native failure and cancellation categories. +pub(crate) fn untracked_directory_candidates_checked( directory: &Path, candidates: &[String], -) -> Option> { +) -> Result, CoreError> { let mut untracked = candidates.iter().cloned().collect::>(); for batch in pathspec_batches(candidates, LS_FILES_PATHSPEC_BUDGET) { let mut arguments = vec!["--literal-pathspecs", "ls-files", "-z", "--"]; arguments.extend(batch.iter().map(String::as_str)); - let output = run_git(directory, &arguments).ok()?; - if !output.status.success() { - return None; + let output = execute_git_readonly( + &directory.to_string_lossy(), + &arguments + .iter() + .map(|value| (*value).to_owned()) + .collect::>(), + None, + )?; + if output.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect tracked repository directories", + ) + .with_details(output.stderr)); } for record in output .stdout - .split(|byte| *byte == 0) + .split('\0') .filter(|record| !record.is_empty()) { - let path = String::from_utf8_lossy(record); - untracked.remove(path.split('/').next().unwrap_or(&path)); + untracked.remove(record.split('/').next().unwrap_or(record)); } } - Some(untracked) + Ok(untracked) } /// Splits `paths` into consecutive non-empty batches whose summed byte length diff --git a/rust/lithe-core/src/project/indexing.rs b/rust/lithe-core/src/project/indexing.rs index ad4bb716d0..75e5558176 100644 --- a/rust/lithe-core/src/project/indexing.rs +++ b/rust/lithe-core/src/project/indexing.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex, OnceLock}; pub struct IndexingPolicy { pub manual_excluded_directories: Vec, pub included_directories: Vec, + #[serde(default)] pub git_ignore_enabled: bool, } @@ -37,6 +38,8 @@ pub struct ConfigureIndexingRequest { #[serde(rename_all = "camelCase")] /// Effective directory reasons and Git availability, never a claim about third-party scans. pub struct IndexingStatus { + /// Effective value resolved by Core; omission of the preference means disabled. + pub effective_git_ignore_enabled: bool, pub revision: u64, pub excluded_directories: BTreeMap, pub git_ignore_failed: bool, @@ -389,6 +392,7 @@ fn configure_with_provider_update( context.published.store(true, Ordering::Release); } Ok(IndexingStatus { + effective_git_ignore_enabled: context.policy.git_ignore_enabled, revision: lease_revision, excluded_directories, git_ignore_failed: context.git_failed.load(Ordering::Acquire), @@ -548,6 +552,7 @@ pub fn refresh_indexing(request: RefreshIndexingRequest) -> Result>(); // The containing repository's index protects tracked descendants and // submodule gitlinks; an independent ignored checkout is not exempt. - let untracked = crate::git::untracked_directory_candidates(parent, &ignored); + let untracked = + match crate::git::untracked_directory_candidates_checked(parent, &ignored) { + Ok(paths) => Some(paths), + Err(error) + if matches!(error.code, ErrorCode::Cancelled | ErrorCode::TimedOut) => + { + return Err(error) + } + Err(_) => None, + }; if untracked.is_none() { self.git_failed.store(true, Ordering::Release); } @@ -941,6 +955,142 @@ mod tests { use serde_json::json; use std::fs; + #[cfg(unix)] + #[test] + fn tracked_directory_query_fixture() { + use std::io::{Read, Write}; + let Some(socket) = std::env::var_os("LITHE_TRACKED_QUERY_SOCKET") else { + return; + }; + if !Path::new(&socket).exists() { + return; + } + let mut stream = std::os::unix::net::UnixStream::connect(socket).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + stream.write_all(b"ready").unwrap(); + let _ = stream.read(&mut [0]); + } + + #[cfg(unix)] + #[test] + fn tracked_directory_query_reclaims_the_process_on_cancel_and_timeout() { + use std::io::Read; + use std::os::unix::{fs::PermissionsExt, net::UnixListener}; + use std::time::{Duration, Instant}; + for cancel in [false, true] { + let workspace = Workspace::new(); + // Unix socket paths have a short platform limit; use the existing short fixture root. + let socket = std::env::temp_dir().join(format!("lq-{}-{cancel}", std::process::id())); + struct SocketPath(PathBuf); + impl Drop for SocketPath { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + let _cleanup = SocketPath(socket.clone()); + let listener = UnixListener::bind(&socket).unwrap(); + listener.set_nonblocking(true).unwrap(); + let executable = workspace.0.join("fake-git"); + let test_executable = std::env::current_exe() + .unwrap() + .to_string_lossy() + .replace('\'', "'\\''"); + fs::write(&executable, format!("#!/bin/sh\ncase \"$*\" in *--version*) echo 'git version 2.40.0';; *) LITHE_TRACKED_QUERY_SOCKET='{}' exec '{test_executable}' --exact project::indexing::tests::tracked_directory_query_fixture --nocapture;; esac\n", socket.display())).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let root = workspace.0.clone(); + let operation = format!("query-{}-{cancel}", root.display()); + let worker_operation = operation.clone(); + let (finished, completion) = std::sync::mpsc::sync_channel(1); + let worker = std::thread::spawn(move || { + let _request = + crate::protocol::cancellation::Scope::begin(Some(worker_operation), Some(1000)); + let _git = crate::git::execution_policy::Scope::begin(Some( + crate::git::execution_policy::GitExecutionOptions { + executable: Some(executable.to_string_lossy().into_owned()), + ..Default::default() + }, + )) + .unwrap(); + let result = + crate::git::untracked_directory_candidates_checked(&root, &["ignored".into()]); + let _ = finished.send( + result + .map(|_| ()) + .map_err(|error| format!("{:?}", error.code)), + ); + }); + let deadline = Instant::now() + Duration::from_secs(2); + let mut connection = loop { + match listener.accept() { + Ok((stream, _)) => break Some(stream), + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && Instant::now() < deadline => + { + std::thread::yield_now() + } + _ => break None, + } + }; + let ready = connection.as_mut().is_some_and(|stream| { + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = [0; 5]; + stream.read_exact(&mut bytes).is_ok() && bytes == *b"ready" + }); + if cancel { + crate::protocol::cancellation::cancel(&operation); + } + let outcome = completion.recv_timeout(Duration::from_secs(2)); + // Release a broken fixture before asserting, so failures still reclaim its worker. + if outcome.is_err() { + drop(connection.take()); + } + if outcome.is_ok() { + worker.join().unwrap(); + } + assert!( + ready, + "selected Git process never reached its gate: {outcome:?}" + ); + assert_eq!( + outcome.unwrap(), + Err(if cancel { "Cancelled" } else { "TimedOut" }.to_string()) + ); + assert_eq!( + connection.as_mut().unwrap().read(&mut [0]).unwrap(), + 0, + "owned child survived the query" + ); + } + } + + #[test] + fn omitted_git_preference_is_disabled_and_explicit_preferences_survive() { + let workspace = Workspace::new(); + for preference in [None, Some(true), Some(false)] { + let mut payload = json!({ + "root": workspace.0, "manualExcludedDirectories": [], "includedDirectories": [] + }); + if let Some(value) = preference { + payload["gitIgnoreEnabled"] = json!(value); + } + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"workspace.configureIndexing", "payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["effectiveGitIgnoreEnabled"], + preference.unwrap_or(false) + ); + } + } + #[test] fn owned_refresh_rechecks_git_without_replacing_any_release_token() { let workspace = Workspace::new(); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 80bd016041..50f897310a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -4613,7 +4613,11 @@ Fixture: `shared/fixtures/lsp/java-project-runtimes-v1.json`. ### Workspace indexing policy `workspace.configureIndexing` accepts `root`, `manualExcludedDirectories`, -`includedDirectories`, `gitIgnoreEnabled`, optional `refresh` and `paths`. +`includedDirectories`, optional `gitIgnoreEnabled`, `refresh` and `paths`. +Omitting `gitIgnoreEnabled` resolves to `false` in Core. Explicit saved booleans retain +their value. Configure and refresh responses include `effectiveGitIgnoreEnabled`; hosts +display that successful result without persisting defaults during reads. Older responses +without the field remain decodable, but do not establish a known effective switch value. Directory identifiers are workspace-relative `/` paths; `.` names the root. Manual ancestor exclusions take precedence over every include exception. An include exception disables automatic Git exclusion for its subtree, while allowing a diff --git a/shared/fixtures/workspace/indexing-policy-v1.json b/shared/fixtures/workspace/indexing-policy-v1.json index 62f9788c91..2c2eb615e8 100644 --- a/shared/fixtures/workspace/indexing-policy-v1.json +++ b/shared/fixtures/workspace/indexing-policy-v1.json @@ -22,7 +22,8 @@ "excludedDirectories": { "vendor": "manual" }, - "gitIgnoreFailed": false + "gitIgnoreFailed": false, + "effectiveGitIgnoreEnabled": true }, "cases": [ { @@ -65,5 +66,19 @@ "releasedOwner": "window-two/workspace-two", "rootRetired": true } + ], + "defaultPreferenceCases": [ + { + "storedValue": null, + "effectiveGitIgnoreEnabled": false + }, + { + "storedValue": true, + "effectiveGitIgnoreEnabled": true + }, + { + "storedValue": false, + "effectiveGitIgnoreEnabled": false + } ] } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 93e04c18fb..05b6df189a 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9704,7 +9704,8 @@ "native.returnedAnUnexpectedResponse": "{arg1} returned an unexpected response.", "indexing.title": "Project Indexing", "indexing.autoGit": "Automatically exclude Git-ignored directories", - "indexing.description": "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nNative exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee.", + "indexing.description": "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nSome language services may still scan excluded directories.", + "indexing.effectiveUnknown": "Not confirmed", "indexing.cancelExclusion": "Cancel Exclusion", "indexing.languageDescription": "Native exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee.", "indexing.providerDependent": "Provider-dependent", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 79fd0be542..36a6f791a9 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9704,7 +9704,8 @@ "native.returnedAnUnexpectedResponse": "{arg1} 返回了无法识别的响应。", "indexing.title": "项目索引", "indexing.autoGit": "根据 Git 忽略规则自动排除目录", - "indexing.description": "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。", + "indexing.description": "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n部分语言服务仍可能扫描已排除的目录。", + "indexing.effectiveUnknown": "未确认", "indexing.cancelExclusion": "取消排除", "indexing.languageDescription": "已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。", "indexing.providerDependent": "取决于语言服务", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 71c6160f9c..0df4a309cb 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -24219,6 +24219,18 @@ "arguments": [] } }, + { + "id": "indexing.effectiveUnknown", + "description": "Effective indexing preference has not been confirmed by Core", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Indexing preference not confirmed", + "arguments": [] + } + }, { "id": "indexing.dismissNotice", "description": "Ignored directory editor notice and explicit exclusion actions", diff --git a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json index f7a845fb4b..cb7b51b9d7 100644 --- a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json +++ b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json @@ -4,7 +4,7 @@ "group": "工作区生命周期", "capability": "目录索引排除与 Git 自动排除", "owner": "Workspace", - "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。 2026-11-02:新增.gitignore只读候选提示、选择排除和项目级不再显示;修复父忽略嵌套仓库的分析自动排除;忽略仅名称着色,排除整行底色,选择色优先。PyCharm2026.2.3对照后明确分析排除不影响Git提交,撤回此前Git发现/未跟踪候选/汇总过滤,Git请求不携带分析策略。已对照排除后提交项仍在、刷新与重开相同、父.gitignore仍忽略ShipFlow时显式登记它后其独立提交项可见;外层忽略与独立仓库映射分别处理。运行补修源码Core1582、macOS聚焦90项逐项计时通过,含空态提示挂载、规则/新文件颜色刷新、并发创建和展开;Git边界调整后的Bun31及TypeScript检查通过,前端此后未改。旧Core1581/Swift75/Bun57不代替最终源码;最终Lithe-Dev构建2026-11-02T07:41:31Z已替换安装;原生验证ShipFlow自动分析排除、开关及部分手动排除后的真实搜索、提示候选选择与不再显示重启持久化、名称棕色和整行底色、刷新后手动排除及ShipFlow独立Git候选保留。候选查询后的状态缓存泄漏已补修,全量Core1582及最终原生刷新通过;Swift源码此后未变。动态规则改色的界面操作未形成有效证据,以模型回归为依据。1426项bundle内容/模式/链接及签名不变,Dev偏好精确恢复,QA缓存归档,应用与辅助进程清理。固定工具链和Windows原生验收仍pending。 拆分至 preview 8b1d6669 后,本机补验 Core1583、Swift91(含挂载提示随元数据刷新且不重扫库存)、前端41(含多选存储失败原子回滚)及 TypeScript 通过;共享契约、i18n、边界、稳定性、矩阵与发行目录只读门禁通过。Swift6.4.2/Xcode27.2,固定工具链和 Windows 原生验收仍待完成。", + "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。 2026-11-02:新增.gitignore只读候选提示、选择排除和项目级不再显示;修复父忽略嵌套仓库的分析自动排除;忽略仅名称着色,排除整行底色,选择色优先。PyCharm2026.2.3对照后明确分析排除不影响Git提交,撤回此前Git发现/未跟踪候选/汇总过滤,Git请求不携带分析策略。已对照排除后提交项仍在、刷新与重开相同、父.gitignore仍忽略ShipFlow时显式登记它后其独立提交项可见;外层忽略与独立仓库映射分别处理。运行补修源码Core1582、macOS聚焦90项逐项计时通过,含空态提示挂载、规则/新文件颜色刷新、并发创建和展开;Git边界调整后的Bun31及TypeScript检查通过,前端此后未改。旧Core1581/Swift75/Bun57不代替最终源码;最终Lithe-Dev构建2026-11-02T07:41:31Z已替换安装;原生验证ShipFlow自动分析排除、开关及部分手动排除后的真实搜索、提示候选选择与不再显示重启持久化、名称棕色和整行底色、刷新后手动排除及ShipFlow独立Git候选保留。候选查询后的状态缓存泄漏已补修,全量Core1582及最终原生刷新通过;Swift源码此后未变。动态规则改色的界面操作未形成有效证据,以模型回归为依据。1426项bundle内容/模式/链接及签名不变,Dev偏好精确恢复,QA缓存归档,应用与辅助进程清理。固定工具链和Windows原生验收仍pending。 拆分至 preview 8b1d6669 后,本机补验 Core1583、Swift91(含挂载提示随元数据刷新且不重扫库存)、前端41(含多选存储失败原子回滚)及 TypeScript 通过;共享契约、i18n、边界、稳定性、矩阵与发行目录只读门禁通过。Swift6.4.2/Xcode27.2,固定工具链和 Windows 原生验收仍待完成。 2026-11-03补修:Core解析未保存Git自动排除偏好为关闭,保留已保存布尔值;配置/刷新返回生效值,两端加载后回显、不补写默认配置。ⓘ删除语言适配细节并保留部分语言服务仍可能扫描的限制。ls-files复用Git Host取消/超时及进程树清理,JDT车道显式筛选模块且普通Core仍全量覆盖;最终验证结果按本轮报告记录。 本轮第一组本地:Core1586项及Rust↔Swift桥接通过,索引37项有逐项计时,前端61项及TypeScript通过;Swift63项在本机Swift6.4.2通过,固定Swift6.3.3/Xcode26.6和Windows原生仍以新head CI为准。独立审查发现的配置失败与未知状态展示已修复并复审,无新增发现。", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", diff --git a/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts b/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts index 3b262295ed..0700e4e165 100644 --- a/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts +++ b/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts @@ -333,7 +333,7 @@ for (const scenario of fixture.cases) { const before = readIndexingSettings(root); writeIndexingSettings(root, {...before, gitIgnoreEnabled: false}); writeIndexingSettings(root, {...readIndexingSettings(root), gitIgnoreEnabled: true}); - expect(readIndexingSettings(root)).toEqual(before); + expect(readIndexingSettings(root)).toEqual({...before, gitIgnoreEnabled: true}); }); } diff --git a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.test.ts b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.test.ts index fc6f95033c..12fa980211 100644 --- a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.test.ts +++ b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.test.ts @@ -20,6 +20,21 @@ beforeEach(() => { }); afterEach(() => restore()); +test("missing preferences remain unset through read and directory preference writes", () => { + const key = indexingSettingsStorageKey("C:/unset"); + const settings = readIndexingSettings("C:/unset"); + expect(settings.gitIgnoreEnabled).toBeUndefined(); + expect(window.localStorage.getItem(key)).toBeNull(); + writeIndexingSettings("C:/unset", {...settings, includedDirectories: ["keep"]}); + expect(JSON.parse(window.localStorage.getItem(key)!)).not.toHaveProperty("gitIgnoreEnabled"); + expect(readIndexingSettings("C:/unset").includedDirectories).toEqual(["keep"]); +}); + +test.each([true, false])("saved Git preference %s survives reads", (enabled) => { + writeIndexingSettings("C:/saved", {gitIgnoreEnabled: enabled, includedDirectories: [], previousRoles: {}}); + expect(readIndexingSettings("C:/saved").gitIgnoreEnabled).toBe(enabled); +}); + test.each([ "", "broken", diff --git a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts index d74c9f1910..50d394935c 100644 --- a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts +++ b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts @@ -10,12 +10,13 @@ import { IndexingSettingsError } from "./indexing-settings-error"; export { IndexingSettingsError, type IndexingErrorCode } from "./indexing-settings-error"; export interface IndexingSettings { - gitIgnoreEnabled: boolean; + gitIgnoreEnabled?: boolean; includedDirectories: string[]; previousRoles: Record; ignoredDirectoryNoticeDismissed?: boolean; } export interface IndexingStatus { + effectiveGitIgnoreEnabled?: boolean; revision: number; excludedDirectories: Record; gitIgnoreFailed: boolean; @@ -35,7 +36,7 @@ function readStoredValue(key: string): string | null { export function readIndexingSettings(root: string): IndexingSettings { const raw = readStoredValue(indexingSettingsStorageKey(root)); - if (raw === null) return { gitIgnoreEnabled: true, includedDirectories: [], previousRoles: {} }; + if (raw === null) return { includedDirectories: [], previousRoles: {} }; let value: unknown; try { value = JSON.parse(raw); @@ -46,7 +47,7 @@ export function readIndexingSettings(root: string): IndexingSettings { throw new IndexingSettingsError("invalidSettings"); const settings = value as Record; if ( - typeof settings.gitIgnoreEnabled !== "boolean" || + (settings.gitIgnoreEnabled !== undefined && typeof settings.gitIgnoreEnabled !== "boolean") || !Array.isArray(settings.includedDirectories) || !settings.includedDirectories.every((path: unknown) => typeof path === "string") || (settings.ignoredDirectoryNoticeDismissed !== undefined && typeof settings.ignoredDirectoryNoticeDismissed !== "boolean") || @@ -58,7 +59,7 @@ export function readIndexingSettings(root: string): IndexingSettings { ) throw new IndexingSettingsError("invalidSettings"); return { - gitIgnoreEnabled: settings.gitIgnoreEnabled, + ...(settings.gitIgnoreEnabled !== undefined ? {gitIgnoreEnabled: settings.gitIgnoreEnabled as boolean} : {}), includedDirectories: settings.includedDirectories, previousRoles: (settings.previousRoles ?? {}) as Record, ...(settings.ignoredDirectoryNoticeDismissed !== undefined diff --git a/windows/tauri/src/features/settings/components/git-indexing-settings.test.tsx b/windows/tauri/src/features/settings/components/git-indexing-settings.test.tsx index bc78701099..26b1b07c29 100644 --- a/windows/tauri/src/features/settings/components/git-indexing-settings.test.tsx +++ b/windows/tauri/src/features/settings/components/git-indexing-settings.test.tsx @@ -29,7 +29,8 @@ test.each(["en-US", "zh-CN"] as const)( previousRoles: { vendor: "sources" }, }; const configure = spyOn(indexing, "configureWorkspaceIndexing").mockImplementation( - async () => ({ + async (root) => ({ + effectiveGitIgnoreEnabled: root ? indexing.readIndexingSettings(root).gitIgnoreEnabled ?? false : false, revision: 1, excludedDirectories: {}, gitIgnoreFailed: false, @@ -87,11 +88,11 @@ test.each(["en-US", "zh-CN"] as const)( await act(async () => checkbox().click()); await act(async () => fileSystem.setState({ rootFolderPath: second })); - expect(checkbox().getAttribute("aria-checked")).toBe("true"); + expect(checkbox().getAttribute("aria-checked")).toBe("false"); // Reading a default value on the Git page must not create a saved preference. expect(window.localStorage.getItem(indexing.indexingSettingsStorageKey(second))).toBeNull(); await act(async () => checkbox().click()); - expect(indexing.readIndexingSettings(second).gitIgnoreEnabled).toBe(false); + expect(indexing.readIndexingSettings(second).gitIgnoreEnabled).toBe(true); expect(indexing.readIndexingSettings(first)).toEqual({ ...settings, gitIgnoreEnabled: true }); await act(async () => fileSystem.setState({ rootFolderPath: undefined })); @@ -120,13 +121,24 @@ test.each(["en-US", "zh-CN"] as const)( failedWrite.mockRestore(); } + configure.mockImplementation(async () => { throw new Error("fixture configure failed"); }); + await act(async () => checkbox().click()); + expect(indexing.readIndexingSettings(first).gitIgnoreEnabled).toBe(false); + expect(checkbox().getAttribute("aria-disabled")).toBe("true"); + expect(checkbox().getAttribute("aria-checked")).toBe("true"); + expect(host.querySelector('[role="alert"]')?.textContent).toBe(t("indexing.configureFailed")); + configure.mockImplementation(async (root) => ({revision: 1, excludedDirectories: {}, gitIgnoreFailed: false, effectiveGitIgnoreEnabled: root ? indexing.readIndexingSettings(root).gitIgnoreEnabled ?? false : false})); + await act(async () => window.dispatchEvent(new window.CustomEvent(indexing.indexingEvent, {detail: first}))); + expect(checkbox().getAttribute("aria-disabled")).not.toBe("true"); + expect(checkbox().getAttribute("aria-checked")).toBe("false"); + for (const remote of ["remote://fixture/project", "wsl://fixture/project"]) { const raw = JSON.stringify({ ...settings, gitIgnoreEnabled: false }); window.localStorage.setItem(indexing.indexingSettingsStorageKey(remote), raw); const calls = configure.mock.calls.length; await act(async () => fileSystem.setState({ rootFolderPath: remote })); expect(checkbox().getAttribute("aria-disabled")).toBe("true"); - expect(checkbox().getAttribute("aria-checked")).toBe("false"); + expect(checkbox().getAttribute("aria-checked")).toBe("mixed"); expect(host.textContent).toContain(t("indexing.remoteUnsupported")); await act(async () => checkbox().click()); expect(configure.mock.calls.length).toBe(calls); @@ -181,6 +193,8 @@ test("a late local configuration cannot replace the remote capability state", as environment.IS_REACT_ACT_ENVIRONMENT = true; document.body.append(host); await act(async () => render("C:/fixture")); + expect(host.querySelector('[role="checkbox"]')?.getAttribute("aria-checked")).toBe("mixed"); + expect(host.querySelector('[role="checkbox"]')?.getAttribute("aria-disabled")).toBe("true"); await act(async () => render("remote://fixture/project")); await act(async () => finish({ revision: 1, excludedDirectories: {}, gitIgnoreFailed: true })); expect(configure).toHaveBeenCalledTimes(1); diff --git a/windows/tauri/src/features/settings/components/workspace-indexing-settings.tsx b/windows/tauri/src/features/settings/components/workspace-indexing-settings.tsx index 350fd26d4f..29afd1d6db 100644 --- a/windows/tauri/src/features/settings/components/workspace-indexing-settings.tsx +++ b/windows/tauri/src/features/settings/components/workspace-indexing-settings.tsx @@ -20,11 +20,12 @@ export function WorkspaceIndexingSettings({ root }: { root: string | undefined } const supported = canUseNativeFileSearch(root); const errorKey = (cause: unknown, fallback: string) => cause instanceof IndexingSettingsError ? `indexing.${cause.code}` : fallback; - const [enabled, setEnabled] = useState(true); + const [enabled, setEnabled] = useState(); const [error, setError] = useState(""); const [failed, setFailed] = useState(false); const [loaded, setLoaded] = useState(false); useEffect(() => { + setEnabled(undefined); setLoaded(false); setFailed(false); setError(""); @@ -33,9 +34,9 @@ export function WorkspaceIndexingSettings({ root }: { root: string | undefined } let generation = 0; const read = () => { const current = ++generation; + setLoaded(false); try { - setEnabled(readIndexingSettings(root).gitIgnoreEnabled); - setLoaded(true); + readIndexingSettings(root); setError(""); } catch (cause) { setLoaded(false); @@ -45,7 +46,11 @@ export function WorkspaceIndexingSettings({ root }: { root: string | undefined } if (!supported) return; void configureWorkspaceIndexing(root, [], false, workspaceId) .then((status) => { - if (!disposed && current === generation) setFailed(status.gitIgnoreFailed); + if (!disposed && current === generation) { + setEnabled(status.effectiveGitIgnoreEnabled); + setLoaded(typeof status.effectiveGitIgnoreEnabled === "boolean"); + setFailed(status.gitIgnoreFailed); + } }) .catch((cause) => { if (!disposed && current === generation) @@ -73,14 +78,15 @@ export function WorkspaceIndexingSettings({ root }: { root: string | undefined } data-setting-interactive-root="true" data-setting-primary-control="true" aria-label={t("indexing.autoGit")} - checked={enabled} + checked={enabled === true} + indeterminate={enabled === undefined} + aria-description={enabled === undefined ? t("indexing.effectiveUnknown") : undefined} disabled={!supported || !loaded} onCheckedChange={(checked) => { if (!root || !supported || !loaded) return; try { const settings = readIndexingSettings(root); writeIndexingSettings(root, { ...settings, gitIgnoreEnabled: checked }); - setEnabled(checked); setError(""); } catch (cause) { setError(errorKey(cause, "indexing.writeFailed")); diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index 17a566b010..a7d388874c 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -3005,8 +3005,9 @@ export const sharedCatalogs = { "indexing.autoGit": "Automatically exclude Git-ignored directories", "indexing.cancelExclusion": "Cancel Exclusion", "indexing.configureFailed": "Could not apply project indexing exclusions. Try reopening the project.", - "indexing.description": "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nNative exclusion settings are merged for Java imports, Rust and Go. Java resources already imported may still be scanned. Python project configuration can override server exclusions; other providers remain active without a verified scan-exclusion guarantee.", + "indexing.description": "Applies only to the current project, regardless of the Git configuration scope. Excluded directories remain browsable. Cancel Exclusion creates a project exception without changing Git rules.\n\nSome language services may still scan excluded directories.", "indexing.dismissNotice": "Don't Show Again", + "indexing.effectiveUnknown": "Not confirmed", "indexing.excludeSelected": "Exclude Selected", "indexing.gitFailed": "Git ignore exclusions could not be fully resolved. Manual exclusions remain active.", "indexing.gitReason": "Excluded from indexing by Git ignore rules", @@ -8042,8 +8043,9 @@ export const sharedCatalogs = { "indexing.autoGit": "根据 Git 忽略规则自动排除目录", "indexing.cancelExclusion": "取消排除", "indexing.configureFailed": "无法应用项目索引排除策略,请重新打开项目后重试。", - "indexing.description": "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n已合并 Java 导入、Rust 和 Go 的原生排除配置。Java 已导入资源仍可能被扫描;Python 项目配置可覆盖服务端排除。其他服务保持运行,尚未保证其后台扫描排除效果。", + "indexing.description": "仅作用于当前项目,不受 Git 配置范围影响。已排除目录仍可浏览;取消排除会保存项目例外,不修改 Git 规则。\n\n部分语言服务仍可能扫描已排除的目录。", "indexing.dismissNotice": "不再显示", + "indexing.effectiveUnknown": "未确认", "indexing.excludeSelected": "排除所选目录", "indexing.gitFailed": "无法完整解析 Git 忽略规则,手动排除仍然生效。", "indexing.gitReason": "根据 Git 忽略规则排除索引", From e70a04ad37048aa47c7a593ddfbe7528897e3ae5 Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Tue, 3 Nov 2026 11:48:51 +0800 Subject: [PATCH 09/10] fix(git): align path identity and inventory visibility with bounded streaming --- ...026-10-28-workspace-indexing-exclusions.md | 8 ++ ...-git-repository-discovery-and-selection.md | 25 ++++ .../Lithe/Core/Rust/RustCoreBridge.swift | 6 +- .../Core/Rust/RustWorkspaceOperations.swift | 4 +- .../Remote/RemoteWorkspaceRouting.swift | 4 +- .../Workspace/WorkspaceFeatureContracts.swift | 4 +- .../Application/WorkspaceFeatureModel.swift | 5 +- .../LazyProjectTreeInventoryTests.swift | 2 +- .../src/git/repository_discovery.rs | 15 ++- rust/lithe-core/src/project/files.rs | 6 +- rust/lithe-core/src/project/indexing.rs | 59 ++++++++- rust/lithe-core/src/project/mod.rs | 1 + rust/lithe-core/src/project/path_identity.rs | 125 ++++++++++++++++++ .../src/tests/git_repository_discovery.rs | 54 ++++++++ rust/lithe-git-host/src/lib.rs | 30 +++-- rust/lithe-git-host/tests/process.rs | 34 +++++ shared/contracts/rust-core-api.md | 5 +- .../ignored-directory-suggestions-v1.json | 40 +++++- .../features/git-repository-discovery.json | 2 +- .../workspace-indexing-exclusions.json | 2 +- .../stores/workspace-indexing.ts | 3 +- 21 files changed, 396 insertions(+), 38 deletions(-) create mode 100644 rust/lithe-core/src/project/path_identity.rs diff --git a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md index ef221a6c57..6d3856eaf6 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md +++ b/.agents/notes/implemented/architecture/2026-10-28-workspace-indexing-exclusions.md @@ -527,6 +527,14 @@ ShipFlow 的已跟踪修改、未跟踪文件均仍存在。最终截图可区 已成功配置后的保存失败保留最后确认值;中英回归覆盖失败和重试。固定工具链、 Windows 原生及新源码界面截图仍须按最终提交核对,不沿用历史验收结论。 +两组整合开发构建的实际 Lithe-Dev 进一步确认:新项目的自动排除默认关闭, +打开设置不产生该项目偏好;显式开启保存 true,再关闭后回显 off。中英文说明均 +显示精简后的语言服务限制,英文局部截图保留原像素并用红框标注。自动化在返回 +项目时再次超时,多选排除与 Git 面板本轮原生复验保持 pending。应用运行前后 +1434 项文件/符号链接、模式和内容哈希一致,签名通过;Git 忽略文件和索引未变, +开发偏好精确恢复,验收应用和辅助进程清理,临时 app 注册注销并删除。 +证据在 `.artifacts/pr-repair-runtime/`,含源码补丁身份、二进制哈希、性能数据与清理记录。 + ## 适用范围 - `rust/lithe-core/src/project/indexing.rs` diff --git a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md index f962d93111..24076aea75 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md +++ b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md @@ -49,6 +49,23 @@ macOS 点击另一仓库的分支时,先刷新目标仓库,再设置目标 正确做法:父项目扫描不进入 `.build`,显式打开依赖仓库仍可正常使用 Git。 不要这样做:只在引用面板隐藏依赖仓库,让状态聚合仍然读取它们;也不要在新仓库里查询旧分支。 +### 2026-11-03:Windows 路径身份与流式吞吐 + +仓库发现使用去掉扩展前缀的 Git 显示路径,但库存委派根使用原生规范路径。 +两者直接 starts_with 在 Windows 会漏掉嵌套根,保存的手动排除也会因目录大小写变化失效。 +共享的组件边界比较现在只在比较时统一 Windows 普通、扩展前缀、UNC 和大小写; +文件系统访问和索引策略键仍保持原生规范路径,不把显示路径写回策略表。 +Unix 比较继续区分大小写,合法冒号目录保持有效,同名前缀兄弟不受排除影响。 + +目录候选复用库存可见性与嵌套 linked-worktree 边界,macOS 传入库存当前使用的规则; +Windows 平面库存目前使用 Core 默认规则,候选请求也显式使用同一默认值,不把仅供 +前端树呈现的隐藏设置冒充已生效的库存规则。Core 回归覆盖内置隐藏、取消内置隐藏和自定义规则。 + +Git Host 保留每轮两条流各 64 KiB 的读取预算;任一流取得进展后立即进入下一轮, +每轮仍检查取消和另一条流,只有没有进展时才按原间隔等待。不提高捕获、取消或清理预算。 +Windows 旧任务的 check-run 注解确认超过 45 分钟上限;共享 Core 步骤先失败,但详细 +日志已不可取,不能把后续取消单独归因于路径问题,也不因此扩大 job 时间预算。 + ## 考虑过的备选方案 - 继续全量扫描、仅隐藏 UI 行:后台仍读取依赖状态,提交范围和扫描成本没有修复。 @@ -91,6 +108,14 @@ macOS 点击另一仓库的分支时,先刷新目标仓库,再设置目标 - `./scripts/architecture/verify-shared-contracts.sh` - `./scripts/agent-notes/verify-agent-notes.sh` +本轮最终 Core 1596 项全量逐项计时、Git Host 10 项、Swift 318 项、前端 88 项及 +TypeScript、Rust↔Swift 桥接通过。33 MiB 输出用例的原预算下,从修复前 6248 ms +降至最终 3539 ms;新增取消回归 179 ms,都是组件样本,不冒充界面流畅度。 +1000 个数据目录、10000 个文件及 20 个嵌套仓库的合成项目,三次发现为 +658/269/309 ms,Git 进程数 20/19/19,均保留预期的 6 个仓库。 +Windows 路径原生回归仍由新 head CI 核验;macOS 的 Windows host 检查只出现 +未修改基线的两个平台 API 错误,不能算 Windows 构建通过。 + ## 适用范围 - `rust/lithe-core/src/git/repository_discovery.rs` diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 55449b6f8f..c1a11249f9 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2531,9 +2531,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let _: Response? = execute(command: "workspace.releaseIndexing", payload: Payload(root: rootURL.path, revision: revision, ownerId: ownerID)) } - func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { - struct Request: Encodable { let root: String } - return try executeResult(command: "workspace.ignoredDirectorySuggestions", payload: Request(root: rootURL.path), timeoutMilliseconds: 10_000).get() + func ignoredDirectorySuggestions(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceIgnoredDirectorySuggestions { + struct Request: Encodable { let root: String; let hiddenDirectoryNames: [String]; let hiddenFilePatterns: [String] } + return try executeResult(command: "workspace.ignoredDirectorySuggestions", payload: Request(root: rootURL.path, hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, hiddenFilePatterns: visibilityRules.hiddenFilePatterns), timeoutMilliseconds: 10_000).get() } func configureIndexing(at rootURL: URL, policy: WorkspaceIndexingPolicy, refresh: Bool, ownerID: String? = nil) throws -> WorkspaceIndexingStatus { diff --git a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift index ad0fd08737..70799250a6 100644 --- a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift @@ -34,8 +34,8 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { } } - func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { - do { return try core.ignoredDirectorySuggestions(at: rootURL) } + func ignoredDirectorySuggestions(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceIgnoredDirectorySuggestions { + do { return try core.ignoredDirectorySuggestions(at: rootURL, visibilityRules: visibilityRules) } catch let error as RustCoreBridge.CoreCallError { throw indexingFailure(error) } catch { throw WorkspaceIndexingFailure.configureFailed } } diff --git a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift index 21703278c3..9ebcb577f6 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift @@ -177,9 +177,9 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { local.releaseIndexing(at: rootURL, revision: revision, ownerID: ownerID) } - func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + func ignoredDirectorySuggestions(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceIgnoredDirectorySuggestions { guard registry.binding(forRoot: rootURL) == nil else { throw WorkspaceIndexingFailure.configureFailed } - return try local.ignoredDirectorySuggestions(at: rootURL) + return try local.ignoredDirectorySuggestions(at: rootURL, visibilityRules: visibilityRules) } func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceSnapshot? { diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift index 20709bb3a5..1c53d1d1ab 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift @@ -71,7 +71,7 @@ package protocol WorkspaceSessionStoring: AnyObject { package protocol WorkspaceOperations: Sendable { func releaseIndexing(at rootURL: URL, revision: UInt64, ownerID: String?) func configureIndexing(at rootURL: URL, policy: WorkspaceIndexingPolicy, refresh: Bool, ownerID: String?) throws -> WorkspaceIndexingStatus - func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions + func ignoredDirectorySuggestions(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceIgnoredDirectorySuggestions /// Drops package-compaction lookahead after workspace or filesystem changes. func invalidateDirectoryListings() @@ -100,7 +100,7 @@ extension WorkspaceOperations { } package func releaseIndexing(at rootURL: URL, revision: UInt64, ownerID: String?) {} package func configureIndexing(at rootURL: URL, policy: WorkspaceIndexingPolicy, refresh: Bool, ownerID: String?) throws -> WorkspaceIndexingStatus { WorkspaceIndexingStatus() } - package func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + package func ignoredDirectorySuggestions(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceIgnoredDirectorySuggestions { throw WorkspaceIndexingFailure.configureFailed } package func invalidateDirectoryListings() {} diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index eb01af7df4..9a9269f375 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -742,11 +742,12 @@ package final class WorkspaceFeatureModel: ObservableObject { throw WorkspaceIndexingFailure.configureFailed } let generation = workspaceGeneration + let rules = visibilityRules let result = try await Task.detached(priority: .utility) { [operations] in - try operations.ignoredDirectorySuggestions(at: workspaceURL) + try operations.ignoredDirectorySuggestions(at: workspaceURL, visibilityRules: rules) }.value guard !Task.isCancelled, self.workspaceURL == workspaceURL, - self.workspaceGeneration == generation else { throw CancellationError() } + self.workspaceGeneration == generation, self.visibilityRules == rules else { throw CancellationError() } return result } diff --git a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift index eba7fcd164..a11867e27d 100644 --- a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift +++ b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift @@ -1064,7 +1064,7 @@ private final class InventoryDisk: WorkspaceOperations, WorkspaceFileOperations, return gate } func setSuggestions(_ paths: [String]) { lock.withLock { suggestedPaths = paths } } - func ignoredDirectorySuggestions(at rootURL: URL) throws -> WorkspaceIgnoredDirectorySuggestions { + func ignoredDirectorySuggestions(at rootURL: URL, visibilityRules: FileVisibilityRules) throws -> WorkspaceIgnoredDirectorySuggestions { suggestionsRequested.open() let (result, gate) = lock.withLock { var result = WorkspaceIgnoredDirectorySuggestions() diff --git a/rust/lithe-core/src/git/repository_discovery.rs b/rust/lithe-core/src/git/repository_discovery.rs index e535e4b2cf..7876ce0101 100644 --- a/rust/lithe-core/src/git/repository_discovery.rs +++ b/rust/lithe-core/src/git/repository_discovery.rs @@ -7,6 +7,7 @@ use super::{ WorkspaceRepositoriesRequest, }; use crate::project::files::VisibilityRules; +use crate::project::path_identity; use crate::protocol::{ CoreError, ErrorCode, WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, }; @@ -42,7 +43,7 @@ pub fn workspace_repositories( Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return Err(repository_scan_error(error)), }; - if !directory.starts_with(&workspace) + if !path_identity::contains(&workspace, &directory) || boundaries.is_delegated(&directory) || !visited.insert(directory.clone()) { @@ -62,7 +63,9 @@ pub fn workspace_repositories( } else if directory_is_visible(&name) && entry.file_type().map_err(repository_scan_error)?.is_dir() && !boundaries.is_delegated(&entry.path()) - && !excluded.iter().any(|path| entry.path().starts_with(path)) + && !excluded + .iter() + .any(|path| path_identity::contains(path, &entry.path())) { children.push(name); } @@ -76,13 +79,15 @@ pub fn workspace_repositories( { for path in submodule_paths(root)? { let candidate = root.join(path); - if !candidate.starts_with(&workspace) || boundaries.is_delegated(&candidate) { + if boundaries.is_delegated(&candidate) { continue; } - let relative = candidate.strip_prefix(&workspace).unwrap(); + let Some(relative) = path_identity::relative_to(&workspace, &candidate) else { + continue; + }; let dependency_depth = relative.components().count(); if dependency_depth <= request.max_depth - && !has_symlink_component(&workspace, relative)? + && !has_symlink_component(&workspace, &relative)? && git_entry_opens_as_repository(&candidate.join(".git")) { queue.push_back((candidate, dependency_depth, Some(root.clone()))); diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index dbc3420834..0dceab97b3 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -1140,7 +1140,9 @@ impl VisibilityRules { // Missing descendants still retain their boundary until the host removes the root. let candidate = search_index::canonicalize_with_missing_components(Path::new(value)) .unwrap_or_else(|| PathBuf::from(value)); - if candidate != root && candidate.starts_with(root) { + if !super::path_identity::same(&candidate, root) + && super::path_identity::contains(root, &candidate) + { self.delegated_roots.push(candidate); } } @@ -1152,7 +1154,7 @@ impl VisibilityRules { pub(crate) fn is_delegated(&self, path: &Path) -> bool { self.delegated_roots .iter() - .any(|root| path.starts_with(root)) + .any(|root| super::path_identity::contains(root, path)) } pub(crate) fn is_hidden(&self, path: &str, is_directory: bool) -> bool { diff --git a/rust/lithe-core/src/project/indexing.rs b/rust/lithe-core/src/project/indexing.rs index 70082a9677..fc7db8bc58 100644 --- a/rust/lithe-core/src/project/indexing.rs +++ b/rust/lithe-core/src/project/indexing.rs @@ -53,6 +53,11 @@ pub struct IgnoredDirectorySuggestionsRequest { /// Explicit root membership; nested roots are inspected with their own policy. #[serde(default)] pub workspace_roots: Vec, + /// The same effective visibility overrides supplied to workspace snapshots. + #[serde(default)] + pub hidden_directory_names: Vec, + #[serde(default)] + pub hidden_file_patterns: Vec, } #[derive(Default, Serialize)] @@ -80,8 +85,11 @@ fn scan_ignored_directories( max_entries: usize, ) -> Result { let root = existing_root(&request.root)?; - let boundaries = super::files::VisibilityRules::new(Vec::new(), Vec::new()) - .with_workspace_roots(&root, &request.workspace_roots); + let boundaries = super::files::VisibilityRules::new( + request.hidden_directory_names, + request.hidden_file_patterns, + ) + .with_workspace_roots(&root, &request.workspace_roots); let context = active_provider_context(&root).ok_or_else(|| { CoreError::new(ErrorCode::Cancelled, "Workspace indexing is not configured") })?; @@ -140,6 +148,13 @@ fn scan_ignored_directories( match entry { Ok(entry) if entry.file_name() != ".git" => match entry.file_type() { Ok(kind) if kind.is_dir() => { + let path = entry.path(); + if boundaries.is_delegated(&path) + || boundaries.is_hidden(&relative_path(&path, &root), true) + || super::files::is_nested_linked_worktree(&path) + { + continue; + } children.insert(entry.path()); if children.len() + queue.len() + visited > max_directories { children.pop_last(); @@ -955,6 +970,38 @@ mod tests { use serde_json::json; use std::fs; + #[test] + fn suggestions_use_the_inventory_visibility_rules_and_disabled_built_ins() { + let workspace = Workspace::new(); + workspace.git(&["init", "--quiet"]); + for name in ["node_modules", "archive"] { + fs::create_dir_all(workspace.0.join(name)).unwrap(); + fs::write(workspace.0.join(name).join("file.js"), "content").unwrap(); + } + fs::write(workspace.0.join(".gitignore"), "node_modules/\narchive/\n").unwrap(); + workspace.configure(&[], &[], false); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/ignored-directory-suggestions-v1.json" + )) + .unwrap(); + for case in fixture["visibilityCases"].as_array().unwrap() { + let response = ignored_directory_suggestions( + serde_json::from_value(json!({ + "root":workspace.0, + "hiddenDirectoryNames":case["hiddenDirectoryNames"], + "hiddenFilePatterns":case["hiddenFilePatterns"] + })) + .unwrap(), + ) + .unwrap(); + assert_eq!( + serde_json::to_value(response.directories).unwrap(), + case["expectedSuggestions"] + ); + assert!(!response.incomplete); + } + } + #[cfg(unix)] #[test] fn tracked_directory_query_fixture() { @@ -1999,6 +2046,8 @@ mod tests { ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { root: workspace.0.to_string_lossy().into_owned(), workspace_roots: Vec::new(), + hidden_directory_names: Vec::new(), + hidden_file_patterns: Vec::new(), }) .unwrap() .directories @@ -2021,6 +2070,8 @@ mod tests { let suggestions = ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { root: workspace.0.to_string_lossy().into_owned(), workspace_roots: Vec::new(), + hidden_directory_names: Vec::new(), + hidden_file_patterns: Vec::new(), }) .unwrap(); assert_eq!(suggestions.directories, ["ignored"]); @@ -2049,6 +2100,8 @@ mod tests { let suggestions = ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { root: workspace.0.to_string_lossy().into_owned(), workspace_roots: Vec::new(), + hidden_directory_names: Vec::new(), + hidden_file_patterns: Vec::new(), }) .unwrap(); assert_eq!(suggestions.directories, ["ignored"]); @@ -2084,6 +2137,8 @@ mod tests { ignored_directory_suggestions(IgnoredDirectorySuggestionsRequest { root: workspace.0.to_string_lossy().into_owned(), workspace_roots: Vec::new(), + hidden_directory_names: Vec::new(), + hidden_file_patterns: Vec::new(), }) .unwrap() .directories diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 72ae4add53..219081b628 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -10,6 +10,7 @@ mod maven; mod maven_dependency_tree; mod maven_test_reports; mod name_matching; +pub(crate) mod path_identity; mod search_index; mod search_paging; pub(crate) use search_paging::{search_page, SearchPageRequest}; diff --git a/rust/lithe-core/src/project/path_identity.rs b/rust/lithe-core/src/project/path_identity.rs new file mode 100644 index 0000000000..066363069f --- /dev/null +++ b/rust/lithe-core/src/project/path_identity.rs @@ -0,0 +1,125 @@ +//! Component-boundary path comparisons without changing native filesystem or policy keys. + +use std::path::Path; + +/// Compares identity while leaving canonical native paths unchanged for I/O and registry keys. +pub(crate) fn same(left: &Path, right: &Path) -> bool { + #[cfg(windows)] + { + windows_key(&left.to_string_lossy()) == windows_key(&right.to_string_lossy()) + } + #[cfg(not(windows))] + { + left == right + } +} + +/// Tests ancestry at a complete component boundary using the platform's existing case policy. +pub(crate) fn contains(parent: &Path, child: &Path) -> bool { + #[cfg(windows)] + { + windows_contains(&parent.to_string_lossy(), &child.to_string_lossy()) + } + #[cfg(not(windows))] + { + child.starts_with(parent) + } +} + +/// Extracts native child components using the same identity rules as ancestry checks. +pub(crate) fn relative_to(parent: &Path, child: &Path) -> Option { + #[cfg(windows)] + { + contains(parent, child).then(|| { + child + .components() + .skip(parent.components().count()) + .collect() + }) + } + #[cfg(not(windows))] + { + child.strip_prefix(parent).ok().map(Path::to_path_buf) + } +} + +#[cfg(any(windows, test))] +fn windows_key(path: &str) -> String { + let path = path.replace('\\', "/"); + let ordinary = if path + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/")) + { + format!("//{}", &path[8..]) + } else if let Some(path) = path.strip_prefix("//?/") { + path.to_owned() + } else { + path + }; + ordinary.trim_end_matches('/').to_lowercase() +} + +#[cfg(any(windows, test))] +fn windows_contains(parent: &str, child: &str) -> bool { + let parent = windows_key(parent); + let child = windows_key(child); + child == parent + || child + .strip_prefix(&parent) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(windows)] + #[test] + fn relative_components_follow_windows_case_and_prefix_identity() { + for (parent, child) in [ + (r"C:\repo\Foo", r"c:\repo\foo\sub"), + (r"\\?\C:\repo\Foo", r"C:\repo\foo\Sub"), + (r"\\server\share\Foo", r"\\?\UNC\SERVER\share\foo\Sub"), + ] { + let relative = relative_to(Path::new(parent), Path::new(child)).unwrap(); + assert_eq!(relative.components().count(), 1); + assert!(relative.to_string_lossy().eq_ignore_ascii_case("sub")); + } + assert!( + relative_to(Path::new(r"C:\repo\foo"), Path::new(r"C:\repo\foo-old\sub")).is_none() + ); + } + + #[test] + fn windows_prefixes_and_case_preserve_component_boundaries() { + for (parent, child) in [ + (r"C:\work\Cache", r"\\?\c:\WORK\cache\child"), + (r"\\?\C:\work", "c:/WORK/child"), + (r"\\server\share\cache", r"\\?\UNC\SERVER\share\CACHE\child"), + ("C:/", r"\\?\C:\work"), + ] { + assert!(windows_contains(parent, child), "{parent} -> {child}"); + } + for (parent, child) in [ + ("C:/work/cache", "C:/work/cache-old"), + (r"\\server\share", r"\\server\share-other\child"), + ("C:/work", "D:/work/child"), + ] { + assert!(!windows_contains(parent, child), "{parent} -> {child}"); + } + } + + #[cfg(not(windows))] + #[test] + fn native_case_sensitive_paths_stay_distinct() { + assert!(!contains( + Path::new("/work/cache"), + Path::new("/work/Cache/child") + )); + assert!(!same(Path::new("/work/cache"), Path::new("/work/Cache"))); + assert!(contains( + Path::new("/work/cache:old"), + Path::new("/work/cache:old/child") + )); + } +} diff --git a/rust/lithe-core/src/tests/git_repository_discovery.rs b/rust/lithe-core/src/tests/git_repository_discovery.rs index 1409c3a3bc..093016ae45 100644 --- a/rust/lithe-core/src/tests/git_repository_discovery.rs +++ b/rust/lithe-core/src/tests/git_repository_discovery.rs @@ -266,6 +266,60 @@ fn discovery_delegates_nested_workspace_roots_to_their_own_policy() { assert_eq!(discover(&root).len(), 3); } +#[cfg(windows)] +#[test] +fn discovery_gitlinks_keep_their_identity_after_a_case_only_workspace_rename() { + let fixture = Workspace::new(); + let root = fixture.initialize("."); + let module = fixture.initialize("foo/sub"); + git( + &module, + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "initial", + ], + ); + let revision = data( + "git.command", + json!({"root":module,"arguments":["rev-parse","HEAD"]}), + ); + let entry = format!( + "160000,{},foo/sub", + revision["stdout"].as_str().unwrap().trim() + ); + git(&root, &["update-index", "--add", "--cacheinfo", &entry]); + fs::rename(root.join("foo"), root.join("temporary-name")).unwrap(); + fs::rename(root.join("temporary-name"), root.join("Foo")).unwrap(); + let opened = root.join("Foo"); + assert_eq!( + discover(&opened), + vec![canonical(&root), canonical(&opened.join("sub"))] + ); +} + +#[cfg(windows)] +#[test] +fn discovery_manual_exclusions_survive_case_only_directory_renames() { + let workspace = Workspace::new(); + let root = workspace.initialize("."); + workspace.initialize("cache/child"); + let sibling = workspace.initialize("cache-old/child"); + fs::rename(root.join("cache"), root.join("temporary-name")).unwrap(); + fs::rename(root.join("temporary-name"), root.join("Cache")).unwrap(); + assert_eq!( + discover_with(&root, &["cache"], &[]), + vec![canonical(&root), canonical(&sibling)] + ); +} + #[test] fn discovery_keeps_index_registered_submodules_inside_excluded_containers() { let workspace = Workspace::new(); diff --git a/rust/lithe-git-host/src/lib.rs b/rust/lithe-git-host/src/lib.rs index 1ef58694ad..ea37b623e3 100644 --- a/rust/lithe-git-host/src/lib.rs +++ b/rust/lithe-git-host/src/lib.rs @@ -121,7 +121,7 @@ pub fn run_with_stdout_capture( capture_stdout, &mut output, ) - .and_then(|_| { + .and_then(|stdout_progress| { drain( &mut stderr, &mut stderr_eof, @@ -130,11 +130,15 @@ pub fn run_with_stdout_capture( true, &mut output, ) + .map(|stderr_progress| stdout_progress || stderr_progress) }); - if let Err(error) = reads { - result.failure = Some(error); - break; - } + let made_progress = match reads { + Ok(progress) => progress, + Err(error) => { + result.failure = Some(error); + break; + } + }; if result.status.is_none() { match child.child.try_wait() { Ok(Some(status)) => { @@ -155,7 +159,9 @@ pub fn run_with_stdout_capture( result.failure = Some(Failure::Cleanup); break; } - std::thread::sleep(POLL_INTERVAL); + if !made_progress { + std::thread::sleep(POLL_INTERVAL); + } } // This also cleans up helpers that inherited pipes after their parent exited. if !child.cleanup() { @@ -174,23 +180,25 @@ fn drain( stream: Stream, capture: bool, output: &mut impl FnMut(Stream, &[u8]), -) -> Result<(), Failure> { +) -> Result { if *eof { - return Ok(()); + return Ok(false); } let mut buffer = [0u8; 8192]; + let mut progress = false; // A noisy stream cannot starve the other stream or cancellation checks. for _ in 0..8 { let count = match platform::read(reader, &mut buffer) { Ok(0) => { *eof = true; - return Ok(()); + return Ok(progress); } Ok(count) => count, - Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(progress), Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(error) => return Err(Failure::Io(error)), }; + progress = true; output(stream, &buffer[..count]); if capture { if captured.len() + count > MAX_CAPTURE_BYTES { @@ -199,7 +207,7 @@ fn drain( captured.extend_from_slice(&buffer[..count]); } } - Ok(()) + Ok(progress) } struct OwnedChild { diff --git a/rust/lithe-git-host/tests/process.rs b/rust/lithe-git-host/tests/process.rs index dec4d17c07..708843251d 100644 --- a/rust/lithe-git-host/tests/process.rs +++ b/rust/lithe-git-host/tests/process.rs @@ -71,6 +71,40 @@ fn streaming_stdout_keeps_bounded_process_ownership_without_capturing_the_index( assert!(String::from_utf8_lossy(&outcome.stderr).contains("stderr retained")); } +#[test] +fn streaming_large_stdout_still_observes_cancellation_and_reclaims_the_child() { + let deadline = Instant::now() + Duration::from_secs(3); + let cancelled = AtomicBool::new(false); + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command + .args(["--exact", "process_fixture", "--nocapture"]) + .env("LITHE_GIT_PROCESS_FIXTURE", "large-output"); + let mut received = 0; + let outcome = run_with_stdout_capture( + &mut command, + None, + false, + || cancelled.load(Ordering::Acquire) || Instant::now() > deadline, + || {}, + |stream, bytes| { + if matches!(stream, Stream::Stdout) { + received += bytes.len(); + if received >= 1024 * 1024 { + cancelled.store(true, Ordering::Release); + } + } + }, + ); + assert!( + cancelled.load(Ordering::Acquire), + "fixture did not reach the cancellation gate" + ); + assert!(matches!(outcome.failure, Some(Failure::Cancelled))); + assert!(outcome.stdout.is_empty()); + assert!(outcome.status.is_some(), "cancelled child was not reaped"); + assert!(received < 33 * 1024 * 1024); +} + #[test] fn incremental_output_can_cancel_before_the_child_exits() { let began = Instant::now(); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index a739b637d0..aad2ad014c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -4641,10 +4641,11 @@ Manual directory exclusions also constrain automatic Git root discovery when sup `workspace.repositories`; they never filter an included repository's status or commit candidates. Automatic analysis exclusions and include exceptions do not configure Git root membership. -`workspace.ignoredDirectorySuggestions` accepts `{ root, workspaceRoots? }` and requires +`workspace.ignoredDirectorySuggestions` accepts `{ root, workspaceRoots?, hiddenDirectoryNames?, hiddenFilePatterns? }` and requires an installed indexing policy. It returns `{ directories, incomplete, gitIgnoreFailed }`: sorted relative directory roots ignored by native Git but still eligible for analysis. -It skips manual/automatic exclusions and roots explicitly delegated to another workspace, +It applies the same effective visibility overrides and nested linked-worktree filtering as the +workspace inventory, then skips manual/automatic exclusions and roots explicitly delegated to another workspace, protects tracked descendants, does not follow symlinks, and prunes beneath each candidate. Scanning is bounded to 10,000 directories, 100,000 entries and 32 levels; partial/error flags never establish absence. Ignore checks and tracked-directory protection are batched, and retired generations diff --git a/shared/fixtures/workspace/ignored-directory-suggestions-v1.json b/shared/fixtures/workspace/ignored-directory-suggestions-v1.json index 6c9d3e541d..8cc9e8a1cf 100644 --- a/shared/fixtures/workspace/ignored-directory-suggestions-v1.json +++ b/shared/fixtures/workspace/ignored-directory-suggestions-v1.json @@ -5,7 +5,9 @@ "root": "/workspace", "workspaceRoots": [ "/workspace" - ] + ], + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [] }, "gitignore": "ignored\n", "nestedRepository": "ignored", @@ -46,5 +48,41 @@ "expectedRepositoryCount": 1, "expectedSuggestions": [] } + ], + "visibilityCases": [ + { + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [], + "expectedSuggestions": [ + "archive" + ] + }, + { + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [ + "!node_modules" + ], + "expectedSuggestions": [ + "archive", + "node_modules" + ] + }, + { + "hiddenDirectoryNames": [ + "archive" + ], + "hiddenFilePatterns": [], + "expectedSuggestions": [] + }, + { + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [ + "!node_modules", + "archive/" + ], + "expectedSuggestions": [ + "node_modules" + ] + } ] } diff --git a/shared/platform-feature-matrix/features/git-repository-discovery.json b/shared/platform-feature-matrix/features/git-repository-discovery.json index 0fe415b015..d85262611f 100644 --- a/shared/platform-feature-matrix/features/git-repository-discovery.json +++ b/shared/platform-feature-matrix/features/git-repository-discovery.json @@ -23,6 +23,6 @@ ] }, "owner": "Git", - "verification": "打开含被 Git 忽略目录、仅手动排除目录、构建缓存和普通可见子仓库的项目,确认提交、分支和日志只自动纳入合格仓库;改变规则后及时更新且旧请求不回填。显式打开被排除根仍可管理;多根分别使用自己的排除快照,真实 gitlink 子模块保留依赖发现。验证父仓库已跟踪改动和普通未跟踪文件不被手动排除过滤、查询失败保留旧快照,以及链接工作树、失效 gitfile 和取消。", + "verification": "打开含被 Git 忽略目录、仅手动排除目录、构建缓存和普通可见子仓库的项目,确认提交、分支和日志只自动纳入合格仓库;改变规则后及时更新且旧请求不回填。显式打开被排除根仍可管理;多根分别使用自己的排除快照,真实 gitlink 子模块保留依赖发现。验证父仓库已跟踪改动和普通未跟踪文件不被手动排除过滤、查询失败保留旧快照,以及链接工作树、失效 gitfile 和取消。 2026-11-03补修:比较路径时统一Windows扩展前缀/普通路径/UNC及大小写,保留原生I/O和索引键;手动标记与嵌套根共用组件边界比较。候选使用库存可见性规则及linked-worktree边界,Core覆盖默认隐藏和取消内置隐藏。Git Host只在无读取进展时等待、保留逐轮取消及双流预算;最终计时和平台结果另按本轮报告记录。 本轮最终本地:Core1596项全量逐项计时、Git Host10项、Swift318项、前端88项与TypeScript通过,完整Rust/Swift桥接和边界门禁通过。33MiB流式读取从6248ms降至最终3539ms(单机组件样本,不代替UI帧率);1000目录/10000文件/20子仓库合成发现三次658/269/309ms、20/19/19个Git进程,均保留6个预期仓库。原生Git面板复验因窗口自动化超时未完成,Windows本机cargo check仅出现原基线的两项Windows专有API错误;固定工具链和Windows原生按新head CI核验。", "notes": "两端使用请求局部的共享发现规则;分析开关和包含例外不注册 Git 根。隔离 macOS QA 已验证提交、分支、日志及显式打开例外,手动标记通过隔离偏好域预置。固定 Swift 工具链及 Windows 原生验收尚未完成;Core、Swift 模型和前端组件测试不代替最终原生验收。 基于 AutoBuild 0.3.0(20261102.105732 / 8b1d6669)整合后,本机 Core1590逐项、Swift317、前端84、TypeScript、Git Host9及桥接9通过;Unix合法冒号目录回归通过。完整Rust crates与边界/契约/i18n/稳定性/矩阵/包只读检查通过。应用AskPass握手首轮5秒超时,按原预算复核全部通过;未改变门限。大型目录树的逐层Git查询耗时仍未实测,Windows原生与固定Swift工具链仍待验证。" } diff --git a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json index cb7b51b9d7..ac8675237d 100644 --- a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json +++ b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json @@ -4,7 +4,7 @@ "group": "工作区生命周期", "capability": "目录索引排除与 Git 自动排除", "owner": "Workspace", - "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。 2026-11-02:新增.gitignore只读候选提示、选择排除和项目级不再显示;修复父忽略嵌套仓库的分析自动排除;忽略仅名称着色,排除整行底色,选择色优先。PyCharm2026.2.3对照后明确分析排除不影响Git提交,撤回此前Git发现/未跟踪候选/汇总过滤,Git请求不携带分析策略。已对照排除后提交项仍在、刷新与重开相同、父.gitignore仍忽略ShipFlow时显式登记它后其独立提交项可见;外层忽略与独立仓库映射分别处理。运行补修源码Core1582、macOS聚焦90项逐项计时通过,含空态提示挂载、规则/新文件颜色刷新、并发创建和展开;Git边界调整后的Bun31及TypeScript检查通过,前端此后未改。旧Core1581/Swift75/Bun57不代替最终源码;最终Lithe-Dev构建2026-11-02T07:41:31Z已替换安装;原生验证ShipFlow自动分析排除、开关及部分手动排除后的真实搜索、提示候选选择与不再显示重启持久化、名称棕色和整行底色、刷新后手动排除及ShipFlow独立Git候选保留。候选查询后的状态缓存泄漏已补修,全量Core1582及最终原生刷新通过;Swift源码此后未变。动态规则改色的界面操作未形成有效证据,以模型回归为依据。1426项bundle内容/模式/链接及签名不变,Dev偏好精确恢复,QA缓存归档,应用与辅助进程清理。固定工具链和Windows原生验收仍pending。 拆分至 preview 8b1d6669 后,本机补验 Core1583、Swift91(含挂载提示随元数据刷新且不重扫库存)、前端41(含多选存储失败原子回滚)及 TypeScript 通过;共享契约、i18n、边界、稳定性、矩阵与发行目录只读门禁通过。Swift6.4.2/Xcode27.2,固定工具链和 Windows 原生验收仍待完成。 2026-11-03补修:Core解析未保存Git自动排除偏好为关闭,保留已保存布尔值;配置/刷新返回生效值,两端加载后回显、不补写默认配置。ⓘ删除语言适配细节并保留部分语言服务仍可能扫描的限制。ls-files复用Git Host取消/超时及进程树清理,JDT车道显式筛选模块且普通Core仍全量覆盖;最终验证结果按本轮报告记录。 本轮第一组本地:Core1586项及Rust↔Swift桥接通过,索引37项有逐项计时,前端61项及TypeScript通过;Swift63项在本机Swift6.4.2通过,固定Swift6.3.3/Xcode26.6和Windows原生仍以新head CI为准。独立审查发现的配置失败与未知状态展示已修复并复审,无新增发现。", + "verification": "2026-11-01:18841d18的Windows前端、共享Core和真实JDT通过,宿主测试因原始路径与规范化刷新表键混用失败。后续仅修正宿主测试:真实临时目录、请求别名与规范化身份分离、共享刷新状态和最后租约释放回归、失败清理;生产代码及预算不变。静态门禁和只读审查通过,本机Windows测试按用户要求不运行,修复后的Windows CI另按提交核验,原生产品验收仍pending。 本轮整合基线4e47d784后的目录动作、租约刷新及监听所有权补修随本记录提交到PR1597,准确提交身份见PR说明;本地源码哈希与验证报告保存在.artifacts/pr1597-final-verification。Core完整门禁、桥接、Swift聚焦33条和静态门禁通过。macOS独立QA已验证Git忽略→取消排除→源码角色、真实搜索与落盘例外;自动化窗口连接故障使第二方向菜单和重启界面验收未完成。用户要求本机不进行Windows测试,最新LSP所有权和在途编辑补修仅静态检查/审查,新增回归未运行;此前Bun79、project Rust41、宿主5条组件结果不覆盖后续源码,也不代替Windows A/B切换、多仓库、共享根和LSP恢复原生验收。两端本轮验证保持pending,历史记录按各自源码阅读。 较早本地验收阶段未执行 Windows/Frontend 测试;用户随后授权 Ready 触发完整自动 CI,原生界面验证仍单独记录。整合 preview a1ed8f4c 后,补修源码921c888c通过完整Core1484项、本机Swift6.4.2/Xcode27.2受影响182条(含完整Git观察28条)、Rust↔Swift桥接及Core边界检查;编译与测试按现有CI分离,未放宽计时预算。完整开发App原生验收确认排除目录与后代仍可浏览并显示排除图标,冷JDT仅导入visible且实际返回PrintStream补全,外部stage/commit自动更新且JDT和补全保持;用户操作取消排除后由代理核验搜索、落盘、重启持久化及A/B项目查询与关闭隔离。损坏配置随App语言中英即时切换且原值保留,修正后显式重试成功;真实权限失败分别显示中英文错误,权限恢复后观察到项目重载,代理未点击该流程的Retry。1419包文件/9符号链接及签名不变;QA/JDT/辅助进程清理,用户Dev保留,开发偏好精确恢复,新增缓存归档。4e7b8441的插件512条及合成库存10202/201剪枝结果保留原版本,不能当作UI或LSP后台性能基准。较早源码的普通角色、linked-worktree和子模块原生证据见Note。macOS在上述能力范围完成本机运行验证,固定Xcode26.6完整CI与本机结果分别核对;Windows新回归仅书写和静态复审,原生仍pending。Java显式构建模块仍可能扫描,Go/Rust配置送达不等于后台零扫描。 后续补修内容搜索首次请求、加载更多及索引状态的错误呈现:保存类型化错误,按当前语言使用共享词条,不拼接内部错误码;本机Bun1.3.13的19条逐项计时及TypeScript检查通过,损坏偏好保留、语言切换不重搜及加载更多保留结果有回归。真实页面有结果时显示紧凑错误和重试,失败暂停自动翻页;新增回归接入Windows CI隔离分组;该补修的固定工具链及Windows CI按对应提交的PR报告单独记录,Windows原生界面保持pending。 批量Run夹具增加首扫完成与快照UUID同步,本机Swift6.4.2完整Run入口21条通过,固定工具链结果以对应PR提交报告为准。 继而统一全部10处Run/Debug/单项/批量/服务/重启首次库存发布:在主actor空闲边界释放库存,重查排队刷新状态并核验准确快照UUID,保留刻意门控的调用方刷新任务;本机完整Run入口21条在三个独立进程中通过。 目录标记读取由树与索引配置共用版本、结构、角色和相对路径校验,真实内容搜索API回归用于核验非法JSON、空结构、null、错误marks类型及越界路径均显示共享中英错误,切语言不重搜、不调用Core安装空策略、不覆盖原始存储。 2026-11-02:新增.gitignore只读候选提示、选择排除和项目级不再显示;修复父忽略嵌套仓库的分析自动排除;忽略仅名称着色,排除整行底色,选择色优先。PyCharm2026.2.3对照后明确分析排除不影响Git提交,撤回此前Git发现/未跟踪候选/汇总过滤,Git请求不携带分析策略。已对照排除后提交项仍在、刷新与重开相同、父.gitignore仍忽略ShipFlow时显式登记它后其独立提交项可见;外层忽略与独立仓库映射分别处理。运行补修源码Core1582、macOS聚焦90项逐项计时通过,含空态提示挂载、规则/新文件颜色刷新、并发创建和展开;Git边界调整后的Bun31及TypeScript检查通过,前端此后未改。旧Core1581/Swift75/Bun57不代替最终源码;最终Lithe-Dev构建2026-11-02T07:41:31Z已替换安装;原生验证ShipFlow自动分析排除、开关及部分手动排除后的真实搜索、提示候选选择与不再显示重启持久化、名称棕色和整行底色、刷新后手动排除及ShipFlow独立Git候选保留。候选查询后的状态缓存泄漏已补修,全量Core1582及最终原生刷新通过;Swift源码此后未变。动态规则改色的界面操作未形成有效证据,以模型回归为依据。1426项bundle内容/模式/链接及签名不变,Dev偏好精确恢复,QA缓存归档,应用与辅助进程清理。固定工具链和Windows原生验收仍pending。 拆分至 preview 8b1d6669 后,本机补验 Core1583、Swift91(含挂载提示随元数据刷新且不重扫库存)、前端41(含多选存储失败原子回滚)及 TypeScript 通过;共享契约、i18n、边界、稳定性、矩阵与发行目录只读门禁通过。Swift6.4.2/Xcode27.2,固定工具链和 Windows 原生验收仍待完成。 2026-11-03补修:Core解析未保存Git自动排除偏好为关闭,保留已保存布尔值;配置/刷新返回生效值,两端加载后回显、不补写默认配置。ⓘ删除语言适配细节并保留部分语言服务仍可能扫描的限制。ls-files复用Git Host取消/超时及进程树清理,JDT车道显式筛选模块且普通Core仍全量覆盖;最终验证结果按本轮报告记录。 本轮第一组本地:Core1586项及Rust↔Swift桥接通过,索引37项有逐项计时,前端61项及TypeScript通过;Swift63项在本机Swift6.4.2通过,固定Swift6.3.3/Xcode26.6和Windows原生仍以新head CI为准。独立审查发现的配置失败与未知状态展示已修复并复审,无新增发现。 两组整合后的Lithe-Dev实际验收确认:新项目默认关闭且未创建项目偏好;开启后明确保存true,再关闭后回显off;精简的中英文ⓘ文案实际可见并取得英文局部截图。1434项bundle清单/哈希/模式和Git忽略及索引保持不变,开发偏好精确恢复,验收App及辅助进程关闭并注销。目录多选与Git面板本轮原生复验因自动化超时未完成,未提升这些场景的验证状态。候选接口新增可选可见性字段、复用库存隐藏和linked-worktree边界,共享fixture执行默认隐藏、取消内置隐藏、自定义目录及folder pattern。", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", diff --git a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts index 50d394935c..be58657ff2 100644 --- a/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts +++ b/windows/tauri/src/features/file-explorer/stores/workspace-indexing.ts @@ -286,7 +286,8 @@ export async function queryIgnoredDirectorySuggestions(root: string, workspaceId await configureWorkspaceIndexing(root, [], false, workspaceId); const response = await executeCore({ id: crypto.randomUUID(), command: "workspace.ignoredDirectorySuggestions", timeoutMilliseconds: 10000, - payload: { root, workspaceRoots }, + // Windows flat inventory currently uses Core defaults for both visibility lists. + payload: { root, workspaceRoots, hiddenDirectoryNames: [], hiddenFilePatterns: [] }, }); if (!response.ok) throw new IndexingSettingsError("configureFailed", response.error); return response.data; From 536c7aed69098974a60b2151f0636987e34da9a9 Mon Sep 17 00:00:00 2001 From: xueyeshang1 Date: Tue, 3 Nov 2026 12:32:52 +0800 Subject: [PATCH 10/10] fix(git): wake Unix stream reads on pipe readiness --- ...-git-repository-discovery-and-selection.md | 20 ++++--- rust/lithe-git-host/src/lib.rs | 55 ++++++++++++++++++- rust/lithe-git-host/tests/process.rs | 4 +- .../features/git-repository-discovery.json | 4 +- 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md index 24076aea75..fe7101f744 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md +++ b/.agents/notes/implemented/bug-fix/2026-10-04-git-repository-discovery-and-selection.md @@ -62,7 +62,11 @@ Windows 平面库存目前使用 Core 默认规则,候选请求也显式使用 前端树呈现的隐藏设置冒充已生效的库存规则。Core 回归覆盖内置隐藏、取消内置隐藏和自定义规则。 Git Host 保留每轮两条流各 64 KiB 的读取预算;任一流取得进展后立即进入下一轮, -每轮仍检查取消和另一条流,只有没有进展时才按原间隔等待。不提高捕获、取消或清理预算。 +每轮仍检查取消和另一条流。Unix 管道暂时读空时用可读通知等待,数据到达立即唤醒, +最长仍为原来的 10 ms;已经读到结尾的管道不参与等待,避免结束通知导致空转。 +Windows 保留 PeekNamedPipe 和无进展时的原间隔等待。不提高捕获、取消或清理预算。 +只取消有进展时的 sleep 不够:Unix 生产者尚未得到调度时仍会不断读空,固定等待 +会限制吞吐,macOS CI 的 33 MiB 回归仍在 12 秒截止处取消。不得以扩大期限掩盖它。 Windows 旧任务的 check-run 注解确认超过 45 分钟上限;共享 Core 步骤先失败,但详细 日志已不可取,不能把后续取消单独归因于路径问题,也不因此扩大 job 时间预算。 @@ -101,20 +105,22 @@ Windows 旧任务的 check-run 注解确认超过 45 分钟上限;共享 Core 子仓库根,父仓库已跟踪修改保留,显式打开被忽略子仓库仍可管理。手动标记通过隔离 偏好域预置,未完成菜单操作的自动化验收。QA 实例已关闭,原有应用实例保持; bundle 的 1,434 项文件/链接及 Git 索引未变化。 -- 本地 Swift 使用 6.4.2/Xcode 27.2,固定 6.3.3/Xcode 26.6 及 Windows 原生验收 - 尚未完成;macOS 上 Windows Rust 检查仍只有基线已有的平台 API 编译错误。 +- 本地 Swift 使用 6.4.2/Xcode 27.2,固定 6.3.3/Xcode 26.6 由 CI 确认; + macOS 上 Windows Rust 检查仍只有基线已有的平台 API 编译错误。 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` - `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` - `./scripts/architecture/verify-shared-contracts.sh` - `./scripts/agent-notes/verify-agent-notes.sh` 本轮最终 Core 1596 项全量逐项计时、Git Host 10 项、Swift 318 项、前端 88 项及 -TypeScript、Rust↔Swift 桥接通过。33 MiB 输出用例的原预算下,从修复前 6248 ms -降至最终 3539 ms;新增取消回归 179 ms,都是组件样本,不冒充界面流畅度。 +TypeScript、Rust↔Swift 桥接通过。33 MiB 输出用例保留原数据和期限;Unix 可读通知 +使本机样本从无进展时固定等待的 3539 ms 降至 30 ms,大输出取消回归为 19 ms。 +这些都是组件样本,不冒充界面流畅度。 1000 个数据目录、10000 个文件及 20 个嵌套仓库的合成项目,三次发现为 658/269/309 ms,Git 进程数 20/19/19,均保留预期的 6 个仓库。 -Windows 路径原生回归仍由新 head CI 核验;macOS 的 Windows host 检查只出现 -未修改基线的两个平台 API 错误,不能算 Windows 构建通过。 +Windows 原生 CI 已通过路径大小写、嵌套根、子模块相对路径、可见性和取消回归, +真实 JDT 筛选任务也已通过;Windows 界面验收仍为 pending。Unix 可读通知补修 +仍须在新 head 的 macOS CI 中确认。 ## 适用范围 diff --git a/rust/lithe-git-host/src/lib.rs b/rust/lithe-git-host/src/lib.rs index ea37b623e3..399b25e05b 100644 --- a/rust/lithe-git-host/src/lib.rs +++ b/rust/lithe-git-host/src/lib.rs @@ -160,7 +160,11 @@ pub fn run_with_stdout_capture( break; } if !made_progress { - std::thread::sleep(POLL_INTERVAL); + if let Err(error) = platform::wait_for_output(&stdout, stdout_eof, &stderr, stderr_eof) + { + result.failure = Some(Failure::Io(error)); + break; + } } } // This also cleans up helpers that inherited pipes after their parent exited. @@ -417,6 +421,44 @@ mod platform { pub fn read(reader: &mut R, buffer: &mut [u8]) -> io::Result { reader.read(buffer) } + pub fn wait_for_output( + stdout: &impl Pipe, + stdout_eof: bool, + stderr: &impl Pipe, + stderr_eof: bool, + ) -> io::Result<()> { + let mut pipes = [ + libc::pollfd { + fd: if stdout_eof { -1 } else { stdout.as_raw_fd() }, + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: if stderr_eof { -1 } else { stderr.as_raw_fd() }, + events: libc::POLLIN, + revents: 0, + }, + ]; + // A temporarily empty pipe must wake as soon as the producer writes. + // Ignoring EOF descriptors avoids a hangup-driven busy loop; the finite + // timeout preserves cancellation and child-exit checks even without data. + // SAFETY: both descriptors remain borrowed for the duration of poll, and + // pipes is a valid writable array of the stated length. + let ready = unsafe { + libc::poll( + pipes.as_mut_ptr(), + pipes.len() as libc::nfds_t, + POLL_INTERVAL.as_millis() as libc::c_int, + ) + }; + if ready < 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } + Ok(()) + } pub struct Group(i32); impl Group { /// A process that starts its own session leaves the group on its own, @@ -467,6 +509,17 @@ mod platform { }; pub trait Pipe: AsRawHandle {} impl Pipe for T {} + pub fn wait_for_output( + _stdout: &impl Pipe, + _stdout_eof: bool, + _stderr: &impl Pipe, + _stderr_eof: bool, + ) -> io::Result<()> { + // Anonymous Windows pipes use PeekNamedPipe in read; they do not expose + // the Unix readiness notification used above. + std::thread::sleep(POLL_INTERVAL); + Ok(()) + } pub fn prepare(command: &mut Command) { // Assign the job before the first thread can spawn credential/SSH helpers. command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED); diff --git a/rust/lithe-git-host/tests/process.rs b/rust/lithe-git-host/tests/process.rs index 708843251d..0f41029799 100644 --- a/rust/lithe-git-host/tests/process.rs +++ b/rust/lithe-git-host/tests/process.rs @@ -44,8 +44,8 @@ fn process_fixture() { #[test] fn streaming_stdout_keeps_bounded_process_ownership_without_capturing_the_index() { - // The adapter drains 64 KiB per poll; 33 MiB takes over five seconds even - // without contention. This deadline stays below the 15-second case budget. + // Keep the 33 MiB fixture and original deadline: temporarily empty pipes + // must not impose a fixed sleep on every producer/consumer handoff. let deadline = Instant::now() + Duration::from_secs(12); let mut command = Command::new(std::env::current_exe().expect("test executable")); command diff --git a/shared/platform-feature-matrix/features/git-repository-discovery.json b/shared/platform-feature-matrix/features/git-repository-discovery.json index d85262611f..0bd633ce27 100644 --- a/shared/platform-feature-matrix/features/git-repository-discovery.json +++ b/shared/platform-feature-matrix/features/git-repository-discovery.json @@ -23,6 +23,6 @@ ] }, "owner": "Git", - "verification": "打开含被 Git 忽略目录、仅手动排除目录、构建缓存和普通可见子仓库的项目,确认提交、分支和日志只自动纳入合格仓库;改变规则后及时更新且旧请求不回填。显式打开被排除根仍可管理;多根分别使用自己的排除快照,真实 gitlink 子模块保留依赖发现。验证父仓库已跟踪改动和普通未跟踪文件不被手动排除过滤、查询失败保留旧快照,以及链接工作树、失效 gitfile 和取消。 2026-11-03补修:比较路径时统一Windows扩展前缀/普通路径/UNC及大小写,保留原生I/O和索引键;手动标记与嵌套根共用组件边界比较。候选使用库存可见性规则及linked-worktree边界,Core覆盖默认隐藏和取消内置隐藏。Git Host只在无读取进展时等待、保留逐轮取消及双流预算;最终计时和平台结果另按本轮报告记录。 本轮最终本地:Core1596项全量逐项计时、Git Host10项、Swift318项、前端88项与TypeScript通过,完整Rust/Swift桥接和边界门禁通过。33MiB流式读取从6248ms降至最终3539ms(单机组件样本,不代替UI帧率);1000目录/10000文件/20子仓库合成发现三次658/269/309ms、20/19/19个Git进程,均保留6个预期仓库。原生Git面板复验因窗口自动化超时未完成,Windows本机cargo check仅出现原基线的两项Windows专有API错误;固定工具链和Windows原生按新head CI核验。", - "notes": "两端使用请求局部的共享发现规则;分析开关和包含例外不注册 Git 根。隔离 macOS QA 已验证提交、分支、日志及显式打开例外,手动标记通过隔离偏好域预置。固定 Swift 工具链及 Windows 原生验收尚未完成;Core、Swift 模型和前端组件测试不代替最终原生验收。 基于 AutoBuild 0.3.0(20261102.105732 / 8b1d6669)整合后,本机 Core1590逐项、Swift317、前端84、TypeScript、Git Host9及桥接9通过;Unix合法冒号目录回归通过。完整Rust crates与边界/契约/i18n/稳定性/矩阵/包只读检查通过。应用AskPass握手首轮5秒超时,按原预算复核全部通过;未改变门限。大型目录树的逐层Git查询耗时仍未实测,Windows原生与固定Swift工具链仍待验证。" + "verification": "打开含 Git 忽略、手动排除、构建缓存和普通可见子仓库的项目,确认提交、分支和日志只自动纳入合格仓库;改变规则后更新且旧请求不回填。显式打开被排除根仍可管理,多根各用自己的标记,真实 gitlink 保留。验证父仓库已跟踪及普通未跟踪文件不被过滤,失败保留快照,覆盖 linked-worktree、失效 gitfile 和取消。Windows 比较覆盖普通路径、扩展前缀、UNC、大小写及组件边界;候选覆盖默认隐藏、取消内置隐藏和自定义规则。Git Host 在 Unix 暂时读空后使用可读通知,最长等待仍10ms,保留逐轮取消和双流64KiB预算。", + "notes": "两端已实现,最终原生界面验收仍为 pending。2026-11-03 本地 Core1596项逐项计时、Git Host10项、Swift318项、前端88项、TypeScript及完整Rust/Swift桥接和静态门禁通过。新head的Windows原生CI已通过路径、嵌套根、可见性与取消回归及真实JDT;固定Swift工具链和Unix通知补修按最终CI确认。Unix33MiB样本由无进展时固定等待的3539ms降至30ms,取消19ms;1000目录/10000文件/20子仓库发现658/269/309ms,Git进程20/19/19,均保留6个预期仓库,均为组件数据。隔离macOS QA已验收提交、分支、日志及显式根,手动标记通过隔离偏好预置;本轮Git面板复验因自动化超时未完成,Windows原生UI尚未验收。分析开关和包含例外不注册Git根。" }