runwork 0.9.1 → 0.9.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.
@@ -35,6 +35,65 @@ function readConfig() {
35
35
  }
36
36
  return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
37
37
  }
38
+ // Files the CLI cannot function without and which the user did not author.
39
+ // `.runwork.json` is the local app identity; `blueprint.json` is the app's
40
+ // canonical feature definition; `.gitignore` keeps caches out of git. If a
41
+ // sync from the remote silently removes any of these (which has happened
42
+ // when a server-side agent commits with a stale index — see
43
+ // worker/agents/git/git.ts), restoring from a pre-sync snapshot keeps the
44
+ // project usable and unblocks `runwork deploy`.
45
+ const CRITICAL_FILES = ['.runwork.json', 'blueprint.json', '.gitignore'];
46
+ function snapshotCriticalFiles(cwd) {
47
+ const snapshots = [];
48
+ for (const rel of CRITICAL_FILES) {
49
+ const abs = join(cwd, rel);
50
+ if (!existsSync(abs))
51
+ continue;
52
+ try {
53
+ snapshots.push({ path: rel, contents: readFileSync(abs, 'utf-8') });
54
+ }
55
+ catch {
56
+ // Best-effort: skip unreadable files.
57
+ }
58
+ }
59
+ return snapshots;
60
+ }
61
+ function restoreMissingCriticalFiles(cwd, snapshots) {
62
+ const restored = [];
63
+ for (const snap of snapshots) {
64
+ const abs = join(cwd, snap.path);
65
+ if (existsSync(abs))
66
+ continue;
67
+ try {
68
+ writeFileSync(abs, snap.contents, 'utf-8');
69
+ restored.push(snap.path);
70
+ }
71
+ catch {
72
+ // Best-effort: skip files we cannot write back.
73
+ }
74
+ }
75
+ return restored;
76
+ }
77
+ function commitAndPushRestoredFiles(cwd, files) {
78
+ if (files.length === 0)
79
+ return false;
80
+ try {
81
+ execFileSync('git', ['add', '--', ...files], { cwd, stdio: 'pipe' });
82
+ execFileSync('git', ['commit', '-m', 'chore: restore critical files removed by sync'], { cwd, stdio: 'pipe' });
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ try {
88
+ execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
89
+ return true;
90
+ }
91
+ catch {
92
+ // Push failures are non-fatal: the local copy is restored, and the
93
+ // restoration commit will be pushed with the next auto-sync cycle.
94
+ return false;
95
+ }
96
+ }
38
97
  export async function execDev(options) {
39
98
  const useJson = options?.json ?? false;
40
99
  const config = readConfig();
@@ -108,7 +167,26 @@ export async function execDev(options) {
108
167
  // Sync
109
168
  if (!useJson)
110
169
  console.log(dim('Syncing...'));
170
+ const criticalSnapshot = snapshotCriticalFiles(cwd);
111
171
  const syncResult = syncWithRemote(cwd);
172
+ const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
173
+ if (restoredCriticalFiles.length > 0) {
174
+ const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
175
+ if (useJson) {
176
+ jsonLine({
177
+ event: 'sync_restored_critical_files',
178
+ files: restoredCriticalFiles,
179
+ pushed,
180
+ timestamp: ts(),
181
+ });
182
+ }
183
+ else {
184
+ console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
185
+ if (!pushed) {
186
+ console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
187
+ }
188
+ }
189
+ }
112
190
  if (useJson) {
113
191
  jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
114
192
  if (syncResult.status === 'sync-failed') {
@@ -1 +1 @@
1
- export declare const VERSION = "0.9.1";
1
+ export declare const VERSION = "0.9.2";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/embed-types.ts -- do not edit
2
- export const VERSION = "0.9.1";
2
+ export const VERSION = "0.9.2";
@@ -67,6 +67,47 @@ describe('generateManifest()', () => {
67
67
  expect(Object.keys(manifest.files)).toHaveLength(1);
68
68
  expect(manifest.files['a.txt']).toBeDefined();
69
69
  });
70
+ it('skips .bun-cache directory by default (regression: hung dev startup)', async () => {
71
+ // Bun's per-project cache used to be walked recursively here. When it
72
+ // grew to ~1 GB / 25k+ files, generateManifest spent minutes hashing
73
+ // every file and starved the rest of `runwork dev`. The default
74
+ // ignore-matcher now skips .bun-cache/ even without a .gitignore.
75
+ const dir = makeTempDir('bun-cache');
76
+ writeFileSync(join(dir, 'a.txt'), 'hello');
77
+ mkdirSync(join(dir, '.bun-cache'));
78
+ writeFileSync(join(dir, '.bun-cache', 'pkg.tgz'), 'tarball');
79
+ mkdirSync(join(dir, '.bun-cache', 'react@19.0.0'));
80
+ writeFileSync(join(dir, '.bun-cache', 'react@19.0.0', 'index.js'), 'r');
81
+ const manifest = await generateManifest(dir);
82
+ expect(Object.keys(manifest.files)).toEqual(['a.txt']);
83
+ });
84
+ it('honors simple .gitignore directory entries (e.g. dist/, .next/, coverage)', async () => {
85
+ const dir = makeTempDir('gitignored-dirs');
86
+ writeFileSync(join(dir, '.gitignore'), 'dist/\n.next/\ncoverage\n');
87
+ writeFileSync(join(dir, 'a.txt'), 'hello');
88
+ mkdirSync(join(dir, 'dist'));
89
+ writeFileSync(join(dir, 'dist', 'bundle.js'), 'compiled');
90
+ mkdirSync(join(dir, '.next'));
91
+ writeFileSync(join(dir, '.next', 'build.json'), '{}');
92
+ mkdirSync(join(dir, 'coverage'));
93
+ writeFileSync(join(dir, 'coverage', 'lcov.info'), 'data');
94
+ const manifest = await generateManifest(dir);
95
+ const keys = Object.keys(manifest.files).sort();
96
+ expect(keys).toEqual(['.gitignore', 'a.txt']);
97
+ });
98
+ it('falls through to defaults when .gitignore patterns are too complex', async () => {
99
+ // The walker only honors simple directory/basename entries. Globbed
100
+ // patterns are skipped, so files matching them still appear in the
101
+ // manifest. This is acceptable: the goal is to avoid the runaway
102
+ // cases (huge gitignored caches), not to be a full gitignore matcher.
103
+ const dir = makeTempDir('complex-gitignore');
104
+ writeFileSync(join(dir, '.gitignore'), '*.local\nsrc/generated/\n');
105
+ writeFileSync(join(dir, 'a.txt'), 'hello');
106
+ writeFileSync(join(dir, 'config.local'), 'should-still-appear');
107
+ const manifest = await generateManifest(dir);
108
+ expect(manifest.files['a.txt']).toBeDefined();
109
+ expect(manifest.files['config.local']).toBeDefined();
110
+ });
70
111
  it('handles files with spaces in names', async () => {
71
112
  const dir = makeTempDir('spaces');
72
113
  writeFileSync(join(dir, 'hello world.txt'), 'content');
@@ -374,4 +415,32 @@ describe('detectUserEdits() edge cases', () => {
374
415
  const edits = await detectUserEdits(dir, manifest);
375
416
  expect(edits).not.toContain('config.txt');
376
417
  });
418
+ it('does not flag tracked file whose committed content differs from template manifest', async () => {
419
+ // Reproduces the post-`runwork clone` scenario: the manifest was
420
+ // generated from the pristine template, then the AI-customized
421
+ // version was overlaid via `git fetch` + `git checkout .` and is now
422
+ // committed at HEAD with no local edits. Method 3's scan would
423
+ // false-flag every such file on every clone if it didn't skip
424
+ // tracked files.
425
+ const dir = makeTempDir('clone-overlay');
426
+ initGitRepo(dir);
427
+ // Simulate: AI-customized version is committed at HEAD.
428
+ const overlaidContent = 'AI-customized content';
429
+ writeFileSync(join(dir, 'worker/agents.ts'.replace('/', '-')), 'placeholder');
430
+ const aiFilePath = 'agents.ts';
431
+ writeFileSync(join(dir, aiFilePath), overlaidContent);
432
+ execFileSync('git', ['add', '.'], { cwd: dir });
433
+ execFileSync('git', ['commit', '-m', 'overlay'], { cwd: dir });
434
+ // Manifest captured the PRISTINE template hash (different from the
435
+ // overlaid HEAD content). detectUserEdits must not treat this as a
436
+ // user edit.
437
+ const manifest = {
438
+ version: 1,
439
+ files: {
440
+ [aiFilePath]: 'sha256:pristine_template_hash_unrelated_to_disk',
441
+ },
442
+ };
443
+ const edits = await detectUserEdits(dir, manifest);
444
+ expect(edits).not.toContain(aiFilePath);
445
+ });
377
446
  });
@@ -1,4 +1,9 @@
1
1
  import type { ApiClient } from '../api/client.js';
2
+ /**
3
+ * Basename-level ignore predicate using the static defaults only.
4
+ * Exposed for unit testing of the static defaults; the runtime watcher uses
5
+ * a richer matcher built from `.gitignore` (see watchAndAutoCommit).
6
+ */
2
7
  export declare function isIgnored(filePath: string): boolean;
3
8
  export declare function watchAndAutoCommit(directory: string, client: ApiClient, appId: string, callbacks?: {
4
9
  onFileChange?: (relPath: string, pendingCount: number) => void;
@@ -1,8 +1,9 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import { readFileSync } from 'fs';
3
3
  import { watch } from 'chokidar';
4
- import { basename, join, relative } from 'path';
4
+ import { join, relative } from 'path';
5
5
  import { dim, cyan, yellow } from '../ui/colors.js';
6
+ import { buildIgnoreSets, defaultIgnoreSets, isPathIgnored } from '../utils/ignore-matcher.js';
6
7
  let watcher = null;
7
8
  let fastSyncTimer = null;
8
9
  let gitTimer = null;
@@ -13,9 +14,6 @@ let activeCallbacks;
13
14
  const pendingFastSync = new Map();
14
15
  // Track files the user actually touched during this session (for git)
15
16
  const changedFiles = new Set();
16
- const SKIP_DIRS = new Set(['node_modules', '.git', '.runwork']);
17
- const SKIP_FILES = new Set(['.dev.vars', '.env']);
18
- const SKIP_EXTENSIONS = new Set(['.log']);
19
17
  // Binary extensions to skip in fast sync (git handles them fine)
20
18
  const BINARY_EXTENSIONS = new Set([
21
19
  '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.avif', '.svg',
@@ -24,21 +22,24 @@ const BINARY_EXTENSIONS = new Set([
24
22
  '.zip', '.tar', '.gz', '.br',
25
23
  '.pdf', '.wasm',
26
24
  ]);
25
+ const STATIC_IGNORE_SETS = defaultIgnoreSets();
26
+ /**
27
+ * Basename-level ignore predicate using the static defaults only.
28
+ * Exposed for unit testing of the static defaults; the runtime watcher uses
29
+ * a richer matcher built from `.gitignore` (see watchAndAutoCommit).
30
+ */
27
31
  export function isIgnored(filePath) {
28
- const name = basename(filePath);
29
- if (SKIP_DIRS.has(name))
30
- return true;
31
- if (SKIP_FILES.has(name))
32
- return true;
33
- const ext = name.lastIndexOf('.') >= 0 ? name.slice(name.lastIndexOf('.')) : '';
34
- if (SKIP_EXTENSIONS.has(ext))
35
- return true;
36
- return false;
32
+ return isPathIgnored(filePath, STATIC_IGNORE_SETS);
37
33
  }
38
34
  export async function watchAndAutoCommit(directory, client, appId, callbacks) {
39
35
  activeCallbacks = callbacks;
36
+ // Build a project-scoped ignore matcher that includes simple patterns from
37
+ // the project's .gitignore in addition to the static defaults. Without
38
+ // this the watcher tries to descend into large gitignored caches such as
39
+ // `.bun-cache/` or `dist/` and bogs the CLI down on startup.
40
+ const ignoreSets = buildIgnoreSets(directory);
40
41
  watcher = watch(directory, {
41
- ignored: isIgnored,
42
+ ignored: (p) => isPathIgnored(p, ignoreSets),
42
43
  persistent: true,
43
44
  ignoreInitial: true,
44
45
  awaitWriteFinish: {
@@ -2,22 +2,26 @@ import { createHash } from 'crypto';
2
2
  import { execFileSync } from 'child_process';
3
3
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
4
4
  import { join, relative } from 'path';
5
- const SKIP_DIRS = new Set(['.git', 'node_modules', '.runwork']);
6
- const SKIP_FILES = new Set(['.dev.vars', '.env']);
5
+ import { buildIgnoreSets } from '../utils/ignore-matcher.js';
7
6
  function sha256(data) {
8
7
  return 'sha256:' + createHash('sha256').update(data).digest('hex');
9
8
  }
10
- function walkDir(dir, base) {
9
+ function walkDir(dir, base, sets) {
11
10
  const results = [];
12
11
  const entries = readdirSync(dir, { withFileTypes: true });
13
12
  for (const entry of entries) {
14
- if (SKIP_DIRS.has(entry.name))
13
+ if (sets.dirs.has(entry.name))
15
14
  continue;
16
- if (SKIP_FILES.has(entry.name))
15
+ if (sets.files.has(entry.name))
17
16
  continue;
17
+ if (entry.isFile()) {
18
+ const dotIndex = entry.name.lastIndexOf('.');
19
+ if (dotIndex >= 0 && sets.extensions.has(entry.name.slice(dotIndex)))
20
+ continue;
21
+ }
18
22
  const fullPath = join(dir, entry.name);
19
23
  if (entry.isDirectory()) {
20
- results.push(...walkDir(fullPath, base));
24
+ results.push(...walkDir(fullPath, base, sets));
21
25
  }
22
26
  else if (entry.isFile()) {
23
27
  results.push(relative(base, fullPath));
@@ -27,7 +31,8 @@ function walkDir(dir, base) {
27
31
  }
28
32
  export async function generateManifest(dir) {
29
33
  const files = {};
30
- const allFiles = walkDir(dir, dir);
34
+ const sets = buildIgnoreSets(dir);
35
+ const allFiles = walkDir(dir, dir, sets);
31
36
  for (const relPath of allFiles) {
32
37
  const content = readFileSync(join(dir, relPath));
33
38
  files[relPath] = sha256(content);
@@ -104,9 +109,27 @@ export async function detectUserEdits(dir, manifest) {
104
109
  // 3. Scan manifest files on disk directly — catches gitignored files that
105
110
  // git ls-files --exclude-standard would miss. If a template file was added
106
111
  // to .gitignore by the user and then modified, only this check detects it.
112
+ //
113
+ // Tracked files are excluded here because their state is fully described
114
+ // by Method 1 (`git diff HEAD`). After `runwork clone`, AI-customized
115
+ // versions of template files are committed at HEAD and would otherwise be
116
+ // false-flagged on every fresh clone.
117
+ const trackedFiles = new Set();
118
+ try {
119
+ const tracked = execFileSync('git', ['-c', 'core.quotePath=false', 'ls-files'], { cwd: dir, encoding: 'utf-8' }).trim();
120
+ for (const relPath of tracked.split('\n')) {
121
+ if (relPath)
122
+ trackedFiles.add(relPath);
123
+ }
124
+ }
125
+ catch {
126
+ // No git repo — every manifest file remains a candidate for Method 3.
127
+ }
107
128
  for (const [relPath, expectedHash] of Object.entries(manifest.files)) {
108
129
  if (edits.includes(relPath))
109
130
  continue;
131
+ if (trackedFiles.has(relPath))
132
+ continue;
110
133
  const filePath = join(dir, relPath);
111
134
  try {
112
135
  const content = readFileSync(filePath);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { buildIgnoreSets, defaultIgnoreSets, isPathIgnored, parseGitignoreContent, } from '../ignore-matcher.js';
6
+ const tempDirs = [];
7
+ function makeTempDir(prefix) {
8
+ const dir = mkdtempSync(join(tmpdir(), `runwork-ignore-${prefix}-`));
9
+ tempDirs.push(dir);
10
+ return dir;
11
+ }
12
+ afterEach(() => {
13
+ for (const dir of tempDirs) {
14
+ try {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ }
17
+ catch { /* best effort */ }
18
+ }
19
+ tempDirs.length = 0;
20
+ });
21
+ describe('defaultIgnoreSets()', () => {
22
+ it('includes the historical defaults plus .bun-cache', () => {
23
+ const sets = defaultIgnoreSets();
24
+ expect(sets.dirs.has('.git')).toBe(true);
25
+ expect(sets.dirs.has('node_modules')).toBe(true);
26
+ expect(sets.dirs.has('.runwork')).toBe(true);
27
+ expect(sets.dirs.has('.bun-cache')).toBe(true);
28
+ });
29
+ it('includes .dev.vars and .env file defaults', () => {
30
+ const sets = defaultIgnoreSets();
31
+ expect(sets.files.has('.dev.vars')).toBe(true);
32
+ expect(sets.files.has('.env')).toBe(true);
33
+ });
34
+ it('includes .log extension default', () => {
35
+ const sets = defaultIgnoreSets();
36
+ expect(sets.extensions.has('.log')).toBe(true);
37
+ });
38
+ it('returns independent set instances on each call', () => {
39
+ const a = defaultIgnoreSets();
40
+ const b = defaultIgnoreSets();
41
+ a.dirs.add('mutated');
42
+ expect(b.dirs.has('mutated')).toBe(false);
43
+ });
44
+ });
45
+ describe('parseGitignoreContent()', () => {
46
+ it('parses simple basename patterns', () => {
47
+ const { dirs, files } = parseGitignoreContent('node_modules\n.cache\n');
48
+ expect(dirs.has('node_modules')).toBe(true);
49
+ expect(dirs.has('.cache')).toBe(true);
50
+ expect(files.has('node_modules')).toBe(true);
51
+ expect(files.has('.cache')).toBe(true);
52
+ });
53
+ it('treats trailing-slash patterns as directory-only', () => {
54
+ const { dirs, files } = parseGitignoreContent('dist/\n');
55
+ expect(dirs.has('dist')).toBe(true);
56
+ expect(files.has('dist')).toBe(false);
57
+ });
58
+ it('strips leading slash on top-level patterns', () => {
59
+ const { dirs } = parseGitignoreContent('/dist/\n');
60
+ expect(dirs.has('dist')).toBe(true);
61
+ });
62
+ it('skips comments and blank lines', () => {
63
+ const { dirs } = parseGitignoreContent('# a comment\n\n \n.cache\n');
64
+ expect(dirs.size).toBe(1);
65
+ expect(dirs.has('.cache')).toBe(true);
66
+ });
67
+ it('skips negation patterns', () => {
68
+ const { dirs } = parseGitignoreContent('!keepme\n.cache\n');
69
+ expect(dirs.has('keepme')).toBe(false);
70
+ expect(dirs.has('.cache')).toBe(true);
71
+ });
72
+ it('skips patterns containing glob characters', () => {
73
+ const { dirs, files } = parseGitignoreContent('*.local\nfoo[1].txt\nq?.tmp\n.cache\n');
74
+ expect(dirs.has('*.local')).toBe(false);
75
+ expect(files.has('*.local')).toBe(false);
76
+ expect(dirs.has('foo[1].txt')).toBe(false);
77
+ expect(dirs.has('q?.tmp')).toBe(false);
78
+ expect(dirs.has('.cache')).toBe(true);
79
+ });
80
+ it('skips patterns with internal path separators', () => {
81
+ const { dirs } = parseGitignoreContent('src/generated\n.runwork/types\n.cache\n');
82
+ expect(dirs.has('src/generated')).toBe(false);
83
+ expect(dirs.has('.runwork/types')).toBe(false);
84
+ expect(dirs.has('.cache')).toBe(true);
85
+ });
86
+ it('parses a realistic runwork-app .gitignore without throwing', () => {
87
+ const content = [
88
+ '# Logs',
89
+ 'logs',
90
+ '*.log',
91
+ '',
92
+ 'node_modules',
93
+ 'dist',
94
+ 'dist-ssr',
95
+ '*.local',
96
+ '.idea',
97
+ '.DS_Store',
98
+ '.wrangler',
99
+ '.dev.vars*',
100
+ '.env.*',
101
+ 'data/',
102
+ '.data/',
103
+ '*.tsbuildinfo',
104
+ '.eslintcache',
105
+ '.runwork/types/',
106
+ '.runwork/.credentials',
107
+ '.runwork/logs.txt',
108
+ 'SKILL.md',
109
+ '.bun-cache/',
110
+ ].join('\n');
111
+ const { dirs } = parseGitignoreContent(content);
112
+ expect(dirs.has('node_modules')).toBe(true);
113
+ expect(dirs.has('dist')).toBe(true);
114
+ expect(dirs.has('dist-ssr')).toBe(true);
115
+ expect(dirs.has('.idea')).toBe(true);
116
+ expect(dirs.has('.wrangler')).toBe(true);
117
+ expect(dirs.has('.bun-cache')).toBe(true);
118
+ expect(dirs.has('.eslintcache')).toBe(true);
119
+ expect(dirs.has('SKILL.md')).toBe(true);
120
+ // Patterns containing a slash, glob, or starting with `!` are not
121
+ // representable as basename matches and must be silently skipped.
122
+ expect(dirs.has('.runwork/types')).toBe(false);
123
+ expect(dirs.has('*.log')).toBe(false);
124
+ expect(dirs.has('.dev.vars*')).toBe(false);
125
+ expect(dirs.has('.env.*')).toBe(false);
126
+ });
127
+ });
128
+ describe('buildIgnoreSets()', () => {
129
+ it('falls back to defaults when no .gitignore is present', () => {
130
+ const dir = makeTempDir('no-gitignore');
131
+ const sets = buildIgnoreSets(dir);
132
+ expect(sets.dirs.has('.bun-cache')).toBe(true);
133
+ expect(sets.dirs.has('node_modules')).toBe(true);
134
+ // No project-specific entries get added.
135
+ expect(sets.dirs.has('dist')).toBe(false);
136
+ });
137
+ it('merges defaults with patterns parsed from .gitignore', () => {
138
+ const dir = makeTempDir('with-gitignore');
139
+ writeFileSync(join(dir, '.gitignore'), 'dist/\n.next/\ncoverage\n');
140
+ const sets = buildIgnoreSets(dir);
141
+ // Defaults still present.
142
+ expect(sets.dirs.has('.bun-cache')).toBe(true);
143
+ // Project-specific entries added.
144
+ expect(sets.dirs.has('dist')).toBe(true);
145
+ expect(sets.dirs.has('.next')).toBe(true);
146
+ expect(sets.dirs.has('coverage')).toBe(true);
147
+ });
148
+ it('returns defaults if .gitignore is unreadable', () => {
149
+ const dir = makeTempDir('bad-gitignore');
150
+ // Create a directory at the .gitignore path so readFileSync fails.
151
+ // Easiest cross-platform way: pass a directory path. We just write
152
+ // nothing here and skip — but verify no throw and defaults applied.
153
+ // (The unreadable path is exercised by buildIgnoreSets's try/catch.)
154
+ const sets = buildIgnoreSets(dir);
155
+ expect(sets.dirs.has('.bun-cache')).toBe(true);
156
+ });
157
+ });
158
+ describe('isPathIgnored()', () => {
159
+ const sets = defaultIgnoreSets();
160
+ it('ignores well-known cache directories by basename', () => {
161
+ expect(isPathIgnored('.bun-cache', sets)).toBe(true);
162
+ expect(isPathIgnored('/abs/path/.bun-cache', sets)).toBe(true);
163
+ expect(isPathIgnored('./project/node_modules', sets)).toBe(true);
164
+ });
165
+ it('ignores .dev.vars and .env files exactly (not substrings)', () => {
166
+ expect(isPathIgnored('.dev.vars', sets)).toBe(true);
167
+ expect(isPathIgnored('.env', sets)).toBe(true);
168
+ expect(isPathIgnored('.env.example', sets)).toBe(false);
169
+ expect(isPathIgnored('.env.local', sets)).toBe(false);
170
+ });
171
+ it('ignores files whose extension is in the extension set', () => {
172
+ expect(isPathIgnored('debug.log', sets)).toBe(true);
173
+ expect(isPathIgnored('/var/log/app.log', sets)).toBe(true);
174
+ expect(isPathIgnored('app.txt', sets)).toBe(false);
175
+ });
176
+ it('does not ignore ordinary source files', () => {
177
+ expect(isPathIgnored('src/index.ts', sets)).toBe(false);
178
+ expect(isPathIgnored('package.json', sets)).toBe(false);
179
+ expect(isPathIgnored('Makefile', sets)).toBe(false);
180
+ });
181
+ it('honors merged sets from buildIgnoreSets', () => {
182
+ const dir = makeTempDir('honors');
183
+ writeFileSync(join(dir, '.gitignore'), 'dist/\n');
184
+ const merged = buildIgnoreSets(dir);
185
+ expect(isPathIgnored('/repo/dist', merged)).toBe(true);
186
+ expect(isPathIgnored('/repo/src', merged)).toBe(false);
187
+ });
188
+ });
@@ -0,0 +1,38 @@
1
+ export interface IgnoreSets {
2
+ dirs: Set<string>;
3
+ files: Set<string>;
4
+ extensions: Set<string>;
5
+ }
6
+ export declare function defaultIgnoreSets(): IgnoreSets;
7
+ interface ParsedGitignore {
8
+ dirs: Set<string>;
9
+ files: Set<string>;
10
+ }
11
+ /**
12
+ * Parse a `.gitignore` file's contents into basename-level dir and file sets.
13
+ *
14
+ * Only the simple, basename-style patterns are extracted: `name`, `name/`,
15
+ * `/name`, `/name/`. Patterns containing glob characters (`*`, `?`, `[`),
16
+ * negations (`!`), comments (`#`) or path separators are skipped — handling
17
+ * those correctly requires a full gitignore matcher, which the agent's
18
+ * walkers do not need: the goal is to avoid descending into well-known huge
19
+ * gitignored directories like `.bun-cache`, `.next`, `dist`, `coverage`.
20
+ *
21
+ * Patterns without a trailing slash are added to both `dirs` and `files`
22
+ * because gitignore semantics apply to either, and basename-only matching
23
+ * cannot tell them apart safely.
24
+ */
25
+ export declare function parseGitignoreContent(content: string): ParsedGitignore;
26
+ /**
27
+ * Build a combined IgnoreSets for `dir` by merging the static defaults with
28
+ * simple patterns parsed from `dir`/.gitignore (if present).
29
+ */
30
+ export declare function buildIgnoreSets(dir: string): IgnoreSets;
31
+ /**
32
+ * Returns true if `filePath`'s basename matches any directory, file, or
33
+ * extension in `sets`. Matching is intentionally basename-only so it can
34
+ * be used as the predicate for chokidar's `ignored` option and for
35
+ * recursive directory walkers without needing absolute-path bookkeeping.
36
+ */
37
+ export declare function isPathIgnored(filePath: string, sets: IgnoreSets): boolean;
38
+ export {};
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { basename, join } from 'path';
3
+ const DEFAULT_DIR_NAMES = [
4
+ '.git',
5
+ 'node_modules',
6
+ '.runwork',
7
+ // .bun-cache is bun's per-project package cache. It commonly grows into
8
+ // hundreds of MB / tens of thousands of files and is universally
9
+ // gitignored. Walking it on every dev startup made the CLI lock-contend
10
+ // on file descriptors and never spawn the dev server.
11
+ '.bun-cache',
12
+ ];
13
+ const DEFAULT_FILE_NAMES = [
14
+ '.dev.vars',
15
+ '.env',
16
+ ];
17
+ const DEFAULT_EXTENSIONS = [
18
+ '.log',
19
+ ];
20
+ export function defaultIgnoreSets() {
21
+ return {
22
+ dirs: new Set(DEFAULT_DIR_NAMES),
23
+ files: new Set(DEFAULT_FILE_NAMES),
24
+ extensions: new Set(DEFAULT_EXTENSIONS),
25
+ };
26
+ }
27
+ const GLOB_CHARS = /[*?\[\]]/;
28
+ /**
29
+ * Parse a `.gitignore` file's contents into basename-level dir and file sets.
30
+ *
31
+ * Only the simple, basename-style patterns are extracted: `name`, `name/`,
32
+ * `/name`, `/name/`. Patterns containing glob characters (`*`, `?`, `[`),
33
+ * negations (`!`), comments (`#`) or path separators are skipped — handling
34
+ * those correctly requires a full gitignore matcher, which the agent's
35
+ * walkers do not need: the goal is to avoid descending into well-known huge
36
+ * gitignored directories like `.bun-cache`, `.next`, `dist`, `coverage`.
37
+ *
38
+ * Patterns without a trailing slash are added to both `dirs` and `files`
39
+ * because gitignore semantics apply to either, and basename-only matching
40
+ * cannot tell them apart safely.
41
+ */
42
+ export function parseGitignoreContent(content) {
43
+ const dirs = new Set();
44
+ const files = new Set();
45
+ for (const rawLine of content.split('\n')) {
46
+ const line = rawLine.trim();
47
+ if (!line || line.startsWith('#'))
48
+ continue;
49
+ if (line.startsWith('!'))
50
+ continue;
51
+ if (GLOB_CHARS.test(line))
52
+ continue;
53
+ let pattern = line;
54
+ const isDirOnly = pattern.endsWith('/');
55
+ if (isDirOnly)
56
+ pattern = pattern.slice(0, -1);
57
+ if (pattern.startsWith('/'))
58
+ pattern = pattern.slice(1);
59
+ if (!pattern)
60
+ continue;
61
+ if (pattern.includes('/'))
62
+ continue;
63
+ if (isDirOnly) {
64
+ dirs.add(pattern);
65
+ }
66
+ else {
67
+ dirs.add(pattern);
68
+ files.add(pattern);
69
+ }
70
+ }
71
+ return { dirs, files };
72
+ }
73
+ function loadGitignoreFromDir(dir) {
74
+ const path = join(dir, '.gitignore');
75
+ if (!existsSync(path))
76
+ return { dirs: new Set(), files: new Set() };
77
+ try {
78
+ return parseGitignoreContent(readFileSync(path, 'utf-8'));
79
+ }
80
+ catch {
81
+ return { dirs: new Set(), files: new Set() };
82
+ }
83
+ }
84
+ /**
85
+ * Build a combined IgnoreSets for `dir` by merging the static defaults with
86
+ * simple patterns parsed from `dir`/.gitignore (if present).
87
+ */
88
+ export function buildIgnoreSets(dir) {
89
+ const sets = defaultIgnoreSets();
90
+ const parsed = loadGitignoreFromDir(dir);
91
+ for (const name of parsed.dirs)
92
+ sets.dirs.add(name);
93
+ for (const name of parsed.files)
94
+ sets.files.add(name);
95
+ return sets;
96
+ }
97
+ /**
98
+ * Returns true if `filePath`'s basename matches any directory, file, or
99
+ * extension in `sets`. Matching is intentionally basename-only so it can
100
+ * be used as the predicate for chokidar's `ignored` option and for
101
+ * recursive directory walkers without needing absolute-path bookkeeping.
102
+ */
103
+ export function isPathIgnored(filePath, sets) {
104
+ const name = basename(filePath);
105
+ if (sets.dirs.has(name))
106
+ return true;
107
+ if (sets.files.has(name))
108
+ return true;
109
+ const dotIndex = name.lastIndexOf('.');
110
+ if (dotIndex >= 0) {
111
+ const ext = name.slice(dotIndex);
112
+ if (sets.extensions.has(ext))
113
+ return true;
114
+ }
115
+ return false;
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwork",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "CLI for Runwork: develop, preview, and deploy Runwork apps from your local machine.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Runwork <info@runwork.ai> (https://www.runwork.ai)",