From 3dcf20a0256ae66463a0939902ab58572358c763 Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 16:56:22 +0800 Subject: [PATCH 01/10] feat(backup): add .gitignore-like exclude patterns support - Add --exclude option to exclude specific patterns - Add --exclude-file option to read patterns from file - Support basic glob patterns (* and ?) - Filter assets before creating tar archive - Show excluded files in skipped list - Include excludePatterns in manifest for verification Closes #40786 --- src/cli/program/register.backup.ts | 12 ++++ src/commands/backup.ts | 90 +++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/cli/program/register.backup.ts b/src/cli/program/register.backup.ts index fc928f0ff3a58..82a6ffba85fea 100644 --- a/src/cli/program/register.backup.ts +++ b/src/cli/program/register.backup.ts @@ -26,6 +26,8 @@ export function registerBackupCommand(program: Command) { .option("--verify", "Verify the archive after writing it", false) .option("--only-config", "Back up only the active JSON config file", false) .option("--no-include-workspace", "Exclude workspace directories from the backup") + .option("--exclude ", "Exclude files matching pattern (can be repeated)", (val, arr) => arr.concat(val), []) + .option("--exclude-file ", "Read exclude patterns from file") .addHelpText( "after", () => @@ -48,6 +50,14 @@ export function registerBackupCommand(program: Command) { "Back up state/config without agent workspace files.", ], ["openclaw backup create --only-config", "Back up only the active JSON config file."], + [ + "openclaw backup create --exclude 'node_modules' --exclude '*.log'", + "Exclude specific patterns from backup.", + ], + [ + "openclaw backup create --exclude-file .openclawignore", + "Exclude patterns from a file (like .gitignore).", + ], ])}`, ) .action(async (opts) => { @@ -59,6 +69,8 @@ export function registerBackupCommand(program: Command) { verify: Boolean(opts.verify), onlyConfig: Boolean(opts.onlyConfig), includeWorkspace: opts.includeWorkspace as boolean, + exclude: opts.exclude as string[] | undefined, + excludeFile: opts.excludeFile as string | undefined, }); }); }); diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 15f0f505d7661..31ff33517cceb 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -25,6 +25,8 @@ export type BackupCreateOptions = { verify?: boolean; json?: boolean; nowMs?: number; + exclude?: string[]; + excludeFile?: string; }; type BackupManifestAsset = { @@ -43,6 +45,7 @@ type BackupManifest = { options: { includeWorkspace: boolean; onlyConfig?: boolean; + excludePatterns?: string[]; }; paths: { stateDir: string; @@ -67,6 +70,7 @@ export type BackupCreateResult = { includeWorkspace: boolean; onlyConfig: boolean; verified: boolean; + excludePatterns?: string[]; assets: BackupAsset[]; skipped: Array<{ kind: string; @@ -194,6 +198,7 @@ function buildManifest(params: { archiveRoot: string; includeWorkspace: boolean; onlyConfig: boolean; + excludePatterns?: string[]; assets: BackupAsset[]; skipped: BackupCreateResult["skipped"]; stateDir: string; @@ -211,6 +216,7 @@ function buildManifest(params: { options: { includeWorkspace: params.includeWorkspace, onlyConfig: params.onlyConfig, + excludePatterns: params.excludePatterns, }, paths: { stateDir: params.stateDir, @@ -271,6 +277,59 @@ function remapArchiveEntryPath(params: { return buildBackupArchivePath(params.archiveRoot, normalizedEntry); } +function matchesExcludePattern(filePath: string, pattern: string): boolean { + // Simple glob matching for common patterns + const normalizedPath = filePath.replace(/\\/g, "/"); + + // Handle wildcards + if (pattern.includes("*")) { + const regex = new RegExp("^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); + return regex.test(normalizedPath) || regex.test(path.basename(filePath)); + } + + // Handle directory patterns (ending with /) + if (pattern.endsWith("/")) { + const dirPattern = pattern.slice(0, -1); + return normalizedPath.includes(dirPattern + "/") || normalizedPath.includes(dirPattern + "\\"); + } + + // Exact match or contains + return normalizedPath.includes(pattern) || path.basename(filePath) === pattern; +} + +function filterExcludedAssets(assets: BackupAsset[], excludePatterns: string[]): { included: BackupAsset[]; excluded: BackupAsset[] } { + if (!excludePatterns || excludePatterns.length === 0) { + return { included: assets, excluded: [] }; + } + + const included: BackupAsset[] = []; + const excluded: BackupAsset[] = []; + + for (const asset of assets) { + const isExcluded = excludePatterns.some(pattern => matchesExcludePattern(asset.sourcePath, pattern)); + if (isExcluded) { + excluded.push(asset); + } else { + included.push(asset); + } + } + + return { included, excluded }; +} + +async function loadExcludePatternsFromFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf8"); + // Filter out empty lines and comments + return content + .split("\n") + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")); + } catch { + return []; + } +} + export async function backupCreateCommand( runtime: RuntimeEnv, opts: BackupCreateOptions = {}, @@ -280,6 +339,23 @@ export async function backupCreateCommand( const onlyConfig = Boolean(opts.onlyConfig); const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); + + // Load exclude patterns + let excludePatterns = opts.exclude ?? []; + if (opts.excludeFile) { + const filePatterns = await loadExcludePatternsFromFile(opts.excludeFile); + excludePatterns = [...excludePatterns, ...filePatterns]; + } + + // Filter excluded assets + const { included: filteredAssets, excluded: excludedAssets } = filterExcludedAssets( + plan.included, + excludePatterns, + ); + + // Update plan with filtered assets + plan.included = filteredAssets; + const outputPath = await resolveOutputPath({ output: opts.output, nowMs, @@ -310,6 +386,16 @@ export async function backupCreateCommand( } const createdAt = new Date(nowMs).toISOString(); + + // Add excluded assets to skipped list + const skippedFromExclude = excludedAssets.map(asset => ({ + kind: asset.kind, + sourcePath: asset.sourcePath, + displayPath: asset.displayPath, + reason: "excluded", + coveredBy: undefined as string | undefined, + })); + const result: BackupCreateResult = { createdAt, archiveRoot, @@ -318,8 +404,9 @@ export async function backupCreateCommand( includeWorkspace, onlyConfig, verified: false, + excludePatterns, assets: plan.included, - skipped: plan.skipped, + skipped: [...plan.skipped, ...skippedFromExclude], }; if (!opts.dryRun) { @@ -333,6 +420,7 @@ export async function backupCreateCommand( archiveRoot, includeWorkspace, onlyConfig, + excludePatterns, assets: result.assets, skipped: result.skipped, stateDir: plan.stateDir, From fcc73ed0b6687fc59f8d65469bf7724373887442 Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 16:59:46 +0800 Subject: [PATCH 02/10] feat(backup): auto-load .gitignore and .openclawignore from workspaces - Automatically find and load .gitignore and .openclawignore files - Load patterns from each workspace directory - Patterns are applied to backup archive --- src/commands/backup.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 31ff33517cceb..5cafd89396f85 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -330,6 +330,31 @@ async function loadExcludePatternsFromFile(filePath: string): Promise } } +async function loadIgnoreFilesFromWorkspaces(workspaceDirs: string[]): Promise { + const patterns: string[] = []; + const ignoreFiles = [".gitignore", ".openclawignore"]; + + for (const workspaceDir of workspaceDirs) { + for (const ignoreFile of ignoreFiles) { + const filePath = path.join(workspaceDir, ignoreFile); + try { + const stats = await fs.stat(filePath); + if (stats.isFile()) { + const filePatterns = await loadExcludePatternsFromFile(filePath); + // Add prefix to avoid conflicts with main workspace + for (const pattern of filePatterns) { + patterns.push(`${workspaceDir}/${pattern}`); + } + } + } catch { + // File doesn't exist, skip + } + } + } + + return patterns; +} + export async function backupCreateCommand( runtime: RuntimeEnv, opts: BackupCreateOptions = {}, @@ -342,10 +367,17 @@ export async function backupCreateCommand( // Load exclude patterns let excludePatterns = opts.exclude ?? []; + + // Load from specified exclude file if (opts.excludeFile) { const filePatterns = await loadExcludePatternsFromFile(opts.excludeFile); excludePatterns = [...excludePatterns, ...filePatterns]; } + + // Auto-load .gitignore and .openclawignore from workspace directories + const workspacePatterns = await loadIgnoreFilesFromWorkspaces(plan.workspaceDirs); + excludePatterns = [...excludePatterns, ...workspacePatterns]; + } // Filter excluded assets const { included: filteredAssets, excluded: excludedAssets } = filterExcludedAssets( From 527af337c5358807dad0f482213c22149e5ec205 Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 17:13:21 +0800 Subject: [PATCH 03/10] fix: remove extra brace in backup.ts --- src/commands/backup.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 5cafd89396f85..f4180e4afea48 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -377,7 +377,6 @@ export async function backupCreateCommand( // Auto-load .gitignore and .openclawignore from workspace directories const workspacePatterns = await loadIgnoreFilesFromWorkspaces(plan.workspaceDirs); excludePatterns = [...excludePatterns, ...workspacePatterns]; - } // Filter excluded assets const { included: filteredAssets, excluded: excludedAssets } = filterExcludedAssets( From df6873537e2151ea5ecf648333ceec22ba3d6b0f Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 17:17:22 +0800 Subject: [PATCH 04/10] fix: escape regex special chars and propagate errors for explicit exclude-file - Escape regex special characters before pattern compilation - Add required parameter to loadExcludePatternsFromFile - Throw error when user-specified exclude file cannot be loaded Fixes review comments --- src/commands/backup.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/commands/backup.ts b/src/commands/backup.ts index f4180e4afea48..1a46df1bc0aa7 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -281,9 +281,11 @@ function matchesExcludePattern(filePath: string, pattern: string): boolean { // Simple glob matching for common patterns const normalizedPath = filePath.replace(/\\/g, "/"); - // Handle wildcards - if (pattern.includes("*")) { - const regex = new RegExp("^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); + // Handle wildcards - escape regex special chars first + if (pattern.includes("*") || pattern.includes("?")) { + // Escape regex special characters except * and ? + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); return regex.test(normalizedPath) || regex.test(path.basename(filePath)); } @@ -317,7 +319,7 @@ function filterExcludedAssets(assets: BackupAsset[], excludePatterns: string[]): return { included, excluded }; } -async function loadExcludePatternsFromFile(filePath: string): Promise { +async function loadExcludePatternsFromFile(filePath: string, required = false): Promise { try { const content = await fs.readFile(filePath, "utf8"); // Filter out empty lines and comments @@ -325,7 +327,10 @@ async function loadExcludePatternsFromFile(filePath: string): Promise .split("\n") .map(line => line.trim()) .filter(line => line && !line.startsWith("#")); - } catch { + } catch (err) { + if (required) { + throw new Error(`Failed to load exclude file: ${filePath}`, { cause: err }); + } return []; } } @@ -370,7 +375,7 @@ export async function backupCreateCommand( // Load from specified exclude file if (opts.excludeFile) { - const filePatterns = await loadExcludePatternsFromFile(opts.excludeFile); + const filePatterns = await loadExcludePatternsFromFile(opts.excludeFile, true); excludePatterns = [...excludePatterns, ...filePatterns]; } From d6be3fa976dc52e9eda55bd27d73710e9744b06d Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 17:20:31 +0800 Subject: [PATCH 05/10] test(backup): add test cases for exclude patterns - Test basic --exclude pattern matching - Test regex special character escaping - Test --exclude-file loading - Test error when exclude file doesn't exist - Test auto-loading .gitignore from workspace - Test multiple --exclude patterns --- src/commands/backup.test.ts | 139 ++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 349714e4d1561..cda480ba449f7 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -431,4 +431,143 @@ describe("backup commands", () => { delete process.env.OPENCLAW_CONFIG_PATH; } }); + + describe("exclude patterns", () => { + it("excludes files matching --exclude pattern", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "workspace", "important.txt"), "important", "utf8"); + await fs.writeFile(path.join(stateDir, "workspace", "node_modules", "dep.js"), "dep", "utf8"); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }), + "utf8", + ); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + const result = await backupCreateCommand(runtime, { + dryRun: true, + exclude: ["node_modules"], + }); + + expect(result.assets.some((a) => a.sourcePath.includes("node_modules"))).toBe(false); + expect(result.skipped.some((s) => s.reason === "excluded")).toBe(true); + }); + + it("escapes regex special characters in exclude patterns", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "workspace", "file.txt"), "txt", "utf8"); + await fs.writeFile(path.join(stateDir, "workspace", "file.old.txt"), "old", "utf8"); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }), + "utf8", + ); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + // Pattern file.old.txt should only match literal file, not file.txt + const result = await backupCreateCommand(runtime, { + dryRun: true, + exclude: ["file.old.txt"], + }); + + // file.txt should still be included + expect(result.assets.some((a) => a.sourcePath.includes("file.txt"))).toBe(true); + // file.old.txt should be excluded + expect(result.assets.some((a) => a.sourcePath.includes("file.old.txt"))).toBe(false); + }); + + it("loads exclude patterns from --exclude-file", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const excludeFile = path.join(tempHome.home, ".openclawignore"); + await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "workspace", "important.txt"), "important", "utf8"); + await fs.writeFile(path.join(stateDir, "workspace", "secret.env"), "secret", "utf8"); + await fs.writeFile(excludeFile, "secret.env\n*.log", "utf8"); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }), + "utf8", + ); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + const result = await backupCreateCommand(runtime, { + dryRun: true, + excludeFile: excludeFile, + }); + + expect(result.assets.some((a) => a.sourcePath.includes("important.txt"))).toBe(true); + expect(result.assets.some((a) => a.sourcePath.includes("secret.env"))).toBe(false); + }); + + it("throws error when --exclude-file does not exist", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }), + "utf8", + ); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + await expect( + backupCreateCommand(runtime, { + dryRun: true, + excludeFile: "/nonexistent/file.txt", + }), + ).rejects.toThrow("Failed to load exclude file"); + }); + + it("auto-loads .gitignore from workspace directories", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const workspaceDir = path.join(stateDir, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "keep.txt"), "keep", "utf8"); + await fs.writeFile(path.join(workspaceDir, "skip.log"), "skip", "utf8"); + await fs.writeFile(path.join(workspaceDir, ".gitignore"), "*.log\nnode_modules/", "utf8"); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ agents: { defaults: { workspace: workspaceDir } } }), + "utf8", + ); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + const result = await backupCreateCommand(runtime, { + dryRun: true, + }); + + expect(result.assets.some((a) => a.sourcePath.includes("keep.txt"))).toBe(true); + expect(result.assets.some((a) => a.sourcePath.includes("skip.log"))).toBe(false); + }); + + it("supports multiple --exclude patterns", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "workspace", "a.txt"), "a", "utf8"); + await fs.writeFile(path.join(stateDir, "workspace", "b.txt"), "b", "utf8"); + await fs.writeFile(path.join(stateDir, "workspace", "c.txt"), "c", "utf8"); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }), + "utf8", + ); + + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + const result = await backupCreateCommand(runtime, { + dryRun: true, + exclude: ["a.txt", "b.txt"], + }); + + expect(result.assets.some((a) => a.sourcePath.includes("c.txt"))).toBe(true); + expect(result.assets.some((a) => a.sourcePath.includes("a.txt"))).toBe(false); + expect(result.assets.some((a) => a.sourcePath.includes("b.txt"))).toBe(false); + }); + }); }); From 72afe6bebd937092975371bd8e834f83431bb587 Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 17:29:40 +0800 Subject: [PATCH 06/10] fix: address remaining review comments - Fix ? wildcard to work independently without * - Fix substring matching to use exact/basename match - Normalize workspace ignore patterns - Add tar filter to exclude individual entries within archived dirs Fixes review comments --- src/commands/backup.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 1a46df1bc0aa7..0ce60d81d230e 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -281,12 +281,12 @@ function matchesExcludePattern(filePath: string, pattern: string): boolean { // Simple glob matching for common patterns const normalizedPath = filePath.replace(/\\/g, "/"); - // Handle wildcards - escape regex special chars first + // Handle wildcards (both * and ? work independently now) if (pattern.includes("*") || pattern.includes("?")) { // Escape regex special characters except * and ? const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); - return regex.test(normalizedPath) || regex.test(path.basename(filePath)); + return regex.test(normalizedPath) || regex.test(path.basename(normalizedPath)); } // Handle directory patterns (ending with /) @@ -295,8 +295,8 @@ function matchesExcludePattern(filePath: string, pattern: string): boolean { return normalizedPath.includes(dirPattern + "/") || normalizedPath.includes(dirPattern + "\\"); } - // Exact match or contains - return normalizedPath.includes(pattern) || path.basename(filePath) === pattern; + // Exact match or basename match (not substring to avoid false positives) + return normalizedPath === pattern || path.basename(normalizedPath) === pattern; } function filterExcludedAssets(assets: BackupAsset[], excludePatterns: string[]): { included: BackupAsset[]; excluded: BackupAsset[] } { @@ -346,9 +346,12 @@ async function loadIgnoreFilesFromWorkspaces(workspaceDirs: string[]): Promise { + // Always include manifest + if (entryPath === manifestPath) return true; + // Check against exclude patterns + return !excludePatterns.some(pattern => matchesExcludePattern(entryPath, pattern)); + }; + await tar.c( { file: tempArchivePath, gzip: true, portable: true, preservePaths: true, + filter: filterFn, onWriteEntry: (entry) => { entry.path = remapArchiveEntryPath({ entryPath: entry.path, From 40bb6ca36de5a19ef3f1d81580387b8b11ed08d4 Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 17:45:41 +0800 Subject: [PATCH 07/10] style: fix formatting with oxfmt --- src/cli/program/register.backup.ts | 7 ++++- src/commands/backup.ts | 41 +++++++++++++++++------------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/cli/program/register.backup.ts b/src/cli/program/register.backup.ts index 82a6ffba85fea..90f404dbeea92 100644 --- a/src/cli/program/register.backup.ts +++ b/src/cli/program/register.backup.ts @@ -26,7 +26,12 @@ export function registerBackupCommand(program: Command) { .option("--verify", "Verify the archive after writing it", false) .option("--only-config", "Back up only the active JSON config file", false) .option("--no-include-workspace", "Exclude workspace directories from the backup") - .option("--exclude ", "Exclude files matching pattern (can be repeated)", (val, arr) => arr.concat(val), []) + .option( + "--exclude ", + "Exclude files matching pattern (can be repeated)", + (val, arr) => arr.concat(val), + [], + ) .option("--exclude-file ", "Read exclude patterns from file") .addHelpText( "after", diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 0ce60d81d230e..22d3ada37022c 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -280,7 +280,7 @@ function remapArchiveEntryPath(params: { function matchesExcludePattern(filePath: string, pattern: string): boolean { // Simple glob matching for common patterns const normalizedPath = filePath.replace(/\\/g, "/"); - + // Handle wildcards (both * and ? work independently now) if (pattern.includes("*") || pattern.includes("?")) { // Escape regex special characters except * and ? @@ -288,34 +288,39 @@ function matchesExcludePattern(filePath: string, pattern: string): boolean { const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); return regex.test(normalizedPath) || regex.test(path.basename(normalizedPath)); } - + // Handle directory patterns (ending with /) if (pattern.endsWith("/")) { const dirPattern = pattern.slice(0, -1); return normalizedPath.includes(dirPattern + "/") || normalizedPath.includes(dirPattern + "\\"); } - + // Exact match or basename match (not substring to avoid false positives) return normalizedPath === pattern || path.basename(normalizedPath) === pattern; } -function filterExcludedAssets(assets: BackupAsset[], excludePatterns: string[]): { included: BackupAsset[]; excluded: BackupAsset[] } { +function filterExcludedAssets( + assets: BackupAsset[], + excludePatterns: string[], +): { included: BackupAsset[]; excluded: BackupAsset[] } { if (!excludePatterns || excludePatterns.length === 0) { return { included: assets, excluded: [] }; } - + const included: BackupAsset[] = []; const excluded: BackupAsset[] = []; - + for (const asset of assets) { - const isExcluded = excludePatterns.some(pattern => matchesExcludePattern(asset.sourcePath, pattern)); + const isExcluded = excludePatterns.some((pattern) => + matchesExcludePattern(asset.sourcePath, pattern), + ); if (isExcluded) { excluded.push(asset); } else { included.push(asset); } } - + return { included, excluded }; } @@ -325,8 +330,8 @@ async function loadExcludePatternsFromFile(filePath: string, required = false): // Filter out empty lines and comments return content .split("\n") - .map(line => line.trim()) - .filter(line => line && !line.startsWith("#")); + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); } catch (err) { if (required) { throw new Error(`Failed to load exclude file: ${filePath}`, { cause: err }); @@ -338,7 +343,7 @@ async function loadExcludePatternsFromFile(filePath: string, required = false): async function loadIgnoreFilesFromWorkspaces(workspaceDirs: string[]): Promise { const patterns: string[] = []; const ignoreFiles = [".gitignore", ".openclawignore"]; - + for (const workspaceDir of workspaceDirs) { for (const ignoreFile of ignoreFiles) { const filePath = path.join(workspaceDir, ignoreFile); @@ -359,7 +364,7 @@ async function loadIgnoreFilesFromWorkspaces(workspaceDirs: string[]): Promise ({ + const skippedFromExclude = excludedAssets.map((asset) => ({ kind: asset.kind, sourcePath: asset.sourcePath, displayPath: asset.displayPath, reason: "excluded", coveredBy: undefined as string | undefined, })); - + const result: BackupCreateResult = { createdAt, archiveRoot, @@ -474,7 +479,7 @@ export async function backupCreateCommand( // Always include manifest if (entryPath === manifestPath) return true; // Check against exclude patterns - return !excludePatterns.some(pattern => matchesExcludePattern(entryPath, pattern)); + return !excludePatterns.some((pattern) => matchesExcludePattern(entryPath, pattern)); }; await tar.c( From 642c68c3a9352f5b1d12b998ef0eb34c55e699a9 Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 17:48:14 +0800 Subject: [PATCH 08/10] feat(backup): add full .gitignore pattern support - Add negation patterns (! prefix) - Add character classes [abc] - Add character ranges [a-z] - Add double wildcard ** for recursive matching - Handle negation in filter logic Closes #40786 --- src/commands/backup.ts | 78 +++++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 22d3ada37022c..fab2f67e51869 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -278,25 +278,61 @@ function remapArchiveEntryPath(params: { } function matchesExcludePattern(filePath: string, pattern: string): boolean { - // Simple glob matching for common patterns + // Handle negation patterns (starting with !) + const isNegation = pattern.startsWith("!"); + if (isNegation) { + pattern = pattern.slice(1); + } + + // Normalize path const normalizedPath = filePath.replace(/\\/g, "/"); + const basename = path.basename(normalizedPath); + + // Build regex from gitignore pattern + let regexPattern = pattern; + + // Handle character classes [abc] and ranges [a-z] + // Convert to regex: [abc] -> \[abc\], [a-z] -> \[a-z\] + regexPattern = regexPattern.replace(/\[([^\]]+)\]/g, (match) => { + return "[" + match.slice(1, -1) + "]"; + }); - // Handle wildcards (both * and ? work independently now) - if (pattern.includes("*") || pattern.includes("?")) { - // Escape regex special characters except * and ? - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); - return regex.test(normalizedPath) || regex.test(path.basename(normalizedPath)); + // Handle ** (matches directories recursively) + // **/foo matches foo at any level + // foo/** matches everything under foo + if (regexPattern.startsWith("**/")) { + regexPattern = ".*/" + regexPattern.slice(3); + } else if (regexPattern.endsWith("/**")) { + regexPattern = regexPattern.slice(0, -2) + "(/.*)?"; + } else { + // Regular * doesn't match slashes + regexPattern = regexPattern.replace(/\*/g, "[^/]*"); } + // ? matches any single character except / + regexPattern = regexPattern.replace(/\?/g, "[^/]"); + + // Escape other regex special characters + regexPattern = regexPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + + // Match from start (anchored) + const regex = new RegExp("^" + regexPattern + "$"); + + // Check if matches full path or basename + const matchesFull = regex.test(normalizedPath); + const matchesBasename = regex.test(basename); + // Handle directory patterns (ending with /) if (pattern.endsWith("/")) { - const dirPattern = pattern.slice(0, -1); - return normalizedPath.includes(dirPattern + "/") || normalizedPath.includes(dirPattern + "\\"); + const isDir = + normalizedPath.endsWith("/") || normalizedPath.split("/").slice(-1)[0] !== basename; + return (matchesFull || matchesBasename) && isDir; } - // Exact match or basename match (not substring to avoid false positives) - return normalizedPath === pattern || path.basename(normalizedPath) === pattern; + const matched = matchesFull || matchesBasename; + + // Negation means include (opposite of exclude) + return isNegation ? !matched : matched; } function filterExcludedAssets( @@ -307,13 +343,29 @@ function filterExcludedAssets( return { included: assets, excluded: [] }; } + // Separate negation patterns from exclusion patterns + const negationPatterns = excludePatterns.filter((p) => p.startsWith("!")); + const exclusionPatterns = excludePatterns.filter((p) => !p.startsWith("!")); + const included: BackupAsset[] = []; const excluded: BackupAsset[] = []; for (const asset of assets) { - const isExcluded = excludePatterns.some((pattern) => - matchesExcludePattern(asset.sourcePath, pattern), + const sourcePath = asset.sourcePath; + + // Check exclusion patterns first + const isExcludedByNormal = exclusionPatterns.some((pattern) => + matchesExcludePattern(sourcePath, pattern), ); + + // Check negation patterns - if any negation matches, file is included + const isNegated = negationPatterns.some((pattern) => + matchesExcludePattern(sourcePath, pattern), + ); + + // File is excluded if matched by exclusion pattern AND not negated + const isExcluded = isExcludedByNormal && !isNegated; + if (isExcluded) { excluded.push(asset); } else { From 8fb15d41323c8292427d7b1a9de9f95e43aa12d9 Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 19:28:03 +0800 Subject: [PATCH 09/10] fix: use collectOption helper for array option - Import collectOption from helpers.ts - Use collectOption for --exclude option - Fix TypeScript type error --- src/cli/program/register.backup.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/cli/program/register.backup.ts b/src/cli/program/register.backup.ts index 90f404dbeea92..0073cb6d06af5 100644 --- a/src/cli/program/register.backup.ts +++ b/src/cli/program/register.backup.ts @@ -6,6 +6,7 @@ import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { formatHelpExamples } from "../help-format.js"; +import { collectOption } from "./helpers.js"; export function registerBackupCommand(program: Command) { const backup = program @@ -26,12 +27,7 @@ export function registerBackupCommand(program: Command) { .option("--verify", "Verify the archive after writing it", false) .option("--only-config", "Back up only the active JSON config file", false) .option("--no-include-workspace", "Exclude workspace directories from the backup") - .option( - "--exclude ", - "Exclude files matching pattern (can be repeated)", - (val, arr) => arr.concat(val), - [], - ) + .option("--exclude ", "Exclude files matching pattern (repeatable)", collectOption, []) .option("--exclude-file ", "Read exclude patterns from file") .addHelpText( "after", From 550af2f9f83c41e7c0f3e67c974c6900fd89bdbe Mon Sep 17 00:00:00 2001 From: Verncake Date: Mon, 9 Mar 2026 19:37:06 +0800 Subject: [PATCH 10/10] fix: add curly braces for lint rule --- src/commands/backup.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/backup.ts b/src/commands/backup.ts index fab2f67e51869..4614a060475cf 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -529,7 +529,9 @@ export async function backupCreateCommand( // Filter function to exclude individual entries within archived directories const filterFn = (entryPath: string): boolean => { // Always include manifest - if (entryPath === manifestPath) return true; + if (entryPath === manifestPath) { + return true; + } // Check against exclude patterns return !excludePatterns.some((pattern) => matchesExcludePattern(entryPath, pattern)); };