magus-cli 0.0.0-placeholder → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect violations of the resolved gitignore manifest against a working
|
|
3
|
+
* tree. Emits one Violation per offending path.
|
|
4
|
+
*
|
|
5
|
+
* Strategy:
|
|
6
|
+
* - For ignore rules: ask `git ls-files` whether the path (or anything
|
|
7
|
+
* under it for directory-style rules) is currently tracked. If so,
|
|
8
|
+
* it's "tracked-but-should-ignore" (destructive). Then check whether
|
|
9
|
+
* the existing .gitignore covers the path; if not, that's
|
|
10
|
+
* "missing-from-gitignore" (safe additive fix).
|
|
11
|
+
* - For track rules: ask `git check-ignore` whether the path is being
|
|
12
|
+
* excluded by the current .gitignore. If yes, "ignored-but-should-track"
|
|
13
|
+
* (destructive — must remove the .gitignore line). If the path exists
|
|
14
|
+
* on disk but isn't tracked and isn't ignored, "untracked-and-should-track".
|
|
15
|
+
*
|
|
16
|
+
* Glob expansion is intentionally limited: we treat patterns as literals
|
|
17
|
+
* with directory-suffix awareness ("foo/" matches anything under foo/).
|
|
18
|
+
* Full gitignore-syntax glob expansion is deferred — git itself handles
|
|
19
|
+
* the heavy lifting via ls-files / check-ignore.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import type { ResolvedManifest, Violation } from "../types/gitignore.js";
|
|
26
|
+
|
|
27
|
+
export async function detectViolations(
|
|
28
|
+
cwd: string,
|
|
29
|
+
resolved: ResolvedManifest,
|
|
30
|
+
): Promise<Violation[]> {
|
|
31
|
+
if (!(await isGitRepo(cwd))) return [];
|
|
32
|
+
|
|
33
|
+
const violations: Violation[] = [];
|
|
34
|
+
const trackedFiles = await listTrackedFiles(cwd);
|
|
35
|
+
const trackedSet = new Set(trackedFiles);
|
|
36
|
+
|
|
37
|
+
for (const rule of resolved.rules) {
|
|
38
|
+
if (rule.action === "ignore") {
|
|
39
|
+
const tracked = matchesTrackedFile(rule.pattern, trackedSet);
|
|
40
|
+
if (tracked.length > 0) {
|
|
41
|
+
for (const path of tracked) {
|
|
42
|
+
violations.push({
|
|
43
|
+
kind: "tracked-but-should-ignore",
|
|
44
|
+
path,
|
|
45
|
+
source: rule.source,
|
|
46
|
+
severity: "destructive",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// Not tracked — check if .gitignore covers it. If not, suggest
|
|
51
|
+
// a safe additive fix (append to .gitignore).
|
|
52
|
+
const ignored = await pathIsIgnored(cwd, rule.pattern);
|
|
53
|
+
if (!ignored) {
|
|
54
|
+
violations.push({
|
|
55
|
+
kind: "missing-from-gitignore",
|
|
56
|
+
path: rule.pattern,
|
|
57
|
+
source: rule.source,
|
|
58
|
+
severity: "safe",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
// action === "track"
|
|
64
|
+
const ignored = await pathIsIgnored(cwd, rule.pattern);
|
|
65
|
+
if (ignored) {
|
|
66
|
+
violations.push({
|
|
67
|
+
kind: "ignored-but-should-track",
|
|
68
|
+
path: rule.pattern,
|
|
69
|
+
source: rule.source,
|
|
70
|
+
severity: "destructive",
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const isTracked = trackedSet.has(rule.pattern);
|
|
75
|
+
const existsOnDisk = existsSync(join(cwd, rule.pattern));
|
|
76
|
+
if (existsOnDisk && !isTracked) {
|
|
77
|
+
violations.push({
|
|
78
|
+
kind: "untracked-and-should-track",
|
|
79
|
+
path: rule.pattern,
|
|
80
|
+
source: rule.source,
|
|
81
|
+
severity: "destructive",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return violations;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Match a pattern against the tracked-files set. Directory-style patterns
|
|
92
|
+
* (trailing "/") match every tracked path under that prefix.
|
|
93
|
+
*/
|
|
94
|
+
function matchesTrackedFile(pattern: string, tracked: Set<string>): string[] {
|
|
95
|
+
if (pattern.endsWith("/")) {
|
|
96
|
+
const prefix = pattern;
|
|
97
|
+
return Array.from(tracked).filter((p) => p.startsWith(prefix));
|
|
98
|
+
}
|
|
99
|
+
if (pattern.includes("*")) {
|
|
100
|
+
// Very limited literal-prefix glob handling. Full expansion is git's job.
|
|
101
|
+
const star = pattern.indexOf("*");
|
|
102
|
+
const prefix = pattern.slice(0, star);
|
|
103
|
+
return Array.from(tracked).filter((p) => p.startsWith(prefix));
|
|
104
|
+
}
|
|
105
|
+
return tracked.has(pattern) ? [pattern] : [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function isGitRepo(cwd: string): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function listTrackedFiles(cwd: string): Promise<string[]> {
|
|
118
|
+
try {
|
|
119
|
+
const out = await runGit(["ls-files"], cwd);
|
|
120
|
+
return out
|
|
121
|
+
.split("\n")
|
|
122
|
+
.map((l) => l.trim())
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function pathIsIgnored(cwd: string, path: string): Promise<boolean> {
|
|
130
|
+
try {
|
|
131
|
+
// git check-ignore exits 0 when path IS ignored, 1 when NOT, >1 on error.
|
|
132
|
+
await runGit(["check-ignore", "--quiet", "--", path], cwd);
|
|
133
|
+
return true;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// exit code 1 = not ignored (expected), anything else swallowed.
|
|
136
|
+
if (err instanceof GitError && err.code === 1) return false;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
class GitError extends Error {
|
|
142
|
+
constructor(
|
|
143
|
+
public code: number,
|
|
144
|
+
public stderr: string,
|
|
145
|
+
) {
|
|
146
|
+
super(`git exited ${code}: ${stderr}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function runGit(args: string[], cwd: string): Promise<string> {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const proc = spawn("git", args, { cwd });
|
|
153
|
+
let stdout = "";
|
|
154
|
+
let stderr = "";
|
|
155
|
+
proc.stdout.on("data", (b) => {
|
|
156
|
+
stdout += b.toString();
|
|
157
|
+
});
|
|
158
|
+
proc.stderr.on("data", (b) => {
|
|
159
|
+
stderr += b.toString();
|
|
160
|
+
});
|
|
161
|
+
proc.on("error", reject);
|
|
162
|
+
proc.on("close", (code) => {
|
|
163
|
+
if (code === 0) resolve(stdout);
|
|
164
|
+
else reject(new GitError(code ?? -1, stderr));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply fixes for gitignore violations. Two flavors:
|
|
3
|
+
*
|
|
4
|
+
* - `applySafeFixes`: additive — appends missing patterns to .gitignore
|
|
5
|
+
* under a "# Added by magus" header. Idempotent (re-running is a no-op
|
|
6
|
+
* because patterns are already present).
|
|
7
|
+
*
|
|
8
|
+
* - `applyDestructiveFix`: index-mutating — runs `git rm --cached <path>`
|
|
9
|
+
* for tracked-but-should-ignore, `git add <path>` (after making sure
|
|
10
|
+
* gitignore has any needed `!` exceptions) for ignored-but-should-track /
|
|
11
|
+
* untracked-and-should-track.
|
|
12
|
+
* Each call handles ONE violation; the caller is responsible for confirming
|
|
13
|
+
* each one with the user.
|
|
14
|
+
*
|
|
15
|
+
* `formatViolationSummary` is a pure helper for the warning line + TUI.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { getGitignoreRuleReason } from "../data/gitignore-reasons.js";
|
|
23
|
+
import type { Violation } from "../types/gitignore.js";
|
|
24
|
+
|
|
25
|
+
const MAGUS_HEADER = "# Added by magus";
|
|
26
|
+
|
|
27
|
+
export interface SafeFixResult {
|
|
28
|
+
appended: string[];
|
|
29
|
+
alreadyPresent: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function applySafeFixes(
|
|
33
|
+
cwd: string,
|
|
34
|
+
violations: Violation[],
|
|
35
|
+
): Promise<SafeFixResult> {
|
|
36
|
+
const safe = violations.filter(
|
|
37
|
+
(v) => v.severity === "safe" && v.kind === "missing-from-gitignore",
|
|
38
|
+
);
|
|
39
|
+
if (safe.length === 0) {
|
|
40
|
+
return { appended: [], alreadyPresent: [] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
44
|
+
const existing = existsSync(gitignorePath)
|
|
45
|
+
? await readFile(gitignorePath, "utf8")
|
|
46
|
+
: "";
|
|
47
|
+
const lines = new Set(
|
|
48
|
+
existing
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((l) => l.trim())
|
|
51
|
+
.filter(Boolean),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const appended: string[] = [];
|
|
55
|
+
const alreadyPresent: string[] = [];
|
|
56
|
+
for (const v of safe) {
|
|
57
|
+
if (lines.has(v.path)) {
|
|
58
|
+
alreadyPresent.push(v.path);
|
|
59
|
+
} else {
|
|
60
|
+
appended.push(v.path);
|
|
61
|
+
lines.add(v.path);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (appended.length === 0) return { appended, alreadyPresent };
|
|
66
|
+
|
|
67
|
+
const block = [
|
|
68
|
+
"",
|
|
69
|
+
MAGUS_HEADER,
|
|
70
|
+
...appended.flatMap(formatGitignoreEntry),
|
|
71
|
+
"",
|
|
72
|
+
].join("\n");
|
|
73
|
+
|
|
74
|
+
if (existing.length > 0 && !existing.endsWith("\n")) {
|
|
75
|
+
await appendFile(gitignorePath, `\n${block}`);
|
|
76
|
+
} else {
|
|
77
|
+
await appendFile(gitignorePath, block);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { appended, alreadyPresent };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DestructiveFixResult {
|
|
84
|
+
applied: boolean;
|
|
85
|
+
message: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function applyDestructiveFix(
|
|
89
|
+
cwd: string,
|
|
90
|
+
violation: Violation,
|
|
91
|
+
rulePattern = violation.path,
|
|
92
|
+
): Promise<DestructiveFixResult> {
|
|
93
|
+
switch (violation.kind) {
|
|
94
|
+
case "tracked-but-should-ignore": {
|
|
95
|
+
await runGit(["rm", "--cached", "--", violation.path], cwd);
|
|
96
|
+
// Then make sure .gitignore has the entry so it stays out
|
|
97
|
+
await appendIfMissing(cwd, rulePattern);
|
|
98
|
+
return {
|
|
99
|
+
applied: true,
|
|
100
|
+
message: `Removed ${violation.path} from index and added ${rulePattern} to .gitignore`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
case "ignored-but-should-track": {
|
|
104
|
+
await stripGitignoreLine(cwd, violation.path);
|
|
105
|
+
await appendTrackExceptionsIfNeeded(cwd, violation.path);
|
|
106
|
+
if (!existsSync(join(cwd, violation.path))) {
|
|
107
|
+
return {
|
|
108
|
+
applied: true,
|
|
109
|
+
message: `Made ${violation.path} trackable; file does not exist yet`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await runGit(["add", "--", violation.path], cwd);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return {
|
|
116
|
+
applied: false,
|
|
117
|
+
message: `Removed .gitignore line but git add failed: ${
|
|
118
|
+
err instanceof Error ? err.message : String(err)
|
|
119
|
+
}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
applied: true,
|
|
124
|
+
message: `Removed .gitignore line and staged ${violation.path}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
case "untracked-and-should-track": {
|
|
128
|
+
await appendTrackExceptionsIfNeeded(cwd, violation.path);
|
|
129
|
+
await runGit(["add", "--", violation.path], cwd);
|
|
130
|
+
return { applied: true, message: `Staged ${violation.path}` };
|
|
131
|
+
}
|
|
132
|
+
case "missing-from-gitignore": {
|
|
133
|
+
// Treat as a safe append for symmetry — caller can route through here too.
|
|
134
|
+
await appendIfMissing(cwd, rulePattern);
|
|
135
|
+
return { applied: true, message: `Added ${rulePattern} to .gitignore` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatViolationSummary(violations: Violation[]): string {
|
|
141
|
+
if (violations.length === 0) return "No gitignore issues detected.";
|
|
142
|
+
|
|
143
|
+
const byKind = new Map<string, Violation[]>();
|
|
144
|
+
for (const v of violations) {
|
|
145
|
+
const arr = byKind.get(v.kind) ?? [];
|
|
146
|
+
arr.push(v);
|
|
147
|
+
byKind.set(v.kind, arr);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const labels: Record<Violation["kind"], string> = {
|
|
151
|
+
"tracked-but-should-ignore": "tracked but should be ignored",
|
|
152
|
+
"ignored-but-should-track": "ignored but should be tracked",
|
|
153
|
+
"untracked-and-should-track": "exist but not tracked (should be)",
|
|
154
|
+
"missing-from-gitignore": "missing from .gitignore",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
for (const [kind, vs] of byKind.entries()) {
|
|
159
|
+
lines.push(` ${labels[kind as Violation["kind"]]}: ${vs.length}`);
|
|
160
|
+
for (const v of vs.slice(0, 5)) {
|
|
161
|
+
lines.push(` - ${v.path}`);
|
|
162
|
+
}
|
|
163
|
+
if (vs.length > 5) lines.push(` … and ${vs.length - 5} more`);
|
|
164
|
+
}
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function appendIfMissing(cwd: string, pattern: string): Promise<void> {
|
|
169
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
170
|
+
const existing = existsSync(gitignorePath)
|
|
171
|
+
? await readFile(gitignorePath, "utf8")
|
|
172
|
+
: "";
|
|
173
|
+
const lines = existing.split("\n").map((l) => l.trim());
|
|
174
|
+
if (lines.includes(pattern)) return;
|
|
175
|
+
// Only emit the "# Added by magus" header once — when fixing several
|
|
176
|
+
// entries in one pass (F), the file already has the header from the first.
|
|
177
|
+
const needsHeader = !lines.includes(MAGUS_HEADER);
|
|
178
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
179
|
+
const header = needsHeader ? `${MAGUS_HEADER}\n` : "";
|
|
180
|
+
const block = `${prefix}${header}${formatGitignoreEntry(pattern).join("\n")}\n`;
|
|
181
|
+
await appendFile(gitignorePath, block);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatGitignoreEntry(pattern: string): string[] {
|
|
185
|
+
return [`# ${getGitignoreRuleReason("ignore", pattern)}`, pattern];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function appendTrackExceptionsIfNeeded(
|
|
189
|
+
cwd: string,
|
|
190
|
+
path: string,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
if (!(await pathIsIgnored(cwd, path))) return;
|
|
193
|
+
|
|
194
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
195
|
+
const existing = existsSync(gitignorePath)
|
|
196
|
+
? await readFile(gitignorePath, "utf8")
|
|
197
|
+
: "";
|
|
198
|
+
const lines = new Set(
|
|
199
|
+
existing
|
|
200
|
+
.split("\n")
|
|
201
|
+
.map((line) => line.trim())
|
|
202
|
+
.filter(Boolean),
|
|
203
|
+
);
|
|
204
|
+
const additions = unignorePatternsForPath(path).filter(
|
|
205
|
+
(line) => !lines.has(line),
|
|
206
|
+
);
|
|
207
|
+
if (additions.length === 0) return;
|
|
208
|
+
|
|
209
|
+
const block = [
|
|
210
|
+
existing.length > 0 && !existing.endsWith("\n") ? "\n" : "",
|
|
211
|
+
MAGUS_HEADER,
|
|
212
|
+
`# ${getGitignoreRuleReason("track", path)}`,
|
|
213
|
+
...additions,
|
|
214
|
+
"",
|
|
215
|
+
].join("\n");
|
|
216
|
+
await appendFile(gitignorePath, block);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function unignorePatternsForPath(path: string): string[] {
|
|
220
|
+
const normalized = path.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
221
|
+
if (!normalized) return [];
|
|
222
|
+
const parts = normalized.split("/");
|
|
223
|
+
const patterns: string[] = [];
|
|
224
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
225
|
+
patterns.push(`!/${parts.slice(0, i + 1).join("/")}/`);
|
|
226
|
+
}
|
|
227
|
+
patterns.push(`!/${normalized}`);
|
|
228
|
+
return patterns;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function stripGitignoreLine(cwd: string, pattern: string): Promise<void> {
|
|
232
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
233
|
+
if (!existsSync(gitignorePath)) return;
|
|
234
|
+
const content = await readFile(gitignorePath, "utf8");
|
|
235
|
+
const filtered = content
|
|
236
|
+
.split("\n")
|
|
237
|
+
.filter((line) => line.trim() !== pattern)
|
|
238
|
+
.join("\n");
|
|
239
|
+
if (filtered !== content) {
|
|
240
|
+
await writeFile(gitignorePath, filtered);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function pathIsIgnored(cwd: string, path: string): Promise<boolean> {
|
|
245
|
+
try {
|
|
246
|
+
await runGit(["check-ignore", "--quiet", "--", path], cwd);
|
|
247
|
+
return true;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err instanceof GitError && err.code === 1) return false;
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
class GitError extends Error {
|
|
255
|
+
constructor(
|
|
256
|
+
public code: number,
|
|
257
|
+
public stderr: string,
|
|
258
|
+
) {
|
|
259
|
+
super(`git exited ${code}: ${stderr}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function runGit(args: string[], cwd: string): Promise<string> {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const proc = spawn("git", args, { cwd });
|
|
266
|
+
let stdout = "";
|
|
267
|
+
let stderr = "";
|
|
268
|
+
proc.stdout.on("data", (b) => {
|
|
269
|
+
stdout += b.toString();
|
|
270
|
+
});
|
|
271
|
+
proc.stderr.on("data", (b) => {
|
|
272
|
+
stderr += b.toString();
|
|
273
|
+
});
|
|
274
|
+
proc.on("error", reject);
|
|
275
|
+
proc.on("close", (code) => {
|
|
276
|
+
if (code === 0) resolve(stdout);
|
|
277
|
+
else reject(new GitError(code ?? -1, stderr));
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-tier gitignore manifest resolver.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (lowest → highest, last-wins):
|
|
5
|
+
* 1. Built-in defaults (shipped with magus)
|
|
6
|
+
* 2. Global manifest at ~/.claude/gitignore.json (team-level, manually shared)
|
|
7
|
+
* 3. Project manifest at <cwd>/.claude/gitignore.json
|
|
8
|
+
*
|
|
9
|
+
* `extends` references are resolved against BUILTIN_TEMPLATES. Within a single
|
|
10
|
+
* tier, the same pattern cannot appear in both `ignore` and `track` — that
|
|
11
|
+
* throws. Across tiers, the higher-precedence tier wins; losers are recorded
|
|
12
|
+
* in `conflicts` so the TUI can show "global says ignore .env, project
|
|
13
|
+
* overrides to track."
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
|
|
21
|
+
import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
|
|
22
|
+
import type {
|
|
23
|
+
GitignoreManifest,
|
|
24
|
+
ResolvedConflict,
|
|
25
|
+
ResolvedManifest,
|
|
26
|
+
ResolvedRule,
|
|
27
|
+
RuleAction,
|
|
28
|
+
Tier,
|
|
29
|
+
} from "../types/gitignore.js";
|
|
30
|
+
|
|
31
|
+
export interface ResolveOptions {
|
|
32
|
+
/** Override $HOME (test seam). */
|
|
33
|
+
homeDir?: string;
|
|
34
|
+
/** Optional logger for non-fatal warnings (malformed JSON, missing template). */
|
|
35
|
+
logger?: (msg: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TIER_ORDER: Tier[] = ["builtin", "global", "project"];
|
|
39
|
+
|
|
40
|
+
export async function resolveGitignore(
|
|
41
|
+
projectCwd: string,
|
|
42
|
+
opts: ResolveOptions = {},
|
|
43
|
+
): Promise<ResolvedManifest> {
|
|
44
|
+
const home = opts.homeDir ?? homedir();
|
|
45
|
+
const log = opts.logger ?? (() => {});
|
|
46
|
+
|
|
47
|
+
const tierManifests = new Map<Tier, GitignoreManifest>();
|
|
48
|
+
|
|
49
|
+
// 1. Built-in (always present)
|
|
50
|
+
tierManifests.set("builtin", BUILTIN_DEFAULTS);
|
|
51
|
+
|
|
52
|
+
// 2. Global ~/.claude/gitignore.json
|
|
53
|
+
const globalPath = join(home, ".claude", "gitignore.json");
|
|
54
|
+
const globalManifest = await readManifest(globalPath, log);
|
|
55
|
+
if (globalManifest) tierManifests.set("global", globalManifest);
|
|
56
|
+
|
|
57
|
+
// 3. Project .claude/gitignore.json
|
|
58
|
+
const projectPath = join(projectCwd, ".claude", "gitignore.json");
|
|
59
|
+
const projectManifest = await readManifest(projectPath, log);
|
|
60
|
+
if (projectManifest) tierManifests.set("project", projectManifest);
|
|
61
|
+
|
|
62
|
+
// Apply `extends` for each tier (templates count as part of their tier)
|
|
63
|
+
for (const tier of TIER_ORDER) {
|
|
64
|
+
const m = tierManifests.get(tier);
|
|
65
|
+
if (!m?.extends?.length) continue;
|
|
66
|
+
const merged: GitignoreManifest = {
|
|
67
|
+
ignore: [...m.ignore],
|
|
68
|
+
track: [...m.track],
|
|
69
|
+
};
|
|
70
|
+
for (const tplName of m.extends) {
|
|
71
|
+
const tpl = BUILTIN_TEMPLATES[tplName];
|
|
72
|
+
if (!tpl) {
|
|
73
|
+
log(`[gitignore] unknown template "${tplName}" in ${tier} manifest`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
merged.ignore.push(...tpl.ignore);
|
|
77
|
+
merged.track.push(...tpl.track);
|
|
78
|
+
}
|
|
79
|
+
tierManifests.set(tier, merged);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Validate intra-tier consistency
|
|
83
|
+
for (const [tier, m] of tierManifests.entries()) {
|
|
84
|
+
const ig = new Set(m.ignore);
|
|
85
|
+
for (const t of m.track) {
|
|
86
|
+
if (ig.has(t)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Intra-tier conflict in ${tier} manifest: "${t}" is in both ignore and track`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Merge across tiers — last-wins, track conflicts
|
|
95
|
+
const ruleByPattern = new Map<
|
|
96
|
+
string,
|
|
97
|
+
{
|
|
98
|
+
action: RuleAction;
|
|
99
|
+
source: Tier;
|
|
100
|
+
history: Array<{ tier: Tier; action: RuleAction }>;
|
|
101
|
+
}
|
|
102
|
+
>();
|
|
103
|
+
|
|
104
|
+
for (const tier of TIER_ORDER) {
|
|
105
|
+
const m = tierManifests.get(tier);
|
|
106
|
+
if (!m) continue;
|
|
107
|
+
for (const p of dedupe(m.ignore)) {
|
|
108
|
+
pushRule(ruleByPattern, p, "ignore", tier);
|
|
109
|
+
}
|
|
110
|
+
for (const p of dedupe(m.track)) {
|
|
111
|
+
pushRule(ruleByPattern, p, "track", tier);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rules: ResolvedRule[] = [];
|
|
116
|
+
const conflicts: ResolvedConflict[] = [];
|
|
117
|
+
|
|
118
|
+
for (const [pattern, entry] of ruleByPattern.entries()) {
|
|
119
|
+
rules.push({ pattern, action: entry.action, source: entry.source });
|
|
120
|
+
|
|
121
|
+
const losers = entry.history.filter(
|
|
122
|
+
(h) => h.tier !== entry.source || h.action !== entry.action,
|
|
123
|
+
);
|
|
124
|
+
if (losers.length > 0 && hasActionDisagreement(entry.history)) {
|
|
125
|
+
conflicts.push({
|
|
126
|
+
pattern,
|
|
127
|
+
winner: entry.source,
|
|
128
|
+
losers: losers.filter((l) => l.action !== entry.action),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { rules, conflicts };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pushRule(
|
|
137
|
+
map: Map<
|
|
138
|
+
string,
|
|
139
|
+
{
|
|
140
|
+
action: RuleAction;
|
|
141
|
+
source: Tier;
|
|
142
|
+
history: Array<{ tier: Tier; action: RuleAction }>;
|
|
143
|
+
}
|
|
144
|
+
>,
|
|
145
|
+
pattern: string,
|
|
146
|
+
action: RuleAction,
|
|
147
|
+
tier: Tier,
|
|
148
|
+
): void {
|
|
149
|
+
const existing = map.get(pattern);
|
|
150
|
+
if (existing) {
|
|
151
|
+
existing.history.push({ tier, action });
|
|
152
|
+
existing.action = action;
|
|
153
|
+
existing.source = tier;
|
|
154
|
+
} else {
|
|
155
|
+
map.set(pattern, { action, source: tier, history: [{ tier, action }] });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasActionDisagreement(
|
|
160
|
+
history: Array<{ tier: Tier; action: RuleAction }>,
|
|
161
|
+
): boolean {
|
|
162
|
+
const actions = new Set(history.map((h) => h.action));
|
|
163
|
+
return actions.size > 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function dedupe(xs: string[]): string[] {
|
|
167
|
+
return Array.from(new Set(xs));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readManifest(
|
|
171
|
+
path: string,
|
|
172
|
+
log: (msg: string) => void,
|
|
173
|
+
): Promise<GitignoreManifest | null> {
|
|
174
|
+
if (!existsSync(path)) return null;
|
|
175
|
+
try {
|
|
176
|
+
const raw = await readFile(path, "utf8");
|
|
177
|
+
const parsed = JSON.parse(raw);
|
|
178
|
+
if (!isManifest(parsed)) {
|
|
179
|
+
log(
|
|
180
|
+
`[gitignore] ${path} is not a valid manifest (expected { ignore: string[], track: string[] })`,
|
|
181
|
+
);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return parsed;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
log(
|
|
187
|
+
`[gitignore] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
188
|
+
);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isManifest(x: unknown): x is GitignoreManifest {
|
|
194
|
+
if (!x || typeof x !== "object") return false;
|
|
195
|
+
const m = x as Record<string, unknown>;
|
|
196
|
+
if (!Array.isArray(m.ignore) || !m.ignore.every((p) => typeof p === "string"))
|
|
197
|
+
return false;
|
|
198
|
+
if (!Array.isArray(m.track) || !m.track.every((p) => typeof p === "string"))
|
|
199
|
+
return false;
|
|
200
|
+
if (m.extends !== undefined) {
|
|
201
|
+
if (
|
|
202
|
+
!Array.isArray(m.extends) ||
|
|
203
|
+
!m.extends.every((p) => typeof p === "string")
|
|
204
|
+
)
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|