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.
Files changed (331) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +445 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +174 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +335 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. 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
+ }