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
60 changes: 50 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,20 +329,25 @@ async function main() {
if (!isDryRun && exportedPaths.length > 0) {
let shouldUpdateGitignore = false

// Check if there are actually new patterns to add
const hasNewPatterns = checkForNewGitignorePatterns(outputDir, exportedPaths)

if (values['gitignore']) {
// --gitignore flag: automatically update gitignore (answer yes)
shouldUpdateGitignore = true
console.log(color.info('Updating .gitignore (auto-enabled by --gitignore flag)'))
} else if (!values['no-gitignore']) {
// Default behavior: ask user
} else if (!values['no-gitignore'] && hasNewPatterns) {
// Default behavior: ask user only if there are new patterns
console.log()
shouldUpdateGitignore = await confirm('Add exported files to .gitignore?', true)
}
// If --no-gitignore is specified, shouldUpdateGitignore remains false
// If --no-gitignore is specified or no new patterns, shouldUpdateGitignore remains false

if (shouldUpdateGitignore) {
updateGitignoreWithPaths(outputDir, exportedPaths)
console.log(color.success('Updated .gitignore'))
const wasUpdated = updateGitignoreWithPaths(outputDir, exportedPaths)
if (wasUpdated) {
console.log(color.success('Updated .gitignore'))
}
}
}
break
Expand Down Expand Up @@ -458,7 +463,42 @@ async function main() {
}
}

function updateGitignoreWithPaths(repoPath: string, paths: string[]): void {
function filterNewPatterns(content: string, paths: string[]): string[] {
const lines = content
.split(/\r?\n/)
.map(l => l.trim())
.filter(l => l && !l.startsWith('#'))

const lineSet = new Set(lines)

const variants = (p: string): string[] => {
if (p.endsWith('/')) {
const base = p.replace(/^\/+/, '')
return [base, `${base}**`, `/${base}`, `/${base}**`]
} else {
const base = p.replace(/^\/+/, '')
return [base, `/${base}`]
}
}

return paths.filter(p => !variants(p).some(v => lineSet.has(v)))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function checkForNewGitignorePatterns(repoPath: string, paths: string[]): boolean {
const gitignorePath = join(repoPath, '.gitignore')

// If gitignore doesn't exist, all patterns are new
if (!existsSync(gitignorePath)) {
return paths.length > 0
}

const content = readFileSync(gitignorePath, 'utf-8')
const newPatterns = filterNewPatterns(content, paths)

return newPatterns.length > 0
}

function updateGitignoreWithPaths(repoPath: string, paths: string[]): boolean {
const gitignorePath = join(repoPath, '.gitignore')

const patterns = [
Expand All @@ -472,16 +512,16 @@ function updateGitignoreWithPaths(repoPath: string, paths: string[]): void {
const content = readFileSync(gitignorePath, 'utf-8')

// Check if any of the patterns already exist
const newPatterns = paths.filter(p => {
const pattern = p.endsWith('/') ? p + '**' : p
return !content.includes(pattern)
})
const newPatterns = filterNewPatterns(content, paths)

if (newPatterns.length > 0) {
appendFileSync(gitignorePath, patterns)
return true
}
return false
} else {
writeFileSync(gitignorePath, patterns.trim() + '\n')
return true
}
}

Expand Down
226 changes: 226 additions & 0 deletions test/gitignore-prompt-conditional.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { execSync } from 'child_process'

describe('Gitignore prompt conditional behavior', () => {
let tempDir: string
let agentDir: string

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'gitignore-prompt-test-'))
agentDir = join(tempDir, '.agent')
mkdirSync(agentDir, { recursive: true })

// Create a simple agent rule file
writeFileSync(join(agentDir, 'test-rule.md'), `---
id: test-rule
title: Test Rule
---

# Test Rule

This is a test rule.`)
})

afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})

function runDotAgentExport(args: string[]): { stdout: string; stderr: string; exitCode: number } {
const cliPath = join(process.cwd(), 'dist', 'cli.js')
const cmd = `node ${cliPath} export ${args.join(' ')}`

try {
const result = execSync(cmd, {
cwd: tempDir,
encoding: 'utf-8',
env: { ...process.env, NODE_ENV: 'test' } // Set NODE_ENV to 'test' to avoid prompts
})
return { stdout: result, stderr: '', exitCode: 0 }
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
exitCode: error.status || 1
}
}
}

it('should not update gitignore when all exported files are already in gitignore', () => {
// Create a gitignore file with the pattern we're about to export
const gitignorePath = join(tempDir, '.gitignore')
writeFileSync(gitignorePath, `# Pre-existing content
node_modules/

# Added by dotagent: ignore exported AI rule files
.github/copilot-instructions.md
`)

// Get the initial gitignore content
const initialContent = readFileSync(gitignorePath, 'utf-8')

const result = runDotAgentExport(['--format', 'copilot'])

expect(result.exitCode).toBe(0)

// Check that gitignore was NOT updated (content should be the same)
const finalContent = readFileSync(gitignorePath, 'utf-8')
expect(finalContent).toBe(initialContent)

// Should still indicate export was successful
expect(result.stdout).toContain('Exported to:')
// Should NOT indicate gitignore was updated
expect(result.stdout).not.toContain('Updated .gitignore')
})

it('should update gitignore when exported files are not in gitignore', () => {
// Create a gitignore file without the pattern we're about to export
const gitignorePath = join(tempDir, '.gitignore')
writeFileSync(gitignorePath, `# Pre-existing content
node_modules/
`)

// Get the initial gitignore content
const initialContent = readFileSync(gitignorePath, 'utf-8')

const result = runDotAgentExport(['--format', 'copilot'])

expect(result.exitCode).toBe(0)

// Check that gitignore was updated (content should be different)
const finalContent = readFileSync(gitignorePath, 'utf-8')
expect(finalContent).not.toBe(initialContent)
expect(finalContent).toContain('.github/copilot-instructions.md')

// Should indicate export was successful and gitignore was updated
expect(result.stdout).toContain('Exported to:')
expect(result.stdout).toContain('Updated .gitignore')
})

it('should update gitignore when gitignore does not exist', () => {
// Don't create a gitignore file

const result = runDotAgentExport(['--format', 'copilot'])

expect(result.exitCode).toBe(0)

// Check that gitignore was created
const gitignorePath = join(tempDir, '.gitignore')
expect(existsSync(gitignorePath)).toBe(true)

const gitignoreContent = readFileSync(gitignorePath, 'utf-8')
expect(gitignoreContent).toContain('.github/copilot-instructions.md')

// Should indicate export was successful and gitignore was updated
expect(result.stdout).toContain('Exported to:')
expect(result.stdout).toContain('Updated .gitignore')
})

it('should not update gitignore with --no-gitignore flag even when there are new patterns', () => {
// Don't create a gitignore file (so all patterns would be new)

const result = runDotAgentExport(['--format', 'copilot', '--no-gitignore'])

expect(result.exitCode).toBe(0)

// Check that gitignore was NOT created
const gitignorePath = join(tempDir, '.gitignore')
expect(existsSync(gitignorePath)).toBe(false)

// Should indicate export was successful but NOT that gitignore was updated
expect(result.stdout).toContain('Exported to:')
expect(result.stdout).not.toContain('Updated .gitignore')
})

it('should update gitignore with --gitignore flag even when all patterns already exist', () => {
// Create a gitignore file with the pattern we're about to export
const gitignorePath = join(tempDir, '.gitignore')
writeFileSync(gitignorePath, `# Pre-existing content
node_modules/

# Added by dotagent: ignore exported AI rule files
.github/copilot-instructions.md
`)

// Get the initial gitignore content
const initialContent = readFileSync(gitignorePath, 'utf-8')

const result = runDotAgentExport(['--format', 'copilot', '--gitignore'])

expect(result.exitCode).toBe(0)

// Check that gitignore was NOT updated (content should be the same)
const finalContent = readFileSync(gitignorePath, 'utf-8')
expect(finalContent).toBe(initialContent)

// Should indicate auto-update
expect(result.stdout).toContain('Updating .gitignore (auto-enabled by --gitignore flag)')
// But should NOT indicate it was actually updated since no new patterns were added
expect(result.stdout).not.toContain('Updated .gitignore')
})

it('should not update gitignore when exporting to multiple formats but all patterns already exist', () => {
// Create a gitignore file with all the patterns we're about to export
const gitignorePath = join(tempDir, '.gitignore')
writeFileSync(gitignorePath, `# Pre-existing content
node_modules/

# Added by dotagent: ignore exported AI rule files
.github/copilot-instructions.md
.cursor/rules/**
.clinerules
.windsurfrules
.rules
AGENTS.md
CONVENTIONS.md
CLAUDE.md
GEMINI.md
best_practices.md
`)

// Get the initial gitignore content
const initialContent = readFileSync(gitignorePath, 'utf-8')

const result = runDotAgentExport(['--formats', 'copilot,cline,claude'])

expect(result.exitCode).toBe(0)

// Check that gitignore was NOT updated (content should be the same)
const finalContent = readFileSync(gitignorePath, 'utf-8')
expect(finalContent).toBe(initialContent)

// Should indicate export was successful but NOT that gitignore was updated
expect(result.stdout).toContain('Exported to:')
expect(result.stdout).not.toContain('Updated .gitignore')
})

it('should update gitignore when exporting to multiple formats and some patterns are new', () => {
// Create a gitignore file with only some of the patterns we're about to export
const gitignorePath = join(tempDir, '.gitignore')
writeFileSync(gitignorePath, `# Pre-existing content
node_modules/

# Added by dotagent: ignore exported AI rule files
.github/copilot-instructions.md
.clinerules
`)

// Get the initial gitignore content
const initialContent = readFileSync(gitignorePath, 'utf-8')

const result = runDotAgentExport(['--formats', 'copilot,cline,claude'])

expect(result.exitCode).toBe(0)

// Check that gitignore was updated (content should be different)
const finalContent = readFileSync(gitignorePath, 'utf-8')
expect(finalContent).not.toBe(initialContent)
expect(finalContent).toContain('CLAUDE.md')

// Should indicate export was successful and gitignore was updated
expect(result.stdout).toContain('Exported to:')
expect(result.stdout).toContain('Updated .gitignore')
})
})
Loading