Skip to content

Commit cb77da8

Browse files
l2yshovladfrangu
andauthored
feat: add .actorignore support (#1038)
Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
1 parent b6c958f commit cb77da8

4 files changed

Lines changed: 560 additions & 15 deletions

File tree

src/commands/actors/push.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
4848
`Deploys Actor to Apify platform using settings from '${LOCAL_CONFIG_PATH}'.\n` +
4949
`Files under '${MAX_MULTIFILE_BYTES / 1024 ** 2}' MB upload as "Multiple source files"; ` +
5050
`larger projects upload as ZIP file.\n` +
51+
`Files matched by .gitignore and .actorignore are excluded. ` +
52+
`Use negation patterns (e.g. !dist/) in .actorignore to force-include git-ignored files.\n` +
5153
`Use --force to override newer remote versions.`;
5254

5355
static override enableJsonFlag = true;

src/lib/utils.ts

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { Mime } from 'mime';
2222
import otherMimes from 'mime/types/other.js';
2323
import standardMimes from 'mime/types/standard.js';
2424
import { gte, minVersion, satisfies } from 'semver';
25-
import { escapePath, glob } from 'tinyglobby';
25+
import { glob } from 'tinyglobby';
2626

2727
import {
2828
ACTOR_ENV_VARS,
@@ -50,6 +50,10 @@ import { deleteFile, ensureFolderExistsSync, rimrafPromised } from './files.js';
5050
import type { AuthJSON } from './types.js';
5151
import { cliDebugPrint } from './utils/cliDebugPrint.js';
5252

53+
// `ignore` is a CJS package; TypeScript sees its default import as the module
54+
// object rather than the callable factory, so we cast through unknown.
55+
const makeIg = ignoreModule as unknown as () => Ignore;
56+
5357
// Export AJV properly: https://github.com/ajv-validator/ajv/issues/2132
5458
// Welcome to the state of JavaScript/TypeScript and CJS/ESM interop.
5559
export const Ajv2019 = _Ajv2019 as unknown as typeof import('ajv/dist/2019.js').default;
@@ -306,10 +310,6 @@ const getGitignoreFallbackFilter = async (cwd: string): Promise<(paths: string[]
306310
expandDirectories: false,
307311
});
308312

309-
// `ignore` is a CJS package; TypeScript sees its default import as the module
310-
// object rather than the callable factory, so we cast through unknown.
311-
const makeIg = ignoreModule as unknown as () => Ignore;
312-
313313
const filters: { dir: string; ig: Ignore; ancestorPrefix?: string }[] = [];
314314

315315
for (const gitignoreFile of gitignoreFiles) {
@@ -368,16 +368,59 @@ const getGitignoreFallbackFilter = async (cwd: string): Promise<(paths: string[]
368368
});
369369
};
370370

371+
interface ActorIgnoreResult {
372+
/** Filter that removes paths matching non-negated .actorignore patterns */
373+
excludeFilter: ((paths: string[]) => string[]) | null;
374+
/** Patterns from negation lines (with `!` stripped) — files matching these should be force-included even if git-ignored */
375+
forceIncludePatterns: string[];
376+
}
377+
378+
const parseActorIgnore = async (cwd: string): Promise<ActorIgnoreResult> => {
379+
const actorignorePath = join(cwd, '.actorignore');
380+
if (!existsSync(actorignorePath)) {
381+
return { excludeFilter: null, forceIncludePatterns: [] };
382+
}
383+
384+
const content = await readFile(actorignorePath, 'utf-8');
385+
const lines = content.split('\n');
386+
387+
const excludeLines: string[] = [];
388+
const forceIncludePatterns: string[] = [];
389+
390+
for (const line of lines) {
391+
const trimmed = line.trim();
392+
if (!trimmed || trimmed.startsWith('#')) continue;
393+
if (trimmed.startsWith('!')) {
394+
forceIncludePatterns.push(trimmed.slice(1));
395+
} else {
396+
excludeLines.push(trimmed);
397+
}
398+
}
399+
400+
const excludeFilter =
401+
excludeLines.length > 0
402+
? (paths: string[]) => {
403+
const ig = makeIg().add(excludeLines);
404+
return paths.filter((filePath) => !ig.ignores(filePath));
405+
}
406+
: null;
407+
408+
return { excludeFilter, forceIncludePatterns };
409+
};
410+
371411
/**
372-
* Get Actor local files, omit files defined in .gitignore and .git folder
412+
* Get Actor local files, omit files defined in .gitignore, .actorignore and .git folder
373413
* All dot files(.file) and folders(.folder/) are included.
374414
*/
375415
export const getActorLocalFilePaths = async (cwd?: string) => {
376416
const resolvedCwd = cwd ?? process.cwd();
377417

378-
const ignore = ['.git/**', 'apify_storage', 'node_modules', 'storage', 'crawlee_storage'];
418+
const hardcodedIgnore = ['.git/**', 'apify_storage', 'node_modules', 'storage', 'crawlee_storage'];
379419

380-
let fallbackFilter: ((paths: string[]) => string[]) | null = null;
420+
// Parse .actorignore early to get both exclude filter and force-include patterns
421+
const { excludeFilter: actorignoreFilter, forceIncludePatterns } = await parseActorIgnore(resolvedCwd);
422+
423+
let gitIgnoreFilter: ((paths: string[]) => string[]) | null = null;
381424

382425
// Use git ls-files to get gitignored paths — this correctly handles ancestor .gitignore files,
383426
// nested .gitignore files, .git/info/exclude, and global gitignore config
@@ -388,23 +431,53 @@ export const getActorLocalFilePaths = async (cwd?: string) => {
388431
stdio: ['ignore', 'pipe', 'ignore'],
389432
})
390433
.split('\n')
391-
.filter(Boolean)
392-
.map((p) => escapePath(p));
434+
.filter(Boolean);
393435

394-
ignore.push(...gitIgnored);
436+
if (gitIgnored.length > 0) {
437+
const ig = makeIg().add(gitIgnored);
438+
gitIgnoreFilter = (paths) => paths.filter((p) => !ig.ignores(p));
439+
}
395440
} catch {
396441
// git is unavailable or directory is not a git repo — fall back to parsing .gitignore files
397-
fallbackFilter = await getGitignoreFallbackFilter(resolvedCwd);
442+
gitIgnoreFilter = await getGitignoreFallbackFilter(resolvedCwd);
398443
}
399444

400-
const paths = await glob(['*', '**/**'], {
401-
ignore,
445+
const allFiles = await glob(['*', '**/**'], {
446+
ignore: hardcodedIgnore,
402447
dot: true,
403448
expandDirectories: false,
404449
cwd: resolvedCwd,
405450
});
406451

407-
return fallbackFilter ? fallbackFilter(paths) : paths;
452+
let paths = gitIgnoreFilter ? gitIgnoreFilter(allFiles) : allFiles;
453+
454+
if (actorignoreFilter) {
455+
paths = actorignoreFilter(paths);
456+
}
457+
458+
// Force-include: negation patterns in .actorignore (e.g. !dist/) override gitignore,
459+
// allowing git-ignored files to be included in the push
460+
if (forceIncludePatterns.length > 0) {
461+
const forceIncludeIg = makeIg().add(forceIncludePatterns);
462+
const forceIncluded = allFiles.filter((filePath) => forceIncludeIg.ignores(filePath));
463+
const pathSet = new Set(paths);
464+
for (const file of forceIncluded) {
465+
pathSet.add(file);
466+
}
467+
paths = [...pathSet];
468+
}
469+
470+
// .actor/ is the Actor specification folder — always include it regardless of gitignore/actorignore
471+
const actorSpecFiles = allFiles.filter((p) => p === '.actor' || p.startsWith('.actor/'));
472+
if (actorSpecFiles.length > 0) {
473+
const pathSet = new Set(paths);
474+
for (const file of actorSpecFiles) {
475+
pathSet.add(file);
476+
}
477+
paths = [...pathSet];
478+
}
479+
480+
return paths;
408481
};
409482

410483
/**
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { writeFileSync } from 'node:fs';
2+
3+
import { ensureFolderExistsSync } from '../../../src/lib/files.js';
4+
import { getActorLocalFilePaths } from '../../../src/lib/utils.js';
5+
import { useTempPath } from '../../__setup__/hooks/useTempPath.js';
6+
7+
// Mock execSync to simulate git not being available.
8+
vi.mock('node:child_process', async (importOriginal) => {
9+
const original = await importOriginal<typeof import('node:child_process')>();
10+
return {
11+
...original,
12+
execSync: () => {
13+
throw new Error('not a git repository');
14+
},
15+
};
16+
});
17+
18+
const TEST_DIR = 'actorignore-no-git-test-dir';
19+
const FOLDERS = ['src', 'docs', 'dist'];
20+
const FILES = ['main.js', 'src/index.js'];
21+
const FILES_TO_GITIGNORE = ['dist/bundle.js'];
22+
const FILES_TO_ACTORIGNORE = ['docs/README.md'];
23+
24+
describe('Utils - .actorignore with .gitignore fallback (no git)', () => {
25+
const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath(TEST_DIR, {
26+
create: true,
27+
remove: true,
28+
cwd: false,
29+
cwdParent: false,
30+
});
31+
32+
beforeAll(async () => {
33+
await beforeAllCalls();
34+
35+
FOLDERS.forEach((folder) => {
36+
ensureFolderExistsSync(tmpPath, folder);
37+
});
38+
39+
FILES.concat(FILES_TO_GITIGNORE, FILES_TO_ACTORIGNORE).forEach((file) =>
40+
writeFileSync(joinPath(file), 'content', { flag: 'w' }),
41+
);
42+
43+
writeFileSync(joinPath('.gitignore'), 'dist/\n', { flag: 'w' });
44+
writeFileSync(joinPath('.actorignore'), 'docs/\n', { flag: 'w' });
45+
});
46+
47+
afterAll(async () => {
48+
await afterAllCalls();
49+
});
50+
51+
it('should exclude files matched by both .gitignore and .actorignore', async () => {
52+
const paths = await getActorLocalFilePaths(tmpPath);
53+
54+
FILES.forEach((file) => expect(paths).toContain(file));
55+
FILES_TO_GITIGNORE.forEach((file) => expect(paths).not.toContain(file));
56+
FILES_TO_ACTORIGNORE.forEach((file) => expect(paths).not.toContain(file));
57+
});
58+
});
59+
60+
const NEGATE_NO_GIT_TEST_DIR = 'actorignore-negate-no-git-test-dir';
61+
const NEGATE_FOLDERS = ['src', 'dist'];
62+
const NEGATE_FILES = ['main.js', 'src/index.js'];
63+
const NEGATE_FILES_TO_GITIGNORE = ['dist/bundle.js'];
64+
65+
describe('Utils - .actorignore negation overrides gitignore (no git)', () => {
66+
const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath(NEGATE_NO_GIT_TEST_DIR, {
67+
create: true,
68+
remove: true,
69+
cwd: false,
70+
cwdParent: false,
71+
});
72+
73+
beforeAll(async () => {
74+
await beforeAllCalls();
75+
76+
NEGATE_FOLDERS.forEach((folder) => {
77+
ensureFolderExistsSync(tmpPath, folder);
78+
});
79+
80+
NEGATE_FILES.concat(NEGATE_FILES_TO_GITIGNORE).forEach((file) =>
81+
writeFileSync(joinPath(file), 'content', { flag: 'w' }),
82+
);
83+
84+
writeFileSync(joinPath('.gitignore'), 'dist/\n', { flag: 'w' });
85+
// .actorignore force-includes dist/ (overrides gitignore)
86+
writeFileSync(joinPath('.actorignore'), '!dist/\n', { flag: 'w' });
87+
});
88+
89+
afterAll(async () => {
90+
await afterAllCalls();
91+
});
92+
93+
it('should include gitignored files that match negation patterns in .actorignore', async () => {
94+
const paths = await getActorLocalFilePaths(tmpPath);
95+
96+
NEGATE_FILES.forEach((file) => expect(paths).toContain(file));
97+
// dist/bundle.js is gitignored but force-included by .actorignore
98+
NEGATE_FILES_TO_GITIGNORE.forEach((file) => expect(paths).toContain(file));
99+
});
100+
});

0 commit comments

Comments
 (0)