add gitignore parsing, new deps
This commit is contained in:
parent
635121a27d
commit
1e36fa0fa3
8
bun.lock
8
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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<string, FileInfo> = {}
|
||||
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 <app>` 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 <app>` to grab one.')
|
||||
return
|
||||
}
|
||||
|
||||
const appName = process.cwd().split('/').pop()
|
||||
if (!appName) {
|
||||
console.error('Could not determine app name from current directory')
|
||||
|
|
|
|||
|
|
@ -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<string, FileInfo> = {}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
174
src/shared/gitignore.test.ts
Normal file
174
src/shared/gitignore.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
77
src/shared/gitignore.ts
Normal file
77
src/shared/gitignore.ts
Normal file
|
|
@ -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}$`)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user