gdrive-syncer 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Readme.md CHANGED
@@ -132,10 +132,44 @@ Global config at `~/.gdrive_syncer/env-sync.json` uses absolute paths:
132
132
  | `name` | Unique name for the sync |
133
133
  | `folderId` | Google Drive folder ID |
134
134
  | `localDir` | Local directory path (relative for local config, absolute for global) |
135
- | `pattern` | File pattern to sync (e.g., `.env.*`, `*`, `*.json`) |
135
+ | `pattern` | File pattern to sync (see [Pattern Syntax](#pattern-syntax)) |
136
+ | `ignore` | Optional pattern to exclude files (see [Pattern Syntax](#pattern-syntax)) |
136
137
  | `backupDir` (per-sync) | Override backup directory for this sync only |
137
138
  | `backupDir` (root) | Default backup directory (local: `gdrive-backups`, global: `~/gdrive-backups`) |
138
139
 
140
+ ### Pattern Syntax
141
+
142
+ Patterns support glob-style matching with the following features:
143
+
144
+ | Pattern | Description | Example |
145
+ |---------|-------------|---------|
146
+ | `*` | Matches any characters | `.env.*` matches `.env.development`, `.env.production` |
147
+ | `?` | Matches single character | `file?.txt` matches `file1.txt`, `fileA.txt` |
148
+ | `[abc]` | Matches specific characters | `file[123].txt` matches `file1.txt`, `file2.txt` |
149
+ | `[a-z]` | Matches character range | `[0-9]` matches any digit |
150
+ | `[!abc]` | Negated character class | `[!.]` matches any non-dot character |
151
+ | `regex:` | Raw regex (advanced) | `regex:\.env\.[^.]+$` for precise matching |
152
+
153
+ #### Examples
154
+
155
+ ```json
156
+ // Match all .env files
157
+ "pattern": ".env.*"
158
+
159
+ // Match .env files but exclude .template files
160
+ "pattern": ".env.*",
161
+ "ignore": "*.template"
162
+
163
+ // Match .env files without additional extensions (no dots after env name)
164
+ "pattern": ".env.[!.]*"
165
+
166
+ // Match using raw regex (excludes .env.xx.template)
167
+ "pattern": "regex:\\.env\\.[^.]+$"
168
+
169
+ // Match specific file types
170
+ "pattern": "*.[jt]s" // matches .js and .ts files
171
+ ```
172
+
139
173
  ### Features
140
174
 
141
175
  - **Multiple sync folders** - Configure multiple sync pairs in one config
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "gdrive-syncer",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Google Drive Syncer",
5
5
  "main": "./index.js",
6
6
  "bin": "./run.js",
7
7
  "scripts": {
8
- "test": "echo \"No test specified\""
8
+ "test": "jest"
9
9
  },
10
10
  "keywords": [
11
11
  "Google",
@@ -25,7 +25,9 @@
25
25
  "picocolors": "^1.1.1",
26
26
  "shelljs": "^0.8.5"
27
27
  },
28
- "devDependencies": {},
28
+ "devDependencies": {
29
+ "jest": "^30.2.0"
30
+ },
29
31
  "np": {
30
32
  "yarn": false,
31
33
  "2fa": false
package/src/envSync.js CHANGED
@@ -184,7 +184,7 @@ const envInit = async () => {
184
184
  }
185
185
 
186
186
  const pattern = await text({
187
- message: 'File pattern to sync (e.g., ".env.*", "*")',
187
+ message: 'File pattern to sync (* any, ? single char, [abc] class, [!x] negated, regex:... raw)',
188
188
  placeholder: '.env.*',
189
189
  defaultValue: '.env.*',
190
190
  });
@@ -194,6 +194,16 @@ const envInit = async () => {
194
194
  return;
195
195
  }
196
196
 
197
+ const ignore = await text({
198
+ message: 'Ignore pattern (leave empty for none)',
199
+ placeholder: 'e.g., *.template, *.backup',
200
+ });
201
+
202
+ if (isCancel(ignore)) {
203
+ cancel('Init cancelled.');
204
+ return;
205
+ }
206
+
197
207
  // For global config, ask for absolute backup path
198
208
  let syncBackupDir;
199
209
  if (saveLocation === 'global') {
@@ -222,6 +232,11 @@ const envInit = async () => {
222
232
  pattern: pattern.trim(),
223
233
  };
224
234
 
235
+ // Only add ignore if specified
236
+ if (ignore && ignore.trim()) {
237
+ newSync.ignore = ignore.trim();
238
+ }
239
+
225
240
  // Only add backupDir if specified
226
241
  if (syncBackupDir && syncBackupDir.trim()) {
227
242
  newSync.backupDir = cleanPath(syncBackupDir);
@@ -231,8 +246,11 @@ const envInit = async () => {
231
246
  saveConfig(configPath, config);
232
247
 
233
248
  log.success(`Config saved: ${configPath}`);
249
+ const ignoreInfo = ignore && ignore.trim() ? `\n${color.cyan('Ignore:')} ${ignore}` : '';
234
250
  note(
235
- `${color.cyan('Name:')} ${name}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan('Local Dir:')} ${destDir}\n${color.cyan('Pattern:')} ${pattern}`,
251
+ `${color.cyan('Name:')} ${name}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan(
252
+ 'Local Dir:'
253
+ )} ${destDir}\n${color.cyan('Pattern:')} ${pattern}${ignoreInfo}`,
236
254
  'New Sync Added'
237
255
  );
238
256
  } catch (e) {
@@ -278,8 +296,16 @@ const envRemove = async () => {
278
296
  configType = await select({
279
297
  message: 'Which config?',
280
298
  options: [
281
- { value: 'local', label: 'Local', hint: `${localSyncs.length} sync(s) in .gdrive-sync.json` },
282
- { value: 'global', label: 'Global', hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/` },
299
+ {
300
+ value: 'local',
301
+ label: 'Local',
302
+ hint: `${localSyncs.length} sync(s) in .gdrive-sync.json`,
303
+ },
304
+ {
305
+ value: 'global',
306
+ label: 'Global',
307
+ hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/`,
308
+ },
283
309
  ],
284
310
  });
285
311
 
@@ -353,7 +379,12 @@ const envShow = async () => {
353
379
  const lines = config.syncs
354
380
  .map((s) => {
355
381
  const backupInfo = s.backupDir ? color.yellow(` [backup: ${s.backupDir}]`) : '';
356
- return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(20)} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(`(${s.pattern})`)}${backupInfo}`;
382
+ const ignoreInfo = s.ignore ? color.red(` [ignore: ${s.ignore}]`) : '';
383
+ return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(
384
+ 20
385
+ )} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(
386
+ `(${s.pattern})`
387
+ )}${ignoreInfo}${backupInfo}`;
357
388
  })
358
389
  .join('\n');
359
390
 
@@ -373,7 +404,12 @@ const envShow = async () => {
373
404
  const lines = config.syncs
374
405
  .map((s) => {
375
406
  const backupInfo = s.backupDir ? color.yellow(` [backup: ${s.backupDir}]`) : '';
376
- return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(20)} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(`(${s.pattern})`)}${backupInfo}`;
407
+ const ignoreInfo = s.ignore ? color.red(` [ignore: ${s.ignore}]`) : '';
408
+ return `${color.cyan(s.name.padEnd(15))} ${color.dim('|')} ${s.localDir.padEnd(
409
+ 20
410
+ )} ${color.dim('↔')} ${s.folderId.slice(0, 12)}... ${color.dim(
411
+ `(${s.pattern})`
412
+ )}${ignoreInfo}${backupInfo}`;
377
413
  })
378
414
  .join('\n');
379
415
 
@@ -397,12 +433,93 @@ const envShow = async () => {
397
433
  };
398
434
 
399
435
  /**
400
- * Match files against pattern
436
+ * Convert a glob pattern to a regex pattern
437
+ * Supports:
438
+ * - * (matches any characters)
439
+ * - ? (matches single character)
440
+ * - [abc] (character class)
441
+ * - [!abc] or [^abc] (negated character class)
442
+ * - regex: prefix for raw regex patterns
401
443
  */
402
- const matchPattern = (filename, pattern) => {
403
- if (pattern === '*') return true;
404
- const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
405
- return regex.test(filename);
444
+ const globToRegex = (pattern) => {
445
+ // Raw regex mode: prefix with "regex:"
446
+ if (pattern.startsWith('regex:')) {
447
+ return pattern.slice(6);
448
+ }
449
+
450
+ let result = '';
451
+ let i = 0;
452
+ while (i < pattern.length) {
453
+ const char = pattern[i];
454
+
455
+ if (char === '*') {
456
+ result += '.*';
457
+ } else if (char === '?') {
458
+ result += '.';
459
+ } else if (char === '[') {
460
+ // Find the closing bracket
461
+ let j = i + 1;
462
+ let charClass = '[';
463
+
464
+ // Handle negation [! or [^
465
+ if (pattern[j] === '!' || pattern[j] === '^') {
466
+ charClass += '^';
467
+ j++;
468
+ }
469
+
470
+ // Find closing bracket
471
+ while (j < pattern.length && pattern[j] !== ']') {
472
+ // Escape special regex chars inside character class (except - and ^)
473
+ if (pattern[j] === '\\' && j + 1 < pattern.length) {
474
+ charClass += '\\' + pattern[j + 1];
475
+ j += 2;
476
+ } else {
477
+ charClass += pattern[j];
478
+ j++;
479
+ }
480
+ }
481
+
482
+ if (j < pattern.length) {
483
+ charClass += ']';
484
+ result += charClass;
485
+ i = j;
486
+ } else {
487
+ // No closing bracket found, treat [ as literal
488
+ result += '\\[';
489
+ }
490
+ } else if ('.+^${}|()\\'.includes(char)) {
491
+ // Escape special regex characters
492
+ result += '\\' + char;
493
+ } else {
494
+ result += char;
495
+ }
496
+ i++;
497
+ }
498
+
499
+ return result;
500
+ };
501
+
502
+ /**
503
+ * Match files against pattern, with optional ignore pattern
504
+ * @param {string} filename - The filename to test
505
+ * @param {string} pattern - Glob pattern or regex: prefixed pattern
506
+ * @param {string} [ignore] - Optional ignore pattern
507
+ * @returns {boolean} - True if filename matches pattern and doesn't match ignore
508
+ */
509
+ const matchPattern = (filename, pattern, ignore) => {
510
+ if (pattern === '*' && !ignore) return true;
511
+
512
+ const regexPattern = globToRegex(pattern);
513
+ const regex = new RegExp('^' + regexPattern + '$');
514
+ if (!regex.test(filename)) return false;
515
+
516
+ if (ignore) {
517
+ const ignoreRegexPattern = globToRegex(ignore);
518
+ const ignoreRegex = new RegExp('^' + ignoreRegexPattern + '$');
519
+ if (ignoreRegex.test(filename)) return false;
520
+ }
521
+
522
+ return true;
406
523
  };
407
524
 
408
525
  /**
@@ -445,11 +562,23 @@ const envRun = async (presetAction, presetConfigType) => {
445
562
 
446
563
  if (localConfig) {
447
564
  const lc = loadConfig(localConfig.configPath);
448
- localSyncs = (lc.syncs || []).map((s) => ({ ...s, _configType: 'local', _configPath: localConfig.configPath, _projectRoot: localConfig.projectRoot, _globalBackupDir: lc.backupDir }));
565
+ localSyncs = (lc.syncs || []).map((s) => ({
566
+ ...s,
567
+ _configType: 'local',
568
+ _configPath: localConfig.configPath,
569
+ _projectRoot: localConfig.projectRoot,
570
+ _globalBackupDir: lc.backupDir,
571
+ }));
449
572
  }
450
573
  if (globalConfig) {
451
574
  const gc = loadConfig(globalConfig.configPath);
452
- globalSyncs = (gc.syncs || []).map((s) => ({ ...s, _configType: 'global', _configPath: globalConfig.configPath, _projectRoot: globalConfig.projectRoot, _globalBackupDir: gc.backupDir }));
575
+ globalSyncs = (gc.syncs || []).map((s) => ({
576
+ ...s,
577
+ _configType: 'global',
578
+ _configPath: globalConfig.configPath,
579
+ _projectRoot: globalConfig.projectRoot,
580
+ _globalBackupDir: gc.backupDir,
581
+ }));
453
582
  }
454
583
 
455
584
  if (localSyncs.length === 0 && globalSyncs.length === 0) {
@@ -477,8 +606,16 @@ const envRun = async (presetAction, presetConfigType) => {
477
606
  configType = await select({
478
607
  message: 'Which config?',
479
608
  options: [
480
- { value: 'local', label: 'Local', hint: `${localSyncs.length} sync(s) in .gdrive-sync.json` },
481
- { value: 'global', label: 'Global', hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/` },
609
+ {
610
+ value: 'local',
611
+ label: 'Local',
612
+ hint: `${localSyncs.length} sync(s) in .gdrive-sync.json`,
613
+ },
614
+ {
615
+ value: 'global',
616
+ label: 'Global',
617
+ hint: `${globalSyncs.length} sync(s) in ~/.gdrive_syncer/`,
618
+ },
482
619
  ],
483
620
  });
484
621
 
@@ -533,9 +670,10 @@ const envRun = async (presetAction, presetConfigType) => {
533
670
  for (const syncConfig of selectedSyncs) {
534
671
  const projectRoot = syncConfig._configType === 'global' ? '' : syncConfig._projectRoot;
535
672
  const globalBackupDir = syncConfig._globalBackupDir || 'gdrive-backups';
536
- const backupPath = syncConfig._configType === 'global'
537
- ? (syncConfig.backupDir || path.join(os.homedir(), 'gdrive-backups'))
538
- : path.join(projectRoot, syncConfig.backupDir || globalBackupDir);
673
+ const backupPath =
674
+ syncConfig._configType === 'global'
675
+ ? syncConfig.backupDir || path.join(os.homedir(), 'gdrive-backups')
676
+ : path.join(projectRoot, syncConfig.backupDir || globalBackupDir);
539
677
 
540
678
  await runSyncOperation(syncConfig, action, projectRoot, backupPath, syncConfig._configType);
541
679
  }
@@ -548,14 +686,19 @@ const envRun = async (presetAction, presetConfigType) => {
548
686
  * Run a single sync operation
549
687
  */
550
688
  const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, configType) => {
551
- const { folderId, pattern, name } = syncConfig;
689
+ const { folderId, pattern, name, ignore } = syncConfig;
552
690
  // Strip quotes from paths (in case manually added)
553
691
  const localDir = syncConfig.localDir.replace(/^['"]|['"]$/g, '');
554
692
  // For global config, localDir is absolute; for local, it's relative
555
693
  const envDir = configType === 'global' ? localDir : path.join(projectRoot, localDir);
556
694
 
695
+ const ignoreInfo = ignore ? `\n${color.cyan('Ignore:')} ${ignore}` : '';
557
696
  note(
558
- `${color.cyan('Sync:')} ${name} ${color.dim(`[${configType}]`)}\n${color.cyan('Folder ID:')} ${folderId}\n${color.cyan('Local Dir:')} ${envDir}\n${color.cyan('Pattern:')} ${pattern}\n${color.cyan('Backup Dir:')} ${backupPath}`,
697
+ `${color.cyan('Sync:')} ${name} ${color.dim(`[${configType}]`)}\n${color.cyan(
698
+ 'Folder ID:'
699
+ )} ${folderId}\n${color.cyan('Local Dir:')} ${envDir}\n${color.cyan(
700
+ 'Pattern:'
701
+ )} ${pattern}${ignoreInfo}\n${color.cyan('Backup Dir:')} ${backupPath}`,
559
702
  'Processing'
560
703
  );
561
704
 
@@ -568,10 +711,9 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
568
711
  const s = spinner();
569
712
  s.start('Fetching files from Google Drive...');
570
713
 
571
- const listResult = shell.exec(
572
- `gdrive list -q "'${folderId}' in parents" --no-header`,
573
- { silent: true }
574
- );
714
+ const listResult = shell.exec(`gdrive list -q "'${folderId}' in parents" --no-header`, {
715
+ silent: true,
716
+ });
575
717
 
576
718
  if (listResult.code !== 0) {
577
719
  s.stop(color.red('Failed to fetch from Drive'));
@@ -590,7 +732,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
590
732
  if (parts.length >= 2) {
591
733
  const fileId = parts[0].trim();
592
734
  const fileName = parts[1].trim();
593
- if (matchPattern(fileName, pattern)) {
735
+ if (matchPattern(fileName, pattern, ignore)) {
594
736
  driveFiles.push({ id: fileId, name: fileName });
595
737
  }
596
738
  }
@@ -606,7 +748,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
606
748
 
607
749
  // Get local files
608
750
  fs.ensureDirSync(envDir);
609
- const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern));
751
+ const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
610
752
 
611
753
  // Compare files
612
754
  const changes = {
@@ -639,9 +781,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
639
781
  }
640
782
 
641
783
  const hasChanges =
642
- changes.modified.length > 0 ||
643
- changes.localOnly.length > 0 ||
644
- changes.driveOnly.length > 0;
784
+ changes.modified.length > 0 || changes.localOnly.length > 0 || changes.driveOnly.length > 0;
645
785
 
646
786
  if (!hasChanges) {
647
787
  log.success('No changes detected. Local and Drive are in sync.');
@@ -728,7 +868,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
728
868
 
729
869
  if (action === 'download') {
730
870
  // Create backup first
731
- const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern));
871
+ const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
732
872
  if (existingFiles.length > 0) {
733
873
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
734
874
  const backupSubdir = path.join(backupPath, `${name}_${timestamp}`);
@@ -756,7 +896,6 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
756
896
  }
757
897
 
758
898
  downloadSpinner.stop(color.green(`Downloaded ${downloaded} file(s)`));
759
-
760
899
  } else if (action === 'upload') {
761
900
  const uploadSpinner = spinner();
762
901
  uploadSpinner.start('Uploading files...');
@@ -767,13 +906,17 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
767
906
  for (const filename of changes.modified) {
768
907
  const driveFile = driveFiles.find((f) => f.name === filename);
769
908
  if (driveFile) {
770
- shell.exec(`gdrive update "${driveFile.id}" "${path.join(envDir, filename)}"`, { silent: true });
909
+ shell.exec(`gdrive update "${driveFile.id}" "${path.join(envDir, filename)}"`, {
910
+ silent: true,
911
+ });
771
912
  replaced++;
772
913
  }
773
914
  }
774
915
 
775
916
  for (const filename of changes.localOnly) {
776
- shell.exec(`gdrive upload "${path.join(envDir, filename)}" --parent "${folderId}"`, { silent: true });
917
+ shell.exec(`gdrive upload "${path.join(envDir, filename)}" --parent "${folderId}"`, {
918
+ silent: true,
919
+ });
777
920
  uploaded++;
778
921
  }
779
922
 
@@ -791,4 +934,7 @@ module.exports = {
791
934
  envRun,
792
935
  envShow,
793
936
  envRemove,
937
+ // Exported for testing
938
+ globToRegex,
939
+ matchPattern,
794
940
  };
@@ -0,0 +1,189 @@
1
+ const { globToRegex, matchPattern } = require('./envSync');
2
+
3
+ describe('globToRegex', () => {
4
+ describe('basic glob patterns (backward compatibility)', () => {
5
+ test('* matches any characters', () => {
6
+ expect(globToRegex('*')).toBe('.*');
7
+ expect(globToRegex('.env.*')).toBe('\\.env\\..*');
8
+ expect(globToRegex('*.js')).toBe('.*\\.js');
9
+ });
10
+
11
+ test('dots are escaped', () => {
12
+ expect(globToRegex('.env')).toBe('\\.env');
13
+ expect(globToRegex('file.txt')).toBe('file\\.txt');
14
+ });
15
+
16
+ test('multiple wildcards', () => {
17
+ expect(globToRegex('*.*')).toBe('.*\\..*');
18
+ });
19
+ });
20
+
21
+ describe('? single character wildcard', () => {
22
+ test('? matches single character', () => {
23
+ expect(globToRegex('file?.txt')).toBe('file.\\.txt');
24
+ expect(globToRegex('???.js')).toBe('...\\.js');
25
+ });
26
+
27
+ test('combined with *', () => {
28
+ expect(globToRegex('file?.*')).toBe('file.\\..*');
29
+ });
30
+ });
31
+
32
+ describe('character classes [abc]', () => {
33
+ test('basic character class', () => {
34
+ expect(globToRegex('[abc]')).toBe('[abc]');
35
+ expect(globToRegex('file[123].txt')).toBe('file[123]\\.txt');
36
+ });
37
+
38
+ test('character range', () => {
39
+ expect(globToRegex('[a-z]')).toBe('[a-z]');
40
+ expect(globToRegex('[0-9]')).toBe('[0-9]');
41
+ });
42
+
43
+ test('negated character class with !', () => {
44
+ expect(globToRegex('[!abc]')).toBe('[^abc]');
45
+ expect(globToRegex('[!0-9]')).toBe('[^0-9]');
46
+ });
47
+
48
+ test('negated character class with ^', () => {
49
+ expect(globToRegex('[^abc]')).toBe('[^abc]');
50
+ });
51
+
52
+ test('character class with special chars', () => {
53
+ expect(globToRegex('[.-]')).toBe('[.-]');
54
+ });
55
+
56
+ test('unclosed bracket treated as literal', () => {
57
+ expect(globToRegex('[abc')).toBe('\\[abc');
58
+ });
59
+ });
60
+
61
+ describe('regex: prefix for raw regex', () => {
62
+ test('passes through raw regex unchanged', () => {
63
+ expect(globToRegex('regex:\\.env\\.[^.]+$')).toBe('\\.env\\.[^.]+$');
64
+ expect(globToRegex('regex:.*\\.js$')).toBe('.*\\.js$');
65
+ });
66
+
67
+ test('complex regex patterns', () => {
68
+ expect(globToRegex('regex:^(foo|bar)\\.(js|ts)$')).toBe('^(foo|bar)\\.(js|ts)$');
69
+ });
70
+ });
71
+
72
+ describe('special regex characters are escaped', () => {
73
+ test('escapes + ^ $ { } | ( ) \\', () => {
74
+ expect(globToRegex('file+name')).toBe('file\\+name');
75
+ expect(globToRegex('file^name')).toBe('file\\^name');
76
+ expect(globToRegex('file$name')).toBe('file\\$name');
77
+ expect(globToRegex('file(1)')).toBe('file\\(1\\)');
78
+ expect(globToRegex('file{1}')).toBe('file\\{1\\}');
79
+ expect(globToRegex('a|b')).toBe('a\\|b');
80
+ });
81
+ });
82
+ });
83
+
84
+ describe('matchPattern', () => {
85
+ describe('backward compatibility - basic glob', () => {
86
+ test('* matches everything', () => {
87
+ expect(matchPattern('anything.txt', '*')).toBe(true);
88
+ expect(matchPattern('.env.development', '*')).toBe(true);
89
+ });
90
+
91
+ test('.env.* pattern', () => {
92
+ expect(matchPattern('.env.development', '.env.*')).toBe(true);
93
+ expect(matchPattern('.env.production', '.env.*')).toBe(true);
94
+ expect(matchPattern('.env.local', '.env.*')).toBe(true);
95
+ expect(matchPattern('.env.development.template', '.env.*')).toBe(true);
96
+ expect(matchPattern('.envrc', '.env.*')).toBe(false);
97
+ expect(matchPattern('config.env.js', '.env.*')).toBe(false);
98
+ });
99
+
100
+ test('*.js pattern', () => {
101
+ expect(matchPattern('index.js', '*.js')).toBe(true);
102
+ expect(matchPattern('app.test.js', '*.js')).toBe(true);
103
+ expect(matchPattern('index.ts', '*.js')).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('? single character', () => {
108
+ test('matches single character', () => {
109
+ expect(matchPattern('file1.txt', 'file?.txt')).toBe(true);
110
+ expect(matchPattern('fileA.txt', 'file?.txt')).toBe(true);
111
+ expect(matchPattern('file12.txt', 'file?.txt')).toBe(false);
112
+ expect(matchPattern('file.txt', 'file?.txt')).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe('character classes', () => {
117
+ test('[abc] matches specific characters', () => {
118
+ expect(matchPattern('filea.txt', 'file[abc].txt')).toBe(true);
119
+ expect(matchPattern('fileb.txt', 'file[abc].txt')).toBe(true);
120
+ expect(matchPattern('filed.txt', 'file[abc].txt')).toBe(false);
121
+ });
122
+
123
+ test('[0-9] matches digit range', () => {
124
+ expect(matchPattern('file5.txt', 'file[0-9].txt')).toBe(true);
125
+ expect(matchPattern('filea.txt', 'file[0-9].txt')).toBe(false);
126
+ });
127
+
128
+ test('[!abc] negated class', () => {
129
+ expect(matchPattern('filed.txt', 'file[!abc].txt')).toBe(true);
130
+ expect(matchPattern('filea.txt', 'file[!abc].txt')).toBe(false);
131
+ });
132
+
133
+ test('[!.] matches non-dot (for excluding .template)', () => {
134
+ expect(matchPattern('.env.development', '.env.[!.]*')).toBe(true);
135
+ expect(matchPattern('.env.production', '.env.[!.]*')).toBe(true);
136
+ // This won't fully exclude .template because [!.]* only affects first char
137
+ // Use ignore pattern instead for this use case
138
+ });
139
+ });
140
+
141
+ describe('regex: prefix', () => {
142
+ test('raw regex for complex patterns', () => {
143
+ expect(matchPattern('.env.development', 'regex:\\.env\\.[^.]+$')).toBe(true);
144
+ expect(matchPattern('.env.production', 'regex:\\.env\\.[^.]+$')).toBe(true);
145
+ expect(matchPattern('.env.development.template', 'regex:\\.env\\.[^.]+$')).toBe(false);
146
+ });
147
+
148
+ test('regex with alternation', () => {
149
+ expect(matchPattern('app.js', 'regex:.*\\.(js|ts)$')).toBe(true);
150
+ expect(matchPattern('app.ts', 'regex:.*\\.(js|ts)$')).toBe(true);
151
+ expect(matchPattern('app.css', 'regex:.*\\.(js|ts)$')).toBe(false);
152
+ });
153
+ });
154
+
155
+ describe('ignore pattern', () => {
156
+ test('excludes files matching ignore', () => {
157
+ expect(matchPattern('.env.development', '.env.*', '*.template')).toBe(true);
158
+ expect(matchPattern('.env.development.template', '.env.*', '*.template')).toBe(false);
159
+ expect(matchPattern('.env.production.template', '.env.*', '*.template')).toBe(false);
160
+ });
161
+
162
+ test('ignore with character class', () => {
163
+ expect(matchPattern('file1.txt', '*.txt', 'file[0-9].txt')).toBe(false);
164
+ expect(matchPattern('fileA.txt', '*.txt', 'file[0-9].txt')).toBe(true);
165
+ });
166
+
167
+ test('ignore with regex:', () => {
168
+ expect(matchPattern('.env.dev', '.env.*', 'regex:.*\\.template$')).toBe(true);
169
+ expect(matchPattern('.env.dev.template', '.env.*', 'regex:.*\\.template$')).toBe(false);
170
+ });
171
+ });
172
+
173
+ describe('edge cases', () => {
174
+ test('empty pattern', () => {
175
+ expect(matchPattern('', '')).toBe(true);
176
+ expect(matchPattern('file.txt', '')).toBe(false);
177
+ });
178
+
179
+ test('exact match', () => {
180
+ expect(matchPattern('.env', '.env')).toBe(true);
181
+ expect(matchPattern('.env.local', '.env')).toBe(false);
182
+ });
183
+
184
+ test('pattern with no wildcards', () => {
185
+ expect(matchPattern('config.json', 'config.json')).toBe(true);
186
+ expect(matchPattern('config.yaml', 'config.json')).toBe(false);
187
+ });
188
+ });
189
+ });