Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ packages/cli/src/generated/
packages/core/src/generated/
.integration-tests/
packages/vscode-ide-companion/*.vsix

# GHA credentials
gha-creds-*.json
135 changes: 134 additions & 1 deletion packages/cli/src/ui/commands/setupGithubCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
});
});
45 changes: 44 additions & 1 deletion packages/cli/src/ui/commands/setupGithubCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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',
Expand Down Expand Up @@ -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));

Expand Down
Loading