block-in-file 1.0.0
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/- +3 -0
- package/.beads/README.md +85 -0
- package/.beads/config.yaml +67 -0
- package/.beads/interactions.jsonl +0 -0
- package/.beads/issues.jsonl +23 -0
- package/.beads/metadata.json +4 -0
- package/.git-blame-ignore-revs +2 -0
- package/.gitattributes +3 -0
- package/.prettierrc.json +5 -0
- package/AGENTS.md +40 -0
- package/README.md +122 -0
- package/block-in-file.ts +150 -0
- package/content +10 -0
- package/deno.json +14 -0
- package/deno.lock +1084 -0
- package/doc/PLAN-envsubst.md +200 -0
- package/doc/PLAN-restructure.md +114 -0
- package/package.json +44 -0
- package/src/attributes.ts +161 -0
- package/src/backup.ts +180 -0
- package/src/block-parser.ts +170 -0
- package/src/block-remover.ts +128 -0
- package/src/conflict-detection.ts +179 -0
- package/src/defaults.ts +23 -0
- package/src/envsubst.ts +59 -0
- package/src/file-processor.ts +378 -0
- package/src/index.ts +5 -0
- package/src/input.ts +69 -0
- package/src/mode-handler.ts +39 -0
- package/src/output.ts +107 -0
- package/src/plugins/.beads/.local_version +1 -0
- package/src/plugins/.beads/issues.jsonl +21 -0
- package/src/plugins/.beads/metadata.json +4 -0
- package/src/plugins/config.ts +282 -0
- package/src/plugins/diff.ts +109 -0
- package/src/plugins/io.ts +72 -0
- package/src/plugins/logger.ts +41 -0
- package/src/tags/tag-merger.ts +31 -0
- package/src/tags/tag-mode.ts +1 -0
- package/src/tags/tag.ts +36 -0
- package/src/tags/tags.ts +4 -0
- package/src/tags/types.ts +4 -0
- package/src/timestamp.ts +39 -0
- package/src/types.ts +32 -0
- package/src/validation.ts +11 -0
- package/test/additive-cli.test.ts +109 -0
- package/test/additive.test.ts +233 -0
- package/test/attributes-integration.test.ts +161 -0
- package/test/attributes.test.ts +100 -0
- package/test/backup.test.ts +386 -0
- package/test/block-in-file.test.ts +235 -0
- package/test/block-parser.test.ts +221 -0
- package/test/block-remover.test.ts +209 -0
- package/test/cli.test.ts +254 -0
- package/test/defaults.test.ts +38 -0
- package/test/envsubst-edge-cases.test.ts +116 -0
- package/test/envsubst-integration.test.ts +78 -0
- package/test/envsubst.test.ts +184 -0
- package/test/input.test.ts +86 -0
- package/test/mode.test.ts +193 -0
- package/test/output.test.ts +44 -0
- package/test/tag-merger.test.ts +176 -0
- package/test/tags.test.ts +116 -0
- package/test/timestamp-integration.test.ts +209 -0
- package/test/timestamp.test.ts +76 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { supportsChattr } from "../src/attributes.ts";
|
|
7
|
+
|
|
8
|
+
describe("Attributes integration", () => {
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
const isLinuxWithChattr = process.platform === "linux" && supportsChattr();
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "blockinfile-attributes-"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function runBlockInFile(args: string, input?: string): string {
|
|
21
|
+
const cwd = path.resolve(import.meta.dirname!, "..");
|
|
22
|
+
const cmd = `npx tsx block-in-file.ts ${args}`;
|
|
23
|
+
return execSync(cmd, {
|
|
24
|
+
cwd,
|
|
25
|
+
input,
|
|
26
|
+
encoding: "utf-8",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("with --attributes option", () => {
|
|
31
|
+
if (!isLinuxWithChattr) {
|
|
32
|
+
it.skip("should be skipped on non-Linux or without chattr", () => {
|
|
33
|
+
expect(process.platform).not.toBe("linux");
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it("sets immutable attribute on file", async () => {
|
|
39
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
40
|
+
await fs.writeFile(targetFile, "original\n");
|
|
41
|
+
|
|
42
|
+
runBlockInFile(`--attributes "+i" ${targetFile}`, "CONTENT");
|
|
43
|
+
|
|
44
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
45
|
+
expect(result).toContain("CONTENT");
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const lsattr = execSync(`lsattr ${targetFile}`, { encoding: "utf-8" });
|
|
49
|
+
expect(lsattr).toMatch(/i/);
|
|
50
|
+
} finally {
|
|
51
|
+
execSync(`chattr -i ${targetFile}`, { stdio: "ignore" });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("sets multiple attributes", async () => {
|
|
56
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
57
|
+
await fs.writeFile(targetFile, "original\n");
|
|
58
|
+
|
|
59
|
+
runBlockInFile(`--attributes "+i +a" ${targetFile}`, "CONTENT");
|
|
60
|
+
|
|
61
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
62
|
+
expect(result).toContain("CONTENT");
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const lsattr = execSync(`lsattr ${targetFile}`, { encoding: "utf-8" });
|
|
66
|
+
expect(lsattr).toMatch(/i/);
|
|
67
|
+
expect(lsattr).toMatch(/a/);
|
|
68
|
+
} finally {
|
|
69
|
+
execSync(`chattr -i -a ${targetFile}`, { stdio: "ignore" });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("removes immutable attribute", async () => {
|
|
74
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
75
|
+
await fs.writeFile(targetFile, "original\n");
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
execSync(`chattr +i ${targetFile}`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.warn(`Skipping test: cannot set immutable attribute: ${(err as Error).message}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
runBlockInFile(`--attributes "-i" ${targetFile}`, "CONTENT");
|
|
85
|
+
|
|
86
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
87
|
+
expect(result).toContain("CONTENT");
|
|
88
|
+
|
|
89
|
+
const lsattr = execSync(`lsattr ${targetFile}`, { encoding: "utf-8" });
|
|
90
|
+
expect(lsattr).not.toMatch(/i/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("works with --backup option", async () => {
|
|
94
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
95
|
+
await fs.writeFile(targetFile, "original\n");
|
|
96
|
+
|
|
97
|
+
runBlockInFile(`--backup .old --attributes "+i" ${targetFile}`, "CONTENT");
|
|
98
|
+
|
|
99
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
100
|
+
expect(result).toContain("CONTENT");
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const lsattr = execSync(`lsattr ${targetFile}`, { encoding: "utf-8" });
|
|
104
|
+
expect(lsattr).toMatch(/i/);
|
|
105
|
+
} finally {
|
|
106
|
+
execSync(`chattr -i ${targetFile}`, { stdio: "ignore" });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("warns about insufficient privileges when not root", async () => {
|
|
111
|
+
if (process.getuid && process.getuid() === 0) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
116
|
+
await fs.writeFile(targetFile, "original\n");
|
|
117
|
+
|
|
118
|
+
runBlockInFile(`--attributes "+i" ${targetFile}`, "CONTENT");
|
|
119
|
+
|
|
120
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
121
|
+
expect(result).toContain("CONTENT");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("cross-platform behavior", () => {
|
|
126
|
+
it("should work without errors on all platforms", async () => {
|
|
127
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
128
|
+
await fs.writeFile(targetFile, "original\n");
|
|
129
|
+
|
|
130
|
+
runBlockInFile(`--attributes "+i" ${targetFile}`, "CONTENT");
|
|
131
|
+
|
|
132
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
133
|
+
expect(result).toContain("CONTENT");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("with --debug option", () => {
|
|
138
|
+
if (!isLinuxWithChattr) {
|
|
139
|
+
it.skip("should show debug info on Linux with chattr", () => {
|
|
140
|
+
expect(process.platform).not.toBe("linux");
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it("shows attribute application in debug output", async () => {
|
|
146
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
147
|
+
await fs.writeFile(targetFile, "original\n");
|
|
148
|
+
|
|
149
|
+
const output = runBlockInFile(`--debug --attributes "+i" ${targetFile}`, "CONTENT");
|
|
150
|
+
|
|
151
|
+
expect(output).toContain("Applying attributes");
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const lsattr = execSync(`lsattr ${targetFile}`, { encoding: "utf-8" });
|
|
155
|
+
expect(lsattr).toMatch(/i/);
|
|
156
|
+
} finally {
|
|
157
|
+
execSync(`chattr -i ${targetFile}`, { stdio: "ignore" });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseAttributes, supportsChattr } from "../src/attributes.ts";
|
|
3
|
+
|
|
4
|
+
describe("attributes", () => {
|
|
5
|
+
describe("parseAttributes", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
{ input: "+i", expected: [{ mode: "+", attribute: "i" }] },
|
|
8
|
+
{ input: "+a", expected: [{ mode: "+", attribute: "a" }] },
|
|
9
|
+
{ input: "-i", expected: [{ mode: "-", attribute: "i" }] },
|
|
10
|
+
{ input: "=i", expected: [{ mode: "=", attribute: "i" }] },
|
|
11
|
+
{ input: "+I", expected: [{ mode: "+", attribute: "I" }] },
|
|
12
|
+
])("parses $input", ({ input, expected }) => {
|
|
13
|
+
const result = parseAttributes(input);
|
|
14
|
+
expect(result).toEqual(expected);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("parses multiple space-separated attributes", () => {
|
|
18
|
+
const result = parseAttributes("+i +a +d");
|
|
19
|
+
expect(result).toEqual([
|
|
20
|
+
{ mode: "+", attribute: "i" },
|
|
21
|
+
{ mode: "+", attribute: "a" },
|
|
22
|
+
{ mode: "+", attribute: "d" },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("parses mixed add and remove attributes", () => {
|
|
27
|
+
const result = parseAttributes("-i +a");
|
|
28
|
+
expect(result).toEqual([
|
|
29
|
+
{ mode: "-", attribute: "i" },
|
|
30
|
+
{ mode: "+", attribute: "a" },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each([
|
|
35
|
+
{ input: "", description: "empty string" },
|
|
36
|
+
{ input: " ", description: "whitespace only" },
|
|
37
|
+
])("handles $description", ({ input }) => {
|
|
38
|
+
const result = parseAttributes(input);
|
|
39
|
+
expect(result).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles extra whitespace between attributes", () => {
|
|
43
|
+
const result = parseAttributes(" +i +a ");
|
|
44
|
+
expect(result).toEqual([
|
|
45
|
+
{ mode: "+", attribute: "i" },
|
|
46
|
+
{ mode: "+", attribute: "a" },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([
|
|
51
|
+
{ input: "xi", description: "invalid mode" },
|
|
52
|
+
{ input: "+", description: "missing attribute" },
|
|
53
|
+
{ input: "+1", description: "invalid attribute characters" },
|
|
54
|
+
{ input: "+ i", description: "attribute with space in mode" },
|
|
55
|
+
])("throws error for $description: $input", ({ input }) => {
|
|
56
|
+
expect(() => parseAttributes(input)).toThrow(/Invalid attribute syntax/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("supportsChattr", () => {
|
|
61
|
+
it.each([
|
|
62
|
+
"darwin",
|
|
63
|
+
"win32",
|
|
64
|
+
] as const)("returns false on $platform", async (platform) => {
|
|
65
|
+
const originalPlatform = process.platform;
|
|
66
|
+
Object.defineProperty(process, "platform", { value: platform });
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
expect(await supportsChattr()).toBe(false);
|
|
70
|
+
} finally {
|
|
71
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns boolean on Linux (depends on chattr availability)", async () => {
|
|
76
|
+
if (process.platform === "linux") {
|
|
77
|
+
const result = await supportsChattr();
|
|
78
|
+
expect(typeof result).toBe("boolean");
|
|
79
|
+
} else {
|
|
80
|
+
expect(await supportsChattr()).toBe(false);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("attribute change types", () => {
|
|
86
|
+
it("correctly identifies all mode types", () => {
|
|
87
|
+
const result = parseAttributes("+i -a =d");
|
|
88
|
+
expect(result[0].mode).toBe("+");
|
|
89
|
+
expect(result[1].mode).toBe("-");
|
|
90
|
+
expect(result[2].mode).toBe("=");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("correctly identifies attribute values", () => {
|
|
94
|
+
const result = parseAttributes("+immutable -appendonly +nodump");
|
|
95
|
+
expect(result[0].attribute).toBe("immutable");
|
|
96
|
+
expect(result[1].attribute).toBe("appendonly");
|
|
97
|
+
expect(result[2].attribute).toBe("nodump");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
generateTemplateVariables,
|
|
7
|
+
replaceTemplateVariables,
|
|
8
|
+
generateBackupPaths,
|
|
9
|
+
detectGitRepo,
|
|
10
|
+
createBackup,
|
|
11
|
+
findAvailableBackupPath,
|
|
12
|
+
performBackup,
|
|
13
|
+
parseBackupOption,
|
|
14
|
+
type BackupOptions,
|
|
15
|
+
} from "../src/backup.ts";
|
|
16
|
+
|
|
17
|
+
describe("Backup", () => {
|
|
18
|
+
let tempDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "blockinfile-backup-test-"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("generateTemplateVariables", () => {
|
|
29
|
+
it("should generate basic date/time variables", () => {
|
|
30
|
+
const variables = generateTemplateVariables();
|
|
31
|
+
|
|
32
|
+
expect(variables.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
33
|
+
expect(variables.time).toMatch(/^\d{6}$/);
|
|
34
|
+
expect(variables.iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
35
|
+
expect(variables.epoch).toMatch(/^\d+$/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should generate hash variables with content", () => {
|
|
39
|
+
const content = "test content";
|
|
40
|
+
const variables = generateTemplateVariables(content);
|
|
41
|
+
|
|
42
|
+
expect(variables.md5).toBeDefined();
|
|
43
|
+
expect(variables.md5?.length).toBe(8);
|
|
44
|
+
expect(variables.sha256).toBeDefined();
|
|
45
|
+
expect(variables.sha256?.length).toBe(8);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should not generate hash variables without content", () => {
|
|
49
|
+
const variables = generateTemplateVariables();
|
|
50
|
+
|
|
51
|
+
expect(variables.md5).toBeUndefined();
|
|
52
|
+
expect(variables.sha256).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("replaceTemplateVariables", () => {
|
|
57
|
+
it("should replace date variable", () => {
|
|
58
|
+
const variables = generateTemplateVariables();
|
|
59
|
+
const template = "backup-{date}";
|
|
60
|
+
const result = replaceTemplateVariables(template, variables);
|
|
61
|
+
|
|
62
|
+
expect(result).toBe(`backup-${variables.date}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should replace multiple variables", () => {
|
|
66
|
+
const variables = generateTemplateVariables();
|
|
67
|
+
const template = "backup-{date}-{time}";
|
|
68
|
+
const result = replaceTemplateVariables(template, variables);
|
|
69
|
+
|
|
70
|
+
expect(result).toBe(`backup-${variables.date}-${variables.time}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should replace hash variables", () => {
|
|
74
|
+
const variables = generateTemplateVariables("test");
|
|
75
|
+
const template = "backup-{md5}";
|
|
76
|
+
const result = replaceTemplateVariables(template, variables);
|
|
77
|
+
|
|
78
|
+
expect(result).toBe(`backup-${variables.md5}`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should handle missing variables", () => {
|
|
82
|
+
const variables = generateTemplateVariables();
|
|
83
|
+
const template = "backup-{unknown}";
|
|
84
|
+
const result = replaceTemplateVariables(template, variables);
|
|
85
|
+
|
|
86
|
+
expect(result).toBe("backup-{unknown}");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("generateBackupPaths", () => {
|
|
91
|
+
it("should generate single backup path", () => {
|
|
92
|
+
const variables = generateTemplateVariables();
|
|
93
|
+
const backupPath = generateBackupPaths("file.txt", ".{date}.backup", variables);
|
|
94
|
+
|
|
95
|
+
expect(backupPath).toBe(`file.txt.${variables.date}.backup`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should use backup directory when specified", () => {
|
|
99
|
+
const variables = generateTemplateVariables();
|
|
100
|
+
const backupPath = generateBackupPaths(
|
|
101
|
+
path.join("some", "dir", "file.txt"),
|
|
102
|
+
".{date}.backup",
|
|
103
|
+
variables,
|
|
104
|
+
"/backups",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(backupPath).toBe(`/backups/file.txt.${variables.date}.backup`);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("detectGitRepo", () => {
|
|
112
|
+
it("should detect git repository", async () => {
|
|
113
|
+
await fs.mkdir(path.join(tempDir, ".git"));
|
|
114
|
+
const isGitRepo = await detectGitRepo(tempDir);
|
|
115
|
+
|
|
116
|
+
expect(isGitRepo).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should not detect non-git directory", async () => {
|
|
120
|
+
const isGitRepo = await detectGitRepo(tempDir);
|
|
121
|
+
|
|
122
|
+
expect(isGitRepo).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("createBackup", () => {
|
|
127
|
+
it("should create backup file", async () => {
|
|
128
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
129
|
+
const backupFile = path.join(tempDir, "file.txt.backup");
|
|
130
|
+
|
|
131
|
+
await fs.writeFile(originalFile, "original content");
|
|
132
|
+
await createBackup(originalFile, backupFile);
|
|
133
|
+
|
|
134
|
+
const backupContent = await fs.readFile(backupFile, "utf-8");
|
|
135
|
+
expect(backupContent).toBe("original content");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should add to .gitignore in git repo", async () => {
|
|
139
|
+
await fs.mkdir(path.join(tempDir, ".git"));
|
|
140
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
141
|
+
const backupFile = path.join(tempDir, "file.txt.backup");
|
|
142
|
+
|
|
143
|
+
await fs.writeFile(originalFile, "original content");
|
|
144
|
+
await createBackup(originalFile, backupFile);
|
|
145
|
+
|
|
146
|
+
const gitignorePath = path.join(tempDir, ".gitignore");
|
|
147
|
+
const gitignore = await fs.readFile(gitignorePath, "utf-8");
|
|
148
|
+
|
|
149
|
+
expect(gitignore).toContain("file.txt.backup");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should append to existing .gitignore", async () => {
|
|
153
|
+
await fs.mkdir(path.join(tempDir, ".git"));
|
|
154
|
+
const gitignorePath = path.join(tempDir, ".gitignore");
|
|
155
|
+
await fs.writeFile(gitignorePath, "existing-pattern\n");
|
|
156
|
+
|
|
157
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
158
|
+
const backupFile = path.join(tempDir, "file.txt.backup");
|
|
159
|
+
|
|
160
|
+
await fs.writeFile(originalFile, "original content");
|
|
161
|
+
await createBackup(originalFile, backupFile);
|
|
162
|
+
|
|
163
|
+
const gitignore = await fs.readFile(gitignorePath, "utf-8");
|
|
164
|
+
|
|
165
|
+
expect(gitignore).toContain("existing-pattern");
|
|
166
|
+
expect(gitignore).toContain("file.txt.backup");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("findAvailableBackupPath", () => {
|
|
171
|
+
it("should return original path if file does not exist", async () => {
|
|
172
|
+
const backupPath = path.join(tempDir, "file.txt.backup");
|
|
173
|
+
const result = await findAvailableBackupPath(backupPath);
|
|
174
|
+
|
|
175
|
+
expect(result).toBe(backupPath);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should add iteration suffix in iterate mode", async () => {
|
|
179
|
+
const backupPath = path.join(tempDir, "file.txt.backup");
|
|
180
|
+
await fs.writeFile(backupPath, "backup");
|
|
181
|
+
|
|
182
|
+
const result = await findAvailableBackupPath(backupPath, "iterate");
|
|
183
|
+
|
|
184
|
+
expect(result).toBe(`${backupPath}.1`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should handle multiple iterations", async () => {
|
|
188
|
+
const backupPath = path.join(tempDir, "file.txt.backup");
|
|
189
|
+
await fs.writeFile(backupPath, "backup");
|
|
190
|
+
await fs.writeFile(`${backupPath}.1`, "backup1");
|
|
191
|
+
await fs.writeFile(`${backupPath}.2`, "backup2");
|
|
192
|
+
|
|
193
|
+
const result = await findAvailableBackupPath(backupPath, "iterate");
|
|
194
|
+
|
|
195
|
+
expect(result).toBe(`${backupPath}.3`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should return same path in overwrite mode", async () => {
|
|
199
|
+
const backupPath = path.join(tempDir, "file.txt.backup");
|
|
200
|
+
await fs.writeFile(backupPath, "backup");
|
|
201
|
+
|
|
202
|
+
const result = await findAvailableBackupPath(backupPath, "overwrite");
|
|
203
|
+
|
|
204
|
+
expect(result).toBe(backupPath);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should return null in fail mode when file exists", async () => {
|
|
208
|
+
const backupPath = path.join(tempDir, "file.txt.backup");
|
|
209
|
+
await fs.writeFile(backupPath, "backup");
|
|
210
|
+
|
|
211
|
+
const result = await findAvailableBackupPath(backupPath, "fail");
|
|
212
|
+
|
|
213
|
+
expect(result).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("parseBackupOption", () => {
|
|
218
|
+
it("should parse single backup suffix", () => {
|
|
219
|
+
const options = parseBackupOption(["bak"]);
|
|
220
|
+
|
|
221
|
+
expect(options.enabled).toBe(true);
|
|
222
|
+
expect(options.suffix).toBe(".bak");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should parse multiple backup suffixes", () => {
|
|
226
|
+
const options = parseBackupOption(["foo", "bar"]);
|
|
227
|
+
|
|
228
|
+
expect(options.enabled).toBe(true);
|
|
229
|
+
expect(options.suffix).toBe(".foo.bar");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("should add dot if not present", () => {
|
|
233
|
+
const options = parseBackupOption(["backup"]);
|
|
234
|
+
|
|
235
|
+
expect(options.enabled).toBe(true);
|
|
236
|
+
expect(options.suffix).toBe(".backup");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should preserve existing dot", () => {
|
|
240
|
+
const options = parseBackupOption([".backup"]);
|
|
241
|
+
|
|
242
|
+
expect(options.enabled).toBe(true);
|
|
243
|
+
expect(options.suffix).toBe(".backup");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should handle empty array", () => {
|
|
247
|
+
const options = parseBackupOption([]);
|
|
248
|
+
|
|
249
|
+
expect(options.enabled).toBe(false);
|
|
250
|
+
expect(options.suffix).toBe("");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should handle undefined", () => {
|
|
254
|
+
const options = parseBackupOption(undefined);
|
|
255
|
+
|
|
256
|
+
expect(options.enabled).toBe(false);
|
|
257
|
+
expect(options.suffix).toBe("");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("performBackup", () => {
|
|
262
|
+
it("should not create backup when disabled", async () => {
|
|
263
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
264
|
+
await fs.writeFile(originalFile, "content");
|
|
265
|
+
|
|
266
|
+
const options: BackupOptions = {
|
|
267
|
+
enabled: false,
|
|
268
|
+
suffix: "",
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const backup = await performBackup(originalFile, options);
|
|
272
|
+
|
|
273
|
+
expect(backup).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("should create single backup", async () => {
|
|
277
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
278
|
+
await fs.writeFile(originalFile, "content");
|
|
279
|
+
|
|
280
|
+
const options: BackupOptions = {
|
|
281
|
+
enabled: true,
|
|
282
|
+
suffix: ".backup",
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const backup = await performBackup(originalFile, options);
|
|
286
|
+
|
|
287
|
+
expect(backup).toBe(path.join(tempDir, "file.txt.backup"));
|
|
288
|
+
|
|
289
|
+
const backupContent = await fs.readFile(backup!, "utf-8");
|
|
290
|
+
expect(backupContent).toBe("content");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("should create backup with concatenated suffix", async () => {
|
|
294
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
295
|
+
await fs.writeFile(originalFile, "content");
|
|
296
|
+
|
|
297
|
+
const options: BackupOptions = {
|
|
298
|
+
enabled: true,
|
|
299
|
+
suffix: ".foo.bar",
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const backup = await performBackup(originalFile, options);
|
|
303
|
+
|
|
304
|
+
expect(backup).toBe(path.join(tempDir, "file.txt.foo.bar"));
|
|
305
|
+
|
|
306
|
+
const backupContent = await fs.readFile(backup!, "utf-8");
|
|
307
|
+
expect(backupContent).toBe("content");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should use custom backup directory", async () => {
|
|
311
|
+
const backupDir = path.join(tempDir, "backups");
|
|
312
|
+
await fs.mkdir(backupDir);
|
|
313
|
+
|
|
314
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
315
|
+
await fs.writeFile(originalFile, "content");
|
|
316
|
+
|
|
317
|
+
const options: BackupOptions = {
|
|
318
|
+
enabled: true,
|
|
319
|
+
suffix: ".backup",
|
|
320
|
+
backupDir,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const backup = await performBackup(originalFile, options);
|
|
324
|
+
|
|
325
|
+
expect(backup).toBe(path.join(backupDir, "file.txt.backup"));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should iterate when backup exists", async () => {
|
|
329
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
330
|
+
await fs.writeFile(originalFile, "content");
|
|
331
|
+
|
|
332
|
+
const existingBackup = path.join(tempDir, "file.txt.backup");
|
|
333
|
+
await fs.writeFile(existingBackup, "old backup");
|
|
334
|
+
|
|
335
|
+
const options: BackupOptions = {
|
|
336
|
+
enabled: true,
|
|
337
|
+
suffix: ".backup",
|
|
338
|
+
stateOnFail: "iterate",
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const backup = await performBackup(originalFile, options);
|
|
342
|
+
|
|
343
|
+
expect(backup).toBe(`${existingBackup}.1`);
|
|
344
|
+
|
|
345
|
+
const originalBackupContent = await fs.readFile(existingBackup, "utf-8");
|
|
346
|
+
expect(originalBackupContent).toBe("old backup");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("should throw error when fail mode and backup exists", async () => {
|
|
350
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
351
|
+
await fs.writeFile(originalFile, "content");
|
|
352
|
+
|
|
353
|
+
const existingBackup = path.join(tempDir, "file.txt.backup");
|
|
354
|
+
await fs.writeFile(existingBackup, "old backup");
|
|
355
|
+
|
|
356
|
+
const options: BackupOptions = {
|
|
357
|
+
enabled: true,
|
|
358
|
+
suffix: ".backup",
|
|
359
|
+
stateOnFail: "fail",
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
await expect(performBackup(originalFile, options)).rejects.toThrow();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("should overwrite when backup exists in overwrite mode", async () => {
|
|
366
|
+
const originalFile = path.join(tempDir, "file.txt");
|
|
367
|
+
await fs.writeFile(originalFile, "content");
|
|
368
|
+
|
|
369
|
+
const existingBackup = path.join(tempDir, "file.txt.backup");
|
|
370
|
+
await fs.writeFile(existingBackup, "old backup");
|
|
371
|
+
|
|
372
|
+
const options: BackupOptions = {
|
|
373
|
+
enabled: true,
|
|
374
|
+
suffix: ".backup",
|
|
375
|
+
stateOnFail: "overwrite",
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const backup = await performBackup(originalFile, options);
|
|
379
|
+
|
|
380
|
+
expect(backup).toBe(existingBackup);
|
|
381
|
+
|
|
382
|
+
const backupContent = await fs.readFile(existingBackup, "utf-8");
|
|
383
|
+
expect(backupContent).toBe("content");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
});
|