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.
- package/dist/commands/dev.js +78 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/manifest.test.js +69 -0
- package/dist/git/auto-commit.d.ts +5 -0
- package/dist/git/auto-commit.js +15 -14
- package/dist/template/manifest.js +30 -7
- package/dist/utils/__tests__/ignore-matcher.test.d.ts +1 -0
- package/dist/utils/__tests__/ignore-matcher.test.js +188 -0
- package/dist/utils/ignore-matcher.d.ts +38 -0
- package/dist/utils/ignore-matcher.js +116 -0
- package/package.json +1 -1
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
+
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.
|
|
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;
|
package/dist/git/auto-commit.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
13
|
+
if (sets.dirs.has(entry.name))
|
|
15
14
|
continue;
|
|
16
|
-
if (
|
|
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
|
|
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