From 1049d388451120587a8643a401fd71430a8cd5fe Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Wed, 20 Aug 2025 10:53:53 +0900 Subject: [PATCH] feat: update .gitignore in /setup-github (#6591) --- .gitignore | 3 + .../ui/commands/setupGithubCommand.test.ts | 135 +++++++++++++++++- .../cli/src/ui/commands/setupGithubCommand.ts | 45 +++++- 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index fbbb2dcc..bcead317 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ packages/cli/src/generated/ packages/core/src/generated/ .integration-tests/ packages/vscode-ide-companion/*.vsix + +# GHA credentials +gha-creds-*.json diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 38589e58..2dd9f97f 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -10,7 +10,7 @@ import fs from 'node:fs/promises'; import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; import * as gitUtils from '../../utils/gitUtils.js'; -import { setupGithubCommand } from './setupGithubCommand.js'; +import { setupGithubCommand, updateGitignore } from './setupGithubCommand.js'; import { CommandContext, ToolActionReturn } from './types.js'; import * as commandUtils from '../utils/commandUtils.js'; @@ -102,5 +102,138 @@ describe('setupGithubCommand', async () => { const contents = await fs.readFile(workflowFile, 'utf8'); expect(contents).toContain(workflow); } + + // Verify that .gitignore was created with the expected entries + const gitignorePath = path.join(scratchDir, '.gitignore'); + const gitignoreExists = await fs + .access(gitignorePath) + .then(() => true) + .catch(() => false); + expect(gitignoreExists).toBe(true); + + if (gitignoreExists) { + const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); + expect(gitignoreContent).toContain('.gemini/'); + expect(gitignoreContent).toContain('gha-creds-*.json'); + } + }); +}); + +describe('updateGitignore', () => { + let scratchDir = ''; + + beforeEach(async () => { + scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'update-gitignore-')); + }); + + afterEach(async () => { + if (scratchDir) await fs.rm(scratchDir, { recursive: true }); + }); + + it('creates a new .gitignore file when none exists', async () => { + await updateGitignore(scratchDir); + + const gitignorePath = path.join(scratchDir, '.gitignore'); + const content = await fs.readFile(gitignorePath, 'utf8'); + + expect(content).toBe('.gemini/\ngha-creds-*.json\n'); + }); + + it('appends entries to existing .gitignore file', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = '# Existing content\nnode_modules/\n'; + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + expect(content).toBe( + '# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n', + ); + }); + + it('does not add duplicate entries', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n'; + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + expect(content).toBe(existingContent); + }); + + it('adds only missing entries when some already exist', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = '.gemini/\nsome-other-file\n'; + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + // Should add only the missing gha-creds-*.json entry + expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n'); + expect(content).toContain('gha-creds-*.json'); + // Should not duplicate .gemini/ entry + expect((content.match(/\.gemini\//g) || []).length).toBe(1); + }); + + it('does not get confused by entries in comments or as substrings', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = [ + '# This is a comment mentioning .gemini/ folder', + 'my-app.gemini/config', + '# Another comment with gha-creds-*.json pattern', + 'some-other-gha-creds-file.json', + '', + ].join('\n'); + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + // Should add both entries since they don't actually exist as gitignore rules + expect(content).toContain('.gemini/'); + expect(content).toContain('gha-creds-*.json'); + + // Verify the entries were added (not just mentioned in comments) + const lines = content + .split('\n') + .map((line) => line.split('#')[0].trim()) + .filter((line) => line); + expect(lines).toContain('.gemini/'); + expect(lines).toContain('gha-creds-*.json'); + expect(lines).toContain('my-app.gemini/config'); + expect(lines).toContain('some-other-gha-creds-file.json'); + }); + + it('handles file system errors gracefully', async () => { + // Try to update gitignore in a non-existent directory + const nonExistentDir = path.join(scratchDir, 'non-existent'); + + // This should not throw an error + await expect(updateGitignore(nonExistentDir)).resolves.toBeUndefined(); + }); + + it('handles permission errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const fsModule = await import('node:fs'); + const writeFileSpy = vi + .spyOn(fsModule.promises, 'writeFile') + .mockRejectedValue(new Error('Permission denied')); + + await expect(updateGitignore(scratchDir)).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to update .gitignore:', + expect.any(Error), + ); + + writeFileSpy.mockRestore(); + consoleSpy.mockRestore(); }); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 2f024e60..8ddb5cd9 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -44,6 +44,46 @@ function getOpenUrlsCommands(readmeUrl: string): string[] { return commands; } +// Add Gemini CLI specific entries to .gitignore file +export async function updateGitignore(gitRepoRoot: string): Promise { + const gitignoreEntries = ['.gemini/', 'gha-creds-*.json']; + + const gitignorePath = path.join(gitRepoRoot, '.gitignore'); + try { + // Check if .gitignore exists and read its content + let existingContent = ''; + let fileExists = true; + try { + existingContent = await fs.promises.readFile(gitignorePath, 'utf8'); + } catch (_error) { + // File doesn't exist + fileExists = false; + } + + if (!fileExists) { + // Create new .gitignore file with the entries + const contentToWrite = gitignoreEntries.join('\n') + '\n'; + await fs.promises.writeFile(gitignorePath, contentToWrite); + } else { + // Check which entries are missing + const missingEntries = gitignoreEntries.filter( + (entry) => + !existingContent + .split(/\r?\n/) + .some((line) => line.split('#')[0].trim() === entry), + ); + + if (missingEntries.length > 0) { + const contentToAdd = '\n' + missingEntries.join('\n') + '\n'; + await fs.promises.appendFile(gitignorePath, contentToAdd); + } + } + } catch (error) { + console.debug('Failed to update .gitignore:', error); + // Continue without failing the whole command + } +} + export const setupGithubCommand: SlashCommand = { name: 'setup-github', description: 'Set up GitHub Actions', @@ -146,11 +186,14 @@ export const setupGithubCommand: SlashCommand = { abortController.abort(); }); + // Add entries to .gitignore file + await updateGitignore(gitRepoRoot); + // Print out a message const commands = []; commands.push('set -eEuo pipefail'); commands.push( - `echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`, + `echo "Successfully downloaded ${workflows.length} workflows and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`, ); commands.push(...getOpenUrlsCommands(readmeUrl));