diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 1317bbbf5..c07213d48 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -3319,9 +3319,69 @@ async function runTests() { console.log(''); // ============================================================ - // Test Suite 46: Python environment check (version parsing + classification) + // Test Suite 46: shared-scripts install gitignores config.user.toml (#2456) // ============================================================ - console.log(`${colors.yellow}Test Suite 46: python-check version parsing and classification${colors.reset}\n`); + console.log(`${colors.yellow}Test Suite 46: shared-scripts install gitignores user config${colors.reset}\n`); + + let root46; + try { + root46 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-gitignore-test-')); + const bmadDir46 = path.join(root46, '_bmad'); + const customDir46 = path.join(bmadDir46, 'custom'); + await fs.ensureDir(customDir46); + + // _installSharedScripts only reaches for these four path fields, so a plain + // object stands in for the frozen InstallPaths instance. srcDir points at + // the real repo so src/scripts is copied into scriptsDir as in production. + const paths46 = { + srcDir: projectRoot, + bmadDir: bmadDir46, + customDir: customDir46, + scriptsDir: path.join(bmadDir46, 'scripts'), + }; + + const installer46 = new Installer(); + await installer46._installSharedScripts(paths46); + + const bmadGitignore46 = path.join(bmadDir46, '.gitignore'); + assert(await fs.pathExists(bmadGitignore46), '_installSharedScripts seeds _bmad/.gitignore'); + const ignoreLines46 = (await fs.readFile(bmadGitignore46, 'utf8')).split(/\r?\n/); + assert(ignoreLines46.includes('config.user.toml'), '_bmad/.gitignore ignores config.user.toml'); + assert(installer46.installedFiles.has(bmadGitignore46), '_bmad/.gitignore is tracked as an installed file'); + + // The pre-existing custom/*.user.toml rule is still seeded alongside it. + assert(await fs.pathExists(path.join(customDir46, '.gitignore')), '_installSharedScripts still seeds _bmad/custom/.gitignore'); + + // Idempotent: a second install must not duplicate the entry. Fresh Installer + // per run, as in production — reuse would hide a failure to re-track the file. + const installer46b = new Installer(); + await installer46b._installSharedScripts(paths46); + const occurrences46 = (await fs.readFile(bmadGitignore46, 'utf8')).split(/\r?\n/).filter((line) => line === 'config.user.toml').length; + assert(occurrences46 === 1, 'second install does not duplicate the config.user.toml entry'); + assert(installer46b.installedFiles.has(bmadGitignore46), 're-install tracks _bmad/.gitignore even when the entry already exists'); + + // An existing .gitignore with unrelated rules is topped up, not clobbered. + await fs.writeFile(bmadGitignore46, 'notes.local.md\n'); + const installer46c = new Installer(); + await installer46c._installSharedScripts(paths46); + const toppedUp46 = await fs.readFile(bmadGitignore46, 'utf8'); + assert(toppedUp46.includes('notes.local.md'), 'existing .gitignore rules are preserved'); + assert(toppedUp46.split(/\r?\n/).includes('config.user.toml'), 'config.user.toml is appended to an existing .gitignore'); + assert(installer46c.installedFiles.has(bmadGitignore46), 'appending install tracks _bmad/.gitignore'); + } catch (error) { + console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`); + console.log(error.stack); + failed++; + } finally { + if (root46) await fs.remove(root46).catch(() => {}); + } + + console.log(''); + + // ============================================================ + // Test Suite 47: Python environment check (version parsing + classification) + // ============================================================ + console.log(`${colors.yellow}Test Suite 47: python-check version parsing and classification${colors.reset}\n`); try { const { parsePythonVersion, classifyPython, detectPython } = require('../tools/installer/core/python-check'); @@ -3449,7 +3509,7 @@ async function runTests() { process.exit = real.exit; } } catch (error) { - console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`); + console.log(`${colors.red}Test Suite 47 setup failed: ${error.message}${colors.reset}`); console.log(error.stack); failed++; } diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 9c6c6cb6c..ee38a8281 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -658,8 +658,8 @@ class Installer { * Excludes dev-only tests and Python caches so they don't ship to users. * Wipes the destination first so files removed or renamed in source * don't linger and get recorded as installed. Also seeds - * _bmad/custom/.gitignore on fresh installs so *.user.toml overrides - * stay out of version control. + * _bmad/custom/.gitignore so *.user.toml overrides and _bmad/.gitignore + * so the default config.user.toml both stay out of version control. */ async _installSharedScripts(paths) { const srcScriptsDir = path.join(paths.srcDir, 'src', 'scripts'); @@ -682,6 +682,36 @@ class Installer { await fs.writeFile(customGitignore, '*.user.toml\n', 'utf8'); this.installedFiles.add(customGitignore); } + + // The default _bmad/config.user.toml holds personal install answers, so it + // gets the same treatment as custom/*.user.toml above — seed _bmad/.gitignore + // so the file never lands in version control. Append to an existing + // .gitignore that lacks the entry so the rule reaches projects predating it. + await this._ensureUserConfigGitignored(paths.bmadDir); + } + + /** + * Keep the personal _bmad/config.user.toml out of version control. Creates + * _bmad/.gitignore when missing, or appends the entry to an existing file + * that doesn't already list it, so the rule lands on fresh installs and + * updates alike without duplicating the line. + */ + async _ensureUserConfigGitignored(bmadDir) { + const gitignorePath = path.join(bmadDir, '.gitignore'); + const entry = 'config.user.toml'; + + if (await fs.pathExists(gitignorePath)) { + const existing = await fs.readFile(gitignorePath, 'utf8'); + if (!existing.split(/\r?\n/).some((line) => line.trim() === entry)) { + const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; + await fs.writeFile(gitignorePath, `${existing}${separator}${entry}\n`, 'utf8'); + } + } else { + await fs.writeFile(gitignorePath, `${entry}\n`, 'utf8'); + } + // Track on every path — each run starts a fresh Installer, so skipping the + // already-correct file would drop it from files-manifest.csv on re-installs. + this.installedFiles.add(gitignorePath); } async _trackFilesRecursive(dir) {