Skip to content

Commit 8c4f736

Browse files
cherkanovartvrcprl
andauthored
feat: unify lockfile deduplication logic (#1873)
* feat: unify lockfile deduplication logic * refactor: remove outdated deduplication function documentation from lockfile.ts * test: verify key preservation across path patterns in lockfile dedup --------- Co-authored-by: Veronica Prilutskaya <veronica@lingo.dev>
1 parent be5896a commit 8c4f736

File tree

4 files changed

+372
-12
lines changed

4 files changed

+372
-12
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
Refactor lockfile deduplication logic to use a single universal function instead of three duplicate implementations. This improves code maintainability and ensures consistent behavior across all lockfile operations. The deduplication automatically handles Git merge conflicts in i18n.lock files.

packages/cli/src/cli/utils/delta.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { md5 } from "./md5";
44
import { tryReadFile, writeFile, checkIfFileExists } from "../utils/fs";
55
import * as path from "path";
66
import YAML from "yaml";
7+
import { deduplicateLockfileYaml } from "./lockfile";
78

89
const LockSchema = z.object({
910
version: z.literal(1).prefault(1),
@@ -91,14 +92,28 @@ export function createDeltaProcessor(fileKey: string) {
9192
},
9293
async loadLock() {
9394
const lockfileContent = tryReadFile(lockfilePath, null);
94-
const lockfileYaml = lockfileContent ? YAML.parse(lockfileContent) : null;
95-
const lockfileData: z.infer<typeof LockSchema> = lockfileYaml
96-
? LockSchema.parse(lockfileYaml)
97-
: {
98-
version: 1,
99-
checksums: {},
100-
};
101-
return lockfileData;
95+
96+
if (!lockfileContent) {
97+
return {
98+
version: 1,
99+
checksums: {},
100+
} as const;
101+
}
102+
103+
// Deduplicate using the universal function
104+
const { deduplicatedContent, duplicatesRemoved } = deduplicateLockfileYaml(lockfileContent);
105+
106+
// Write back to disk if duplicates were found
107+
if (duplicatesRemoved > 0) {
108+
writeFile(lockfilePath, deduplicatedContent);
109+
console.log(
110+
`Removed ${duplicatesRemoved} duplicate ${duplicatesRemoved === 1 ? "entry" : "entries"} from i18n.lock`,
111+
);
112+
}
113+
114+
// Parse to validated JavaScript object
115+
const parsed = LockSchema.parse(YAML.parse(deduplicatedContent));
116+
return parsed;
102117
},
103118
async saveLock(lockData: LockData) {
104119
const lockfileYaml = YAML.stringify(lockData);
@@ -107,12 +122,14 @@ export function createDeltaProcessor(fileKey: string) {
107122
async loadChecksums() {
108123
const id = md5(fileKey);
109124
const lockfileData = await this.loadLock();
110-
return lockfileData.checksums[id] || {};
125+
const checksums = lockfileData.checksums as Record<string, Record<string, string>>;
126+
return checksums[id] || {};
111127
},
112128
async saveChecksums(checksums: Record<string, string>) {
113129
const id = md5(fileKey);
114130
const lockfileData = await this.loadLock();
115-
lockfileData.checksums[id] = checksums;
131+
const lockChecksums = lockfileData.checksums as Record<string, Record<string, string>>;
132+
lockChecksums[id] = checksums;
116133
await this.saveLock(lockfileData);
117134
},
118135
async createChecksums(sourceData: Record<string, any>) {
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { describe, it, expect } from "vitest";
2+
import { deduplicateLockfileYaml } from "./lockfile";
3+
import YAML from "yaml";
4+
5+
describe("deduplicateLockfileYaml", () => {
6+
it("should return unchanged content when there are no duplicates", () => {
7+
const yamlContent = `version: 1
8+
checksums:
9+
pathHash1:
10+
key1: checksum1
11+
key2: checksum2
12+
pathHash2:
13+
key3: checksum3
14+
key4: checksum4
15+
`;
16+
17+
const result = deduplicateLockfileYaml(yamlContent);
18+
19+
expect(result.duplicatesRemoved).toBe(0);
20+
21+
// Parse both to compare structure
22+
const originalParsed = YAML.parse(yamlContent);
23+
const resultParsed = YAML.parse(result.deduplicatedContent);
24+
expect(resultParsed).toEqual(originalParsed);
25+
});
26+
27+
it("should remove duplicate keys within a path pattern", () => {
28+
// Create YAML with actual duplicate keys (not possible in JS object literals)
29+
const yamlContent = `version: 1
30+
checksums:
31+
pathHash1:
32+
key1: checksum1
33+
key2: checksum2
34+
key1: checksum1_duplicate
35+
`;
36+
37+
const result = deduplicateLockfileYaml(yamlContent);
38+
39+
expect(result.duplicatesRemoved).toBe(1);
40+
41+
const parsed = YAML.parse(result.deduplicatedContent);
42+
// Last occurrence should win
43+
expect(parsed.checksums.pathHash1.key1).toBe("checksum1_duplicate");
44+
expect(parsed.checksums.pathHash1.key2).toBe("checksum2");
45+
expect(Object.keys(parsed.checksums.pathHash1)).toHaveLength(2);
46+
});
47+
48+
it("should handle conflicting duplicates (same key, different checksums) and keep last occurrence", () => {
49+
const yamlContent = `version: 1
50+
checksums:
51+
pathHash1:
52+
key1: checksum1
53+
key2: checksum2
54+
key1: checksum_updated
55+
key2: checksum2_updated
56+
`;
57+
58+
const result = deduplicateLockfileYaml(yamlContent);
59+
60+
expect(result.duplicatesRemoved).toBe(2);
61+
62+
const parsed = YAML.parse(result.deduplicatedContent);
63+
// The last occurrences should win
64+
expect(parsed.checksums.pathHash1.key1).toBe("checksum_updated");
65+
expect(parsed.checksums.pathHash1.key2).toBe("checksum2_updated");
66+
expect(Object.keys(parsed.checksums.pathHash1)).toHaveLength(2);
67+
});
68+
69+
it("should handle multiple duplicates of the same key", () => {
70+
const yamlContent = `version: 1
71+
checksums:
72+
pathHash1:
73+
key1: checksum1
74+
key1: checksum2
75+
key1: checksum3
76+
key1: checksum_final
77+
key2: checksum_other
78+
`;
79+
80+
const result = deduplicateLockfileYaml(yamlContent);
81+
82+
expect(result.duplicatesRemoved).toBe(3); // 4 occurrences - 1 = 3 duplicates removed
83+
84+
const parsed = YAML.parse(result.deduplicatedContent);
85+
expect(parsed.checksums.pathHash1.key1).toBe("checksum_final");
86+
expect(parsed.checksums.pathHash1.key2).toBe("checksum_other");
87+
expect(Object.keys(parsed.checksums.pathHash1)).toHaveLength(2);
88+
});
89+
90+
it("should deduplicate across multiple path patterns independently", () => {
91+
const yamlContent = `version: 1
92+
checksums:
93+
pathHash1:
94+
key1: checksum1
95+
key2: checksum2
96+
key1: checksum1_duplicate
97+
pathHash2:
98+
key1: checksumA
99+
key3: checksumB
100+
key1: checksumA_duplicate
101+
`;
102+
103+
const result = deduplicateLockfileYaml(yamlContent);
104+
105+
expect(result.duplicatesRemoved).toBe(2); // One duplicate in each path pattern
106+
107+
const parsed = YAML.parse(result.deduplicatedContent);
108+
expect(parsed.checksums.pathHash1.key1).toBe("checksum1_duplicate");
109+
expect(parsed.checksums.pathHash2.key1).toBe("checksumA_duplicate");
110+
});
111+
112+
it("should preserve same key names across different path patterns (no cross-block deduplication)", () => {
113+
const yamlContent = `version: 1
114+
checksums:
115+
pathHash1:
116+
greeting: checksum1
117+
button: checksum2
118+
pathHash2:
119+
greeting: checksum3
120+
button: checksum4
121+
`;
122+
123+
const result = deduplicateLockfileYaml(yamlContent);
124+
125+
expect(result.duplicatesRemoved).toBe(0);
126+
127+
const parsed = YAML.parse(result.deduplicatedContent);
128+
expect(parsed.checksums.pathHash1.greeting).toBe("checksum1");
129+
expect(parsed.checksums.pathHash1.button).toBe("checksum2");
130+
expect(parsed.checksums.pathHash2.greeting).toBe("checksum3");
131+
expect(parsed.checksums.pathHash2.button).toBe("checksum4");
132+
expect(Object.keys(parsed.checksums.pathHash1)).toHaveLength(2);
133+
expect(Object.keys(parsed.checksums.pathHash2)).toHaveLength(2);
134+
});
135+
136+
it("should handle empty lockfile", () => {
137+
const yamlContent = `version: 1
138+
checksums: {}
139+
`;
140+
141+
const result = deduplicateLockfileYaml(yamlContent);
142+
143+
expect(result.duplicatesRemoved).toBe(0);
144+
145+
const parsed = YAML.parse(result.deduplicatedContent);
146+
expect(parsed.checksums).toEqual({});
147+
});
148+
149+
it("should handle path pattern with empty checksums", () => {
150+
const yamlContent = `version: 1
151+
checksums:
152+
pathHash1: {}
153+
pathHash2:
154+
key1: checksum1
155+
`;
156+
157+
const result = deduplicateLockfileYaml(yamlContent);
158+
159+
expect(result.duplicatesRemoved).toBe(0);
160+
161+
const parsed = YAML.parse(result.deduplicatedContent);
162+
expect(parsed.checksums.pathHash1).toEqual({});
163+
expect(parsed.checksums.pathHash2.key1).toBe("checksum1");
164+
});
165+
166+
it("should be idempotent (running multiple times produces same result)", () => {
167+
const yamlContent = `version: 1
168+
checksums:
169+
pathHash1:
170+
key1: checksum1
171+
key2: checksum2
172+
key1: checksum1_duplicate
173+
`;
174+
175+
const result1 = deduplicateLockfileYaml(yamlContent);
176+
const result2 = deduplicateLockfileYaml(result1.deduplicatedContent);
177+
const result3 = deduplicateLockfileYaml(result2.deduplicatedContent);
178+
179+
expect(result1.duplicatesRemoved).toBe(1);
180+
expect(result2.duplicatesRemoved).toBe(0);
181+
expect(result3.duplicatesRemoved).toBe(0);
182+
183+
const parsed1 = YAML.parse(result1.deduplicatedContent);
184+
const parsed2 = YAML.parse(result2.deduplicatedContent);
185+
const parsed3 = YAML.parse(result3.deduplicatedContent);
186+
187+
expect(parsed1).toEqual(parsed2);
188+
expect(parsed2).toEqual(parsed3);
189+
});
190+
191+
it("should correctly count duplicates removed across multiple patterns", () => {
192+
const yamlContent = `version: 1
193+
checksums:
194+
pathHash1:
195+
key1: checksum1
196+
key2: checksum2
197+
key1: checksum1_dup
198+
key3: checksum3
199+
key2: checksum2_dup
200+
pathHash2:
201+
keyA: checksumA
202+
keyB: checksumB
203+
keyA: checksumA_dup
204+
`;
205+
206+
const result = deduplicateLockfileYaml(yamlContent);
207+
208+
// pathHash1 has 2 duplicates (key1, key2), pathHash2 has 1 duplicate (keyA)
209+
expect(result.duplicatesRemoved).toBe(3);
210+
});
211+
212+
it("should preserve version field", () => {
213+
const yamlContent = `version: 1
214+
checksums:
215+
pathHash1:
216+
key1: checksum1
217+
`;
218+
219+
const result = deduplicateLockfileYaml(yamlContent);
220+
221+
expect(result.duplicatesRemoved).toBe(0);
222+
223+
const parsed = YAML.parse(result.deduplicatedContent);
224+
expect(parsed.version).toBe(1);
225+
});
226+
227+
it("should handle many keys in a single path pattern", () => {
228+
const keys = Array.from({ length: 100 }, (_, i) => ` key${i}: checksum${i}`).join('\n');
229+
const yamlContent = `version: 1
230+
checksums:
231+
pathHash1:
232+
${keys}
233+
`;
234+
235+
const result = deduplicateLockfileYaml(yamlContent);
236+
237+
expect(result.duplicatesRemoved).toBe(0);
238+
239+
const parsed = YAML.parse(result.deduplicatedContent);
240+
expect(Object.keys(parsed.checksums.pathHash1)).toHaveLength(100);
241+
});
242+
243+
it("should handle Git merge conflict scenario", () => {
244+
// Simulates what might happen after a git merge with conflicts in i18n.lock
245+
const yamlContent = `version: 1
246+
checksums:
247+
pathHash1:
248+
greeting.hello: abc123
249+
greeting.goodbye: def456
250+
greeting.hello: xyz789
251+
button.submit: ghi012
252+
button.submit: jkl345
253+
button.cancel: mno678
254+
`;
255+
256+
const result = deduplicateLockfileYaml(yamlContent);
257+
258+
expect(result.duplicatesRemoved).toBe(2); // greeting.hello and button.submit duplicates
259+
260+
const parsed = YAML.parse(result.deduplicatedContent);
261+
expect(parsed.checksums.pathHash1["greeting.hello"]).toBe("xyz789");
262+
expect(parsed.checksums.pathHash1["greeting.goodbye"]).toBe("def456");
263+
expect(parsed.checksums.pathHash1["button.submit"]).toBe("jkl345");
264+
expect(parsed.checksums.pathHash1["button.cancel"]).toBe("mno678");
265+
expect(Object.keys(parsed.checksums.pathHash1)).toHaveLength(4);
266+
});
267+
});

0 commit comments

Comments
 (0)