Skip to content

Commit e89449c

Browse files
committed
feat(FR-1365): auto merge i18n conflicts (#4136)
resolves #4135 ([FR-1365](https://lablup.atlassian.net/browse/FR-1365)) ### Add JSON merge driver for better conflict resolution Enhanced the i18n JSON merge driver to automatically resolve conflicts during Git merges and added a comprehensive test suite to ensure reliability. ### Driver Settings Even if `.gitconfig` is included in the remote branch, it may not be applied due to security concerns. Save the contents of `.gitconfig` as-is, then verify that the json-i18n driver is configured for the i18n file using the command `git check-attr merge <i18n-json-path>`. - `git config --local include.path ../.gitconfig` ### Changes 1. Merge Driver Improvements (`scripts/i18n-merge-driver.js`) - **Added key sorting priority**: Ensures `$schema` key always appears at the top of JSON objects - **Consistent key ordering**: Applied stable sorting (lowercase → uppercase → special characters) - **Fixed Git configuration**: Corrected merge driver setup to match `.gitattributes` (`merge.json-i18n.driver`) ### Merge Case Scenarios ✅ Automatically Successful Cases 1. **Different keys modified** ```json // Base: {"key1": "value1"} // Ours: {"key1": "value1", "newKey": "ours"} // Theirs: {"key1": "value1", "anotherKey": "theirs"} // → Auto-merge successful 2. Same key modified identically ```json // Base: {"key1": "old"} // Ours: {"key1": "new"} // Theirs: {"key1": "new"} // → Auto-merge successful 3. One-sided modifications ```json // Base: {"key1": "value1", "key2": "value2"} // Ours: {"key1": "modified", "key2": "value2"} // Theirs: {"key1": "value1", "key2": "value2"} // → Auto-merge successful 4. Key addition/deletion combinations ```json // Base: {"key1": "value1", "toDelete": "will be removed"} // Ours: {"key1": "value1"} // Key deleted // Theirs: {"key1": "value1", "toDelete": "will be removed", "newKey": "added"} // → Auto-merge successful ❌ Conflict Cases 1. Same key modified differently ```json // Base: {"key1": "original"} // Ours: {"key1": "changed by ours"} // Theirs: {"key1": "changed by theirs"} // → Conflict occurs, manual resolution required 2. Nested object conflicts ```json // Base: {"comp:Button": {"label": "Click"}} // Ours: {"comp:Button": {"label": "클릭"}} // Theirs: {"comp:Button": {"label": "Press"}} // → Conflict occurs, manual resolution required 3. Mixed scenarios (partial success, partial conflict) ```json // Base: {"safe": "value", "conflict": "original"} // Ours: {"safe": "value", "conflict": "ours", "newKey": "added"} // Theirs: {"safe": "value", "conflict": "theirs", "anotherKey": "added"} // → Conflict occurs (due to conflict key), entire merge fails This enhancement enables automatic handling of most i18n JSON file merge conflicts while providing test coverage to ensure stability across complex scenarios. **Checklist:** (if applicable) - [ ] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after [FR-1365]: https://lablup.atlassian.net/browse/FR-1365?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent f1d6778 commit e89449c

File tree

5 files changed

+484
-1
lines changed

5 files changed

+484
-1
lines changed

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Custom merge drivers for conflict resolution
2+
# Specifically target i18n translation files to avoid trailing comma conflicts
3+
resources/i18n/**/*.json merge=json-i18n
4+
packages/backend.ai-ui/src/locale/*.json merge=json-i18n

.gitconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[merge "json-i18n"]
2+
name = i18n JSON 3-way merge (sorted, conflict on same path)
3+
driver = node scripts/i18n-merge-driver.js %O %A %B

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@
228228
"json",
229229
"node"
230230
],
231-
"rootDir": "src"
231+
"rootDir": "./",
232+
"roots": [
233+
"scripts",
234+
"src"
235+
]
232236
}
233237
}

scripts/i18n-merge-driver.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
const { readFileSync, writeFileSync } = require("fs");
2+
3+
function readJSON(p) {
4+
return JSON.parse(readFileSync(p, "utf8"));
5+
}
6+
function isObject(v) {
7+
return v && typeof v === "object" && !Array.isArray(v);
8+
}
9+
function deepEqual(a, b) {
10+
return JSON.stringify(a) === JSON.stringify(b);
11+
}
12+
// Use prettier to sort JSON objects
13+
async function sortObject(obj) {
14+
// Lazy load prettier to avoid issues in test environment
15+
const prettier = require("prettier");
16+
17+
// Format the JSON using prettier with inline config
18+
const formatted = await prettier.format(JSON.stringify(obj), {
19+
parser: "json",
20+
plugins: ["prettier-plugin-sort-json"],
21+
jsonRecursiveSort: true,
22+
});
23+
24+
return JSON.parse(formatted.trim());
25+
}
26+
// collect changed diff
27+
function diffLeaves(base, v, path = [], acc = []) {
28+
if (deepEqual(base, v)) return acc; // no change
29+
if (isObject(base) && isObject(v)) {
30+
const keys = new Set([...Object.keys(base), ...Object.keys(v)]);
31+
for (const key of keys) {
32+
const baseValue = base[key];
33+
const newValue = v[key];
34+
diffLeaves(baseValue, newValue, [...path, key], acc);
35+
}
36+
return acc;
37+
} else {
38+
acc.push({ path, value: v });
39+
return acc;
40+
}
41+
}
42+
43+
function setAt(root, path, value) {
44+
if (path.length === 0) return value;
45+
let current = root;
46+
for (let i = 0; i < path.length - 1; i++) {
47+
const k = path[i];
48+
if (!isObject(current[k])) current[k] = {};
49+
current = current[k];
50+
}
51+
const last = path[path.length - 1];
52+
if (value === undefined) {
53+
// delete
54+
delete current[last];
55+
} else {
56+
current[last] = value;
57+
}
58+
return root;
59+
}
60+
function pathKey(path) {
61+
return path.join("\x1f");
62+
}
63+
64+
// Export functions for testing
65+
module.exports = {
66+
readJSON,
67+
isObject,
68+
deepEqual,
69+
diffLeaves,
70+
setAt,
71+
pathKey,
72+
};
73+
74+
// Only run the main logic if this file is executed directly
75+
if (require.main === module) {
76+
(async () => {
77+
const [, , basePath, oursPath, theirsPath] = process.argv;
78+
// parsing + sorting
79+
const base = await sortObject(readJSON(basePath));
80+
const ours = await sortObject(readJSON(oursPath));
81+
const theirs = await sortObject(readJSON(theirsPath));
82+
83+
// collect changed diff
84+
const dOurs = diffLeaves(base, ours);
85+
const dTheirs = diffLeaves(base, theirs);
86+
87+
// occur conflicts when both sides have changes
88+
const mOurs = new Map(dOurs.map((e) => [pathKey(e.path), e]));
89+
const mTheirs = new Map(dTheirs.map((e) => [pathKey(e.path), e]));
90+
for (const [k, o] of mOurs) {
91+
if (mTheirs.has(k)) {
92+
const t = mTheirs.get(k);
93+
if (!deepEqual(o.value, t.value)) {
94+
console.error("i18n JSON merge conflict at path:", o.path.join("."));
95+
process.exit(1);
96+
}
97+
}
98+
}
99+
100+
// merge: base -> ours -> theirs
101+
let result = JSON.parse(JSON.stringify(base));
102+
for (const e of dOurs) result = setAt(result, e.path, e.value);
103+
for (const e of dTheirs) result = setAt(result, e.path, e.value);
104+
105+
result = await sortObject(result);
106+
writeFileSync(oursPath, JSON.stringify(result, null, 2) + "\n", "utf8");
107+
process.exit(0);
108+
})();
109+
}

0 commit comments

Comments
 (0)