gdrive-syncer 2.1.0 → 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.0",
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,16 +781,16 @@ 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.');
648
788
  return;
649
789
  }
650
790
 
651
- // Show diff
791
+ // Show diff (for upload: local is new (+), drive is old (-))
792
+ // (for download: drive is new (+), local is old (-))
793
+ const isUpload = action === 'upload';
652
794
  console.log('');
653
795
  log.info(color.bold('Changes Preview:'));
654
796
  console.log('');
@@ -656,15 +798,18 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
656
798
  for (const filename of changes.modified) {
657
799
  const localFile = path.join(envDir, filename);
658
800
  const driveFile = path.join(tempDir, filename);
659
- const diffResult = shell.exec(`diff -u "${localFile}" "${driveFile}"`, { silent: true });
801
+ // Swap order based on action: upload shows drive->local, download shows local->drive
802
+ const diffResult = isUpload
803
+ ? shell.exec(`diff -u "${driveFile}" "${localFile}"`, { silent: true })
804
+ : shell.exec(`diff -u "${localFile}" "${driveFile}"`, { silent: true });
660
805
 
661
806
  if (diffResult.stdout) {
662
807
  const lines = diffResult.stdout.split('\n');
663
808
  lines.forEach((line) => {
664
809
  if (line.startsWith('---')) {
665
- console.log(color.cyan(`--- ${filename} (Local)`));
810
+ console.log(color.cyan(`--- ${filename} (${isUpload ? 'Drive' : 'Local'})`));
666
811
  } else if (line.startsWith('+++')) {
667
- console.log(color.cyan(`+++ ${filename} (Drive)`));
812
+ console.log(color.cyan(`+++ ${filename} (${isUpload ? 'Local' : 'Drive'})`));
668
813
  } else if (line.startsWith('@@')) {
669
814
  console.log(color.cyan(line));
670
815
  } else if (line.startsWith('-')) {
@@ -682,14 +827,14 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
682
827
  for (const filename of changes.localOnly) {
683
828
  console.log(color.cyan(`--- /dev/null`));
684
829
  console.log(color.cyan(`+++ ${filename} (Local only)`));
685
- console.log(color.yellow(` [New local file - will be uploaded]`));
830
+ console.log(color.green(` [New local file - will be uploaded]`));
686
831
  console.log('');
687
832
  }
688
833
 
689
834
  for (const filename of changes.driveOnly) {
690
835
  console.log(color.cyan(`--- /dev/null`));
691
836
  console.log(color.cyan(`+++ ${filename} (Drive only)`));
692
- console.log(color.yellow(` [New Drive file - will be downloaded]`));
837
+ console.log(color.green(` [New Drive file - will be downloaded]`));
693
838
  console.log('');
694
839
  }
695
840
 
@@ -723,7 +868,7 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
723
868
 
724
869
  if (action === 'download') {
725
870
  // Create backup first
726
- const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern));
871
+ const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
727
872
  if (existingFiles.length > 0) {
728
873
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
729
874
  const backupSubdir = path.join(backupPath, `${name}_${timestamp}`);
@@ -751,7 +896,6 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
751
896
  }
752
897
 
753
898
  downloadSpinner.stop(color.green(`Downloaded ${downloaded} file(s)`));
754
-
755
899
  } else if (action === 'upload') {
756
900
  const uploadSpinner = spinner();
757
901
  uploadSpinner.start('Uploading files...');
@@ -762,13 +906,17 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
762
906
  for (const filename of changes.modified) {
763
907
  const driveFile = driveFiles.find((f) => f.name === filename);
764
908
  if (driveFile) {
765
- 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
+ });
766
912
  replaced++;
767
913
  }
768
914
  }
769
915
 
770
916
  for (const filename of changes.localOnly) {
771
- 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
+ });
772
920
  uploaded++;
773
921
  }
774
922
 
@@ -786,4 +934,7 @@ module.exports = {
786
934
  envRun,
787
935
  envShow,
788
936
  envRemove,
937
+ // Exported for testing
938
+ globToRegex,
939
+ matchPattern,
789
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
+ });