From 1e36fa0fa3bf3341122e508682602cb8bd94a938 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:17:26 -0800 Subject: [PATCH] add gitignore parsing, new deps --- bun.lock | 8 +- package.json | 7 +- src/cli/index.ts | 54 +++++------ src/server/sync.ts | 35 +------ src/shared/gitignore.test.ts | 174 +++++++++++++++++++++++++++++++++++ src/shared/gitignore.ts | 77 ++++++++++++++++ 6 files changed, 288 insertions(+), 67 deletions(-) create mode 100644 src/shared/gitignore.test.ts create mode 100644 src/shared/gitignore.ts diff --git a/bun.lock b/bun.lock index 0764fca..3a04b72 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "toes", "dependencies": { - "@defunkt/forge": "*", - "@defunkt/hype": "*", + "@_because/forge": "*", + "@_because/hype": "*", "commander": "^14.0.2", "kleur": "^4.1.5", }, @@ -19,9 +19,9 @@ }, }, "packages": { - "@defunkt/forge": ["@defunkt/forge@0.1.0", "https://npm.nose.space/@defunkt/forge/-/forge-0.1.0.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-t/byfEmjUSr4QiQhFZSnjuh1ozSJQD/kdurVHWEjE8HgSvyn9UMM+ZDXKiPD5LaRue5qtiAhBYTzHK9fHPSnSg=="], + "@_because/forge": ["@_because/forge@0.1.0", "https://npm.nose.space/@_because/forge/-/forge-0.1.0.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-kut50WMLDUb088SHnCPENJKI6rcXLzswSlGcKqsl3d8F40X8uRXTX/CdZDK5Q9Z1CDpFzGZHQ9nireqme3IvPQ=="], - "@defunkt/hype": ["@defunkt/hype@0.1.0", "https://npm.nose.space/@defunkt/hype/-/hype-0.1.0.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-PDds9petpQONUXSI4MPYmjzjtSU0DnETBLlGEGaSSe1cSg/AiW25AN9H5plQyiHJoS+86jYviVncD7uPXiIarQ=="], + "@_because/hype": ["@_because/hype@0.1.0", "https://npm.nose.space/@_because/hype/-/hype-0.1.0.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-cw3Ms2jOIv1159VeNZFAEcH/fzVKOuqul9ggcemkZQHFAX1uGJD9wEvdJd2PNbbR5GG62J5rAPpqHHNmWrb8qA=="], "@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], diff --git a/package.json b/package.json index 2dd3752..b00baed 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ }, "scripts": { "start": "bun run src/server/index.tsx", - "dev": "bun run --hot src/server/index.tsx" + "dev": "bun run --hot src/server/index.tsx", + "test": "bun test" }, "devDependencies": { "@types/bun": "latest" @@ -18,8 +19,8 @@ }, "dependencies": { "commander": "^14.0.2", - "@defunkt/forge": "*", - "@defunkt/hype": "*", + "@_because/forge": "*", + "@_because/hype": "*", "kleur": "^4.1.5" } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 3e94f2f..afaeeee 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,11 +1,12 @@ #!/usr/bin/env bun +import { APPS_DIR } from '$apps' +import type { App, LogLine } from '@types' +import { loadGitignore } from '@gitignore' import { program } from 'commander' import { createHash } from 'crypto' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs' -import { dirname, join, relative } from 'path' -import type { App, LogLine } from '@types' import color from 'kleur' -import { APPS_DIR } from '$apps' +import { dirname, join, relative } from 'path' const HOST = `http://localhost:${process.env.PORT ?? 3000}` @@ -20,17 +21,6 @@ interface Manifest { name: string } -const EXCLUDE_PATTERNS = [ - 'node_modules', - '.DS_Store', - '*.log', - 'dist', - 'build', - '.env.local', - '.git', - 'bun.lockb', -] - const STATE_ICONS: Record = { running: color.green('●'), starting: color.yellow('◎'), @@ -108,22 +98,9 @@ function computeHash(content: Buffer | string): string { return createHash('sha256').update(content).digest('hex') } -function shouldExclude(path: string): boolean { - const parts = path.split('/') - - for (const pattern of EXCLUDE_PATTERNS) { - if (parts.includes(pattern)) return true - if (pattern.startsWith('*')) { - const ext = pattern.slice(1) - if (path.endsWith(ext)) return true - } - } - - return false -} - function generateLocalManifest(appPath: string, appName: string): Manifest { const files: Record = {} + const gitignore = loadGitignore(appPath) function walkDir(dir: string) { if (!existsSync(dir)) return @@ -134,7 +111,7 @@ function generateLocalManifest(appPath: string, appName: string): Manifest { const fullPath = join(dir, entry.name) const relativePath = relative(appPath, fullPath) - if (shouldExclude(relativePath)) continue + if (gitignore.shouldExclude(relativePath)) continue if (entry.isDirectory()) { walkDir(fullPath) @@ -316,7 +293,21 @@ async function getApp(name: string) { console.log(color.green(`✓ Downloaded ${name}`)) } +function isApp(): boolean { + try { + const pkg = JSON.parse(join(process.cwd(), 'package.json')) + return !!pkg?.scripts?.toes + } catch (e) { + return false + } +} + async function pushApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + const appName = process.cwd().split('/').pop() if (!appName) { console.error('Could not determine app name from current directory') @@ -388,6 +379,11 @@ async function pushApp() { } async function pullApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + const appName = process.cwd().split('/').pop() if (!appName) { console.error('Could not determine app name from current directory') diff --git a/src/server/sync.ts b/src/server/sync.ts index 763c18c..f0f5e83 100644 --- a/src/server/sync.ts +++ b/src/server/sync.ts @@ -1,6 +1,7 @@ -import { readdirSync, readFileSync, statSync } from 'fs' import { createHash } from 'crypto' +import { readdirSync, readFileSync, statSync } from 'fs' import { join, relative } from 'path' +import { loadGitignore } from '@gitignore' export interface FileInfo { hash: string @@ -13,23 +14,13 @@ export interface Manifest { name: string } -const EXCLUDE_PATTERNS = [ - 'node_modules', - '.DS_Store', - '*.log', - 'dist', - 'build', - '.env.local', - '.git', - 'bun.lockb', -] - export function computeHash(content: Buffer | string): string { return createHash('sha256').update(content).digest('hex') } export function generateManifest(appPath: string, appName: string): Manifest { const files: Record = {} + const gitignore = loadGitignore(appPath) function walkDir(dir: string) { const entries = readdirSync(dir, { withFileTypes: true }) @@ -38,8 +29,7 @@ export function generateManifest(appPath: string, appName: string): Manifest { const fullPath = join(dir, entry.name) const relativePath = relative(appPath, fullPath) - // Check exclusions - if (shouldExclude(relativePath)) continue + if (gitignore.shouldExclude(relativePath)) continue if (entry.isDirectory()) { walkDir(fullPath) @@ -63,20 +53,3 @@ export function generateManifest(appPath: string, appName: string): Manifest { files, } } - -function shouldExclude(path: string): boolean { - const parts = path.split('/') - - for (const pattern of EXCLUDE_PATTERNS) { - // Exact match or starts with (for directories) - if (parts.includes(pattern)) return true - - // Wildcard patterns - if (pattern.startsWith('*')) { - const ext = pattern.slice(1) - if (path.endsWith(ext)) return true - } - } - - return false -} diff --git a/src/shared/gitignore.test.ts b/src/shared/gitignore.test.ts new file mode 100644 index 0000000..337a6db --- /dev/null +++ b/src/shared/gitignore.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdirSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import { loadGitignore } from './gitignore' + +const TEST_DIR = '/tmp/toes-gitignore-test' + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) +}) + +afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) +}) + +describe('loadGitignore', () => { + describe('with .gitignore file', () => { + test('should parse simple patterns', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules\n*.log\ndist/') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('node_modules')).toBe(true) + expect(checker.shouldExclude('node_modules/package.json')).toBe(true) + expect(checker.shouldExclude('test.log')).toBe(true) + expect(checker.shouldExclude('dist/')).toBe(true) + expect(checker.shouldExclude('dist/main.js')).toBe(true) + expect(checker.shouldExclude('src/index.ts')).toBe(false) + }) + + test('should ignore comments and empty lines', () => { + writeFileSync( + join(TEST_DIR, '.gitignore'), + '# Comment\nnode_modules\n\n# Another comment\n*.log\n\n' + ) + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('node_modules')).toBe(true) + expect(checker.shouldExclude('test.log')).toBe(true) + expect(checker.shouldExclude('# Comment')).toBe(false) + }) + + test('should handle wildcard patterns', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), '*.log\n*.tmp\n*.swp') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('debug.log')).toBe(true) + expect(checker.shouldExclude('cache.tmp')).toBe(true) + expect(checker.shouldExclude('vim.swp')).toBe(true) + expect(checker.shouldExclude('src/file.log')).toBe(true) + expect(checker.shouldExclude('index.ts')).toBe(false) + }) + + test('should handle glob patterns', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), '**/*.log\n**/dist\n**/.DS_Store') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('test.log')).toBe(true) + expect(checker.shouldExclude('src/test.log')).toBe(true) + expect(checker.shouldExclude('src/nested/test.log')).toBe(true) + expect(checker.shouldExclude('dist')).toBe(true) + expect(checker.shouldExclude('src/dist')).toBe(true) + expect(checker.shouldExclude('.DS_Store')).toBe(true) + expect(checker.shouldExclude('src/.DS_Store')).toBe(true) + }) + + test('should handle directory patterns', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), 'dist/\nbuild/\ntemp') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('dist/')).toBe(true) + expect(checker.shouldExclude('dist/main.js')).toBe(true) + expect(checker.shouldExclude('build/')).toBe(true) + expect(checker.shouldExclude('build/output.js')).toBe(true) + expect(checker.shouldExclude('temp')).toBe(true) + expect(checker.shouldExclude('temp/file.txt')).toBe(true) + }) + + test('should handle nested paths', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules\n.git\n.env.local') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('src/node_modules/lib.js')).toBe(true) + expect(checker.shouldExclude('project/node_modules')).toBe(true) + expect(checker.shouldExclude('.git/config')).toBe(true) + expect(checker.shouldExclude('.env.local')).toBe(true) + expect(checker.shouldExclude('config/.env.local')).toBe(true) + }) + + test('should handle exact filename matches', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), '.DS_Store\nbun.lockb') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('.DS_Store')).toBe(true) + expect(checker.shouldExclude('src/.DS_Store')).toBe(true) + expect(checker.shouldExclude('bun.lockb')).toBe(true) + expect(checker.shouldExclude('project/bun.lockb')).toBe(true) + }) + }) + + describe('without .gitignore file (default patterns)', () => { + test('should use default exclusions', () => { + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('node_modules')).toBe(true) + expect(checker.shouldExclude('node_modules/pkg/index.js')).toBe(true) + expect(checker.shouldExclude('.DS_Store')).toBe(true) + expect(checker.shouldExclude('src/.DS_Store')).toBe(true) + expect(checker.shouldExclude('debug.log')).toBe(true) + expect(checker.shouldExclude('dist')).toBe(true) + expect(checker.shouldExclude('dist/main.js')).toBe(true) + expect(checker.shouldExclude('build')).toBe(true) + expect(checker.shouldExclude('.env.local')).toBe(true) + expect(checker.shouldExclude('.git')).toBe(true) + expect(checker.shouldExclude('.git/config')).toBe(true) + expect(checker.shouldExclude('bun.lockb')).toBe(true) + }) + + test('should not exclude normal files', () => { + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('src/index.ts')).toBe(false) + expect(checker.shouldExclude('package.json')).toBe(false) + expect(checker.shouldExclude('README.md')).toBe(false) + expect(checker.shouldExclude('tsconfig.json')).toBe(false) + }) + }) + + describe('edge cases', () => { + test('should handle empty .gitignore', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), '') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('anything.js')).toBe(false) + }) + + test('should handle .gitignore with only comments', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), '# Comment 1\n# Comment 2') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('anything.js')).toBe(false) + }) + + test('should handle paths with dots', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), '*.env.*\n.cache') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('.env.local')).toBe(true) + expect(checker.shouldExclude('.env.production')).toBe(true) + expect(checker.shouldExclude('.cache')).toBe(true) + expect(checker.shouldExclude('src/.cache')).toBe(true) + }) + + test('should handle patterns with trailing slashes', () => { + writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules/\nbuild/') + + const checker = loadGitignore(TEST_DIR) + + expect(checker.shouldExclude('node_modules/')).toBe(true) + expect(checker.shouldExclude('node_modules/package')).toBe(true) + expect(checker.shouldExclude('build/')).toBe(true) + expect(checker.shouldExclude('build/output')).toBe(true) + }) + }) +}) diff --git a/src/shared/gitignore.ts b/src/shared/gitignore.ts new file mode 100644 index 0000000..2b288fd --- /dev/null +++ b/src/shared/gitignore.ts @@ -0,0 +1,77 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +const DEFAULT_PATTERNS = [ + 'node_modules', + '.DS_Store', + '*.log', + 'dist', + 'build', + '.env.local', + '.git', + 'bun.lockb', +] + +export interface GitignoreChecker { + shouldExclude: (path: string) => boolean +} + +export function loadGitignore(appPath: string): GitignoreChecker { + const gitignorePath = join(appPath, '.gitignore') + const patterns = existsSync(gitignorePath) + ? parseGitignore(readFileSync(gitignorePath, 'utf-8')) + : DEFAULT_PATTERNS + + return { + shouldExclude: (path: string) => matchesPattern(path, patterns), + } +} + +function parseGitignore(content: string): string[] { + return content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) +} + +function matchesPattern(path: string, patterns: string[]): boolean { + const parts = path.split('/') + const filename = parts[parts.length - 1]! + + for (const pattern of patterns) { + // Patterns with wildcards or globs + if (pattern.includes('*') || pattern.includes('?')) { + const regex = globToRegex(pattern) + // Try matching against full path and just filename + if (regex.test(path) || regex.test(filename)) return true + continue + } + + // Directory match: "node_modules" or "node_modules/" + const dirPattern = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern + if (parts.includes(dirPattern)) return true + + // Prefix patterns: "dist/" or "build/" + if (pattern.endsWith('/') && path.startsWith(pattern)) return true + + // Exact match + if (path === pattern || filename === pattern) return true + } + + return false +} + +function globToRegex(glob: string): RegExp { + // Handle ** specially - it can match zero or more path segments + // Use placeholders to avoid conflicts with regex escaping + let pattern = glob + .replace(/\*\*\//g, '\x00') // **/ placeholder + .replace(/\*\*/g, '\x01') // ** placeholder + .replace(/\./g, '\\.') // Escape dots + .replace(/\*/g, '[^/]*') // Single * matches within path segment + .replace(/\?/g, '[^/]') // ? matches single char + .replace(/\x00/g, '(.*/)?') // **/ matches zero or more dirs + .replace(/\x01/g, '.*') // ** matches anything + + return new RegExp(`^${pattern}$`) +}