11// SPDX-License-Identifier: MIT
22
3+ import { open , readFile , stat } from "node:fs/promises" ;
34import { join } from "node:path" ;
4- import { Glob } from "bun" ;
5+ import { glob } from "fast-glob" ;
6+ import picomatch from "picomatch" ;
57import ignore , { type Ignore } from "ignore" ;
68import type { BundleConfigInput } from "./config.ts" ;
79
810// Binary file detection: check first 8KB for null bytes (same heuristic as git)
911const BINARY_CHECK_SIZE = 8192 ;
1012
1113async function isBinary ( filePath : string ) : Promise < boolean > {
12- const file = Bun . file ( filePath ) ;
13- const size = file . size ;
14- if ( size === 0 ) return false ;
14+ const stats = await stat ( filePath ) ;
15+ if ( stats . size === 0 ) return false ;
1516
16- const chunk = await file . slice ( 0 , Math . min ( size , BINARY_CHECK_SIZE ) ) . bytes ( ) ;
17- return chunk . includes ( 0 ) ;
17+ const fd = await open ( filePath , "r" ) ;
18+ try {
19+ const buffer = Buffer . alloc ( Math . min ( stats . size , BINARY_CHECK_SIZE ) ) ;
20+ await fd . read ( buffer , 0 , buffer . length , 0 ) ;
21+ return buffer . includes ( 0 ) ;
22+ } finally {
23+ await fd . close ( ) ;
24+ }
1825}
1926
2027export interface FileEntry {
@@ -30,11 +37,15 @@ export interface BundleResult {
3037}
3138
3239/**
33- * Normalize BundleConfig to arrays of include/exclude patterns
40+ * Normalize BundleConfig to arrays of include/exclude/force patterns.
41+ * - Regular patterns: included, filtered by .gitignore
42+ * - `!pattern`: excluded from results
43+ * - `+pattern`: force-included, bypasses .gitignore
3444 */
3545function normalizePatterns ( config : BundleConfigInput ) : {
3646 include : string [ ] ;
3747 exclude : string [ ] ;
48+ force : string [ ] ;
3849} {
3950 let patterns : string [ ] ;
4051
@@ -50,29 +61,28 @@ function normalizePatterns(config: BundleConfigInput): {
5061
5162 const include : string [ ] = [ ] ;
5263 const exclude : string [ ] = [ ] ;
64+ const force : string [ ] = [ ] ;
5365
5466 for ( const p of patterns ) {
5567 if ( p . startsWith ( "!" ) ) {
5668 exclude . push ( p . slice ( 1 ) ) ;
69+ } else if ( p . startsWith ( "+" ) ) {
70+ force . push ( p . slice ( 1 ) ) ;
5771 } else {
5872 include . push ( p ) ;
5973 }
6074 }
6175
62- return { include, exclude } ;
76+ return { include, exclude, force } ;
6377}
6478
79+ type Matcher = ( path : string ) => boolean ;
80+
6581/**
66- * Check if a path matches any of the exclusion patterns
82+ * Check if a path matches any of the exclusion matchers
6783 */
68- function isExcluded ( filePath : string , excludePatterns : string [ ] ) : boolean {
69- for ( const pattern of excludePatterns ) {
70- const glob = new Glob ( pattern ) ;
71- if ( glob . match ( filePath ) ) {
72- return true ;
73- }
74- }
75- return false ;
84+ function isExcluded ( filePath : string , matchers : Matcher [ ] ) : boolean {
85+ return matchers . some ( ( match ) => match ( filePath ) ) ;
7686}
7787
7888/**
@@ -83,7 +93,7 @@ async function loadGitignore(cwd: string): Promise<Ignore> {
8393 const gitignorePath = join ( cwd , ".gitignore" ) ;
8494
8595 try {
86- const content = await Bun . file ( gitignorePath ) . text ( ) ;
96+ const content = await readFile ( gitignorePath , "utf-8" ) ;
8797 ig . add ( content ) ;
8898 } catch {
8999 // No .gitignore file, return empty ignore instance
@@ -94,20 +104,37 @@ async function loadGitignore(cwd: string): Promise<Ignore> {
94104
95105/**
96106 * Resolve bundle config to a list of file paths.
97- * Respects .gitignore patterns in the working directory.
107+ * - Regular patterns respect .gitignore
108+ * - Force patterns (+prefix) bypass .gitignore
109+ * - Exclude patterns (!prefix) filter both
98110 */
99111export async function resolvePatterns (
100112 config : BundleConfigInput ,
101113 cwd : string ,
102114) : Promise < string [ ] > {
103- const { include, exclude } = normalizePatterns ( config ) ;
115+ const { include, exclude, force } = normalizePatterns ( config ) ;
116+ const excludeMatchers = exclude . map ( ( p ) => picomatch ( p ) ) ;
104117 const gitignore = await loadGitignore ( cwd ) ;
105118 const files = new Set < string > ( ) ;
106119
107- for ( const pattern of include ) {
108- const glob = new Glob ( pattern ) ;
109- for await ( const match of glob . scan ( { cwd, onlyFiles : true } ) ) {
110- if ( ! isExcluded ( match , exclude ) && ! gitignore . ignores ( match ) ) {
120+ // Regular includes: respect .gitignore
121+ if ( include . length > 0 ) {
122+ const matches = await glob ( include , { cwd, onlyFiles : true , dot : true } ) ;
123+ for ( const match of matches ) {
124+ if ( ! isExcluded ( match , excludeMatchers ) && ! gitignore . ignores ( match ) ) {
125+ const fullPath = join ( cwd , match ) ;
126+ if ( ! ( await isBinary ( fullPath ) ) ) {
127+ files . add ( match ) ;
128+ }
129+ }
130+ }
131+ }
132+
133+ // Force includes: bypass .gitignore
134+ if ( force . length > 0 ) {
135+ const matches = await glob ( force , { cwd, onlyFiles : true , dot : true } ) ;
136+ for ( const match of matches ) {
137+ if ( ! isExcluded ( match , excludeMatchers ) ) {
111138 const fullPath = join ( cwd , match ) ;
112139 if ( ! ( await isBinary ( fullPath ) ) ) {
113140 files . add ( match ) ;
@@ -184,7 +211,7 @@ export async function createBundle(
184211 for ( let i = 0 ; i < files . length ; i ++ ) {
185212 const filePath = files [ i ] ! ;
186213 const fullPath = join ( cwd , filePath ) ;
187- const content = await Bun . file ( fullPath ) . text ( ) ;
214+ const content = await readFile ( fullPath , "utf-8" ) ;
188215 const lines = countLines ( content ) ;
189216
190217 // Separator takes 1 line, then content starts on next line
0 commit comments