Skip to content

Commit b4ecf61

Browse files
committed
feat: implement core git JSON conflict resolution system
- Add main resolver entry point with conflict processing pipeline - Implement JSON/YAML file parsing and serialization - Add intelligent merge strategies with conflict detection - Create backup system and file matching utilities - Add comprehensive logging and error handling - Include strategy resolver for custom merge logic - Add conflict reconstruction for unresolved conflicts - Update package configuration and dependencies
1 parent 6b5ffa4 commit b4ecf61

13 files changed

+694
-32
lines changed

lib/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"json5": "^2.2.3",
4444
"micromatch": "^4.0.8",
4545
"picomatch": "^4.0.3",
46-
"toml": "^3.0.0",
46+
"smol-toml": "^1.4.2",
4747
"tsup": "^8.5.0",
4848
"typescript": "^5.9.2",
4949
"vite-tsconfig-paths": "^5.1.4",
@@ -80,7 +80,7 @@
8080
"json5": "^2.2.3",
8181
"micromatch": "^4.0.8",
8282
"picomatch": "^4.0.3",
83-
"toml": "^3.0.0",
83+
"smol-toml": "^1.4.2",
8484
"yaml": "^2.8.1"
8585
},
8686
"peerDependenciesMeta": {
@@ -96,7 +96,7 @@
9696
"picomatch": {
9797
"optional": true
9898
},
99-
"toml": {
99+
"smol-toml": {
100100
"optional": true
101101
},
102102
"yaml": {

lib/src.zip

19 KB
Binary file not shown.

lib/src/conflict-helper.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { serialize } from "./file-serializer";
2+
import { DROP } from "./merger";
3+
4+
/** Remove DROP, replace undefined with conflict markers */
5+
const preprocessForConflicts = (node: any, path: string, conflicts: string[] = []): any => {
6+
if (node === DROP) {
7+
return undefined; // remove key later
8+
}
9+
if (node === undefined) {
10+
const marker = `__CONFLICT_MARKER::${path}__`;
11+
conflicts.push(path);
12+
return marker;
13+
}
14+
if (Array.isArray(node)) {
15+
return node
16+
.map((v, i) => preprocessForConflicts(v, `${path}[${i}]`, conflicts))
17+
.filter(v => v !== undefined);
18+
}
19+
if (node && typeof node === "object") {
20+
const out: Record<string, any> = {};
21+
for (const [k, v] of Object.entries(node)) {
22+
const val = preprocessForConflicts(v, path ? `${path}.${k}` : k, conflicts);
23+
if (val !== undefined) out[k] = val;
24+
}
25+
return out;
26+
}
27+
return node;
28+
};
29+
30+
/** Build conflict markers into serialized string */
31+
export const reconstructConflict = async (
32+
merged: any,
33+
ours: any,
34+
theirs: any,
35+
format: string,
36+
): Promise<string> => {
37+
const conflictPaths: string[] = [];
38+
const preprocessed = preprocessForConflicts(merged, "", conflictPaths);
39+
let serialized = await serialize(format, preprocessed);
40+
41+
for (const path of conflictPaths) {
42+
const oursVal = getByPath(ours, path);
43+
const theirsVal = getByPath(theirs, path);
44+
45+
const oursStr = await serialize(format, oursVal);
46+
const theirsStr = await serialize(format, theirsVal);
47+
48+
const block = [
49+
"<<<<<<< ours",
50+
indentBlock(oursStr, 2),
51+
"=======",
52+
indentBlock(theirsStr, 2),
53+
">>>>>>> theirs",
54+
].join("\n");
55+
56+
serialized = serialized.replace(
57+
JSON.stringify(`__CONFLICT_MARKER::${path}__`), // works for JSON
58+
block,
59+
);
60+
}
61+
62+
return serialized;
63+
};
64+
65+
const getByPath = (obj: any, path: string): any => {
66+
const parts = path
67+
.replace(/\[(\d+)\]/g, ".$1")
68+
.split(".")
69+
.filter(Boolean);
70+
let cur = obj;
71+
for (const p of parts) cur = cur?.[p];
72+
return cur;
73+
};
74+
75+
const indentBlock = (str: string, spaces: number) => {
76+
const pad = " ".repeat(spaces);
77+
return str
78+
.split("\n")
79+
.map(line => (line ? pad + line : line))
80+
.join("\n");
81+
};

lib/src/file-parser.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export interface ParsedConflict<T = unknown> {
1313
}
1414

1515
/** A parser function that takes a raw string and returns parsed content. */
16-
export type Parser = (input: string) => unknown;
16+
export type Parser = { name: string; parser: (input: string) => unknown };
1717

1818
/** Built-in parser identifiers or a custom parser function. */
1919
export type SupportedParsers = "json" | "json5" | "yaml" | "toml" | "xml" | Parser;
@@ -116,34 +116,34 @@ export const parseConflictContent = async <T = unknown>(
116116
return {
117117
ours: oursParsed as T,
118118
theirs: theirsParsed as T,
119-
format: typeof format === "function" ? "custom" : format,
119+
format: typeof format === "string" ? format : format.name,
120120
};
121121
};
122122

123+
const FILE_EXTENSION_TO_PARSER_MAP: Record<string, SupportedParsers> = {
124+
json: "json",
125+
json5: "json5",
126+
yaml: "yaml",
127+
yml: "yaml",
128+
toml: "toml",
129+
xml: "xml",
130+
};
131+
123132
/** Normalize parsers based on filename + options. */
124133
const normalizeParsers = (options: ParseConflictOptions): SupportedParsers[] => {
134+
if (Array.isArray(options.parsers)) return options.parsers;
135+
125136
if (options.parsers) {
126-
return Array.isArray(options.parsers)
127-
? options.parsers
128-
: options.parsers === "auto"
129-
? ["json", "json5", "yaml", "toml", "xml"]
130-
: [options.parsers];
137+
return options.parsers === "auto"
138+
? ["json", "json5", "yaml", "toml", "xml"]
139+
: [options.parsers];
131140
}
132141

133142
if (options.filename) {
134-
const ext = options.filename.split(".").pop()?.toLowerCase();
135-
switch (ext) {
136-
case "json":
137-
return ["json"];
138-
case "json5":
139-
return ["json5"];
140-
case "yaml":
141-
case "yml":
142-
return ["yaml"];
143-
case "toml":
144-
return ["toml"];
145-
case "xml":
146-
return ["xml"];
143+
const parserBasedOnExt =
144+
FILE_EXTENSION_TO_PARSER_MAP[options.filename.split(".").pop()?.toLowerCase() ?? ""];
145+
if (parserBasedOnExt) {
146+
return [parserBasedOnExt];
147147
}
148148
}
149149

@@ -157,7 +157,7 @@ const runParser = async (
157157
): Promise<[unknown, SupportedParsers]> => {
158158
for (const parser of parsers) {
159159
try {
160-
if (typeof parser === "function") return [parser(raw), parser];
160+
if (typeof parser !== "string") return [parser.parser(raw), parser];
161161
return [await parseFormat(parser, raw), parser];
162162
} catch (err) {
163163
console.debug(`Parser ${typeof parser === "function" ? "custom" : parser} failed:`, err);
@@ -194,7 +194,7 @@ const parseFormat = async (
194194
}
195195
case "toml": {
196196
try {
197-
const { parse } = await import("toml");
197+
const { parse } = await import("smol-toml");
198198
return parse(raw);
199199
} catch {
200200
throw new Error("toml parser not installed. Please install as peer dependency.");

lib/src/file-serializer.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const serialize = async (format: string, value: unknown): Promise<string> => {
2+
switch (format) {
3+
case "json":
4+
case "json5":
5+
return JSON.stringify(value, null, 2);
6+
case "yaml": {
7+
const { stringify } = await import("yaml");
8+
return stringify(value);
9+
}
10+
case "toml": {
11+
const { stringify } = await import("smol-toml");
12+
return stringify(value as any);
13+
}
14+
case "xml": {
15+
const { XMLBuilder } = await import("fast-xml-parser");
16+
return new XMLBuilder({}).build(value);
17+
}
18+
default:
19+
throw new Error(`Unknown format: ${format}`);
20+
}
21+
};

lib/src/index.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { execSync } from "node:child_process";
2+
import { parseConflictContent } from "./file-parser";
3+
import { serialize } from "./file-serializer";
4+
import { Conflict, mergeObject } from "./merger";
5+
import { normalizeConfig, NormalizedConfig } from "./normalizer";
6+
import { Config } from "./types";
7+
import { backupFile, listMatchingFiles } from "./utils";
8+
import fs from "node:fs/promises";
9+
import { reconstructConflict } from "./conflict-helper";
10+
11+
const _strategyCache = new Map<string, string[]>();
12+
13+
export const resolveConflicts = async (config: Config) => {
14+
const normalizedConfig: NormalizedConfig = await normalizeConfig(config);
15+
const filesEntries = await listMatchingFiles(normalizedConfig);
16+
await Promise.all(
17+
filesEntries.map(async ({ filePath, content }) => {
18+
const { theirs, ours, format } = await parseConflictContent(content, { filename: filePath });
19+
const conflicts: Conflict[] = [];
20+
const [merged] = await Promise.all([
21+
mergeObject({
22+
ours,
23+
theirs,
24+
base: undefined,
25+
filePath,
26+
conflicts,
27+
path: "",
28+
ctx: {
29+
config: normalizedConfig,
30+
strategies: normalizedConfig.customStrategies,
31+
_strategyCache,
32+
},
33+
}),
34+
backupFile(filePath),
35+
]);
36+
37+
if (conflicts.length === 0) {
38+
const serialized = await serialize(format, merged);
39+
await fs.writeFile(filePath, serialized, "utf8");
40+
execSync(`git add ${filePath}`);
41+
} else {
42+
const serialized = await reconstructConflict(merged, ours, theirs, format);
43+
await Promise.all([
44+
fs.writeFile(filePath, serialized, "utf8"),
45+
config.writeConflictSidecar
46+
? fs.writeFile(`${filePath}.conflict.json`, JSON.stringify(conflicts, null, 2))
47+
: null,
48+
]);
49+
}
50+
}),
51+
);
52+
};

lib/src/logger.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import fs from "fs";
2+
import path from "path";
3+
4+
type LogLevel = "info" | "warn" | "error" | "debug";
5+
6+
type Mode = "memory" | "stream";
7+
8+
interface LoggerConfig {
9+
mode?: Mode; // default: "memory"
10+
logDir?: string; // default: "logs"
11+
singleFile?: boolean; // default: false (per input file logs)
12+
levels?: {
13+
stdout?: LogLevel[]; // default: ["warn", "error"]
14+
file?: LogLevel[]; // default: ["info", "warn", "error"]
15+
};
16+
}
17+
18+
interface LogEntry {
19+
timestamp: string;
20+
level: LogLevel;
21+
message: string;
22+
}
23+
24+
export const createLogger = (config: LoggerConfig = {}) => {
25+
const mode: Mode = config.mode ?? "memory";
26+
const logDir = config.logDir ?? "logs";
27+
const singleFile = config.singleFile ?? false;
28+
const levels = {
29+
stdout: config.levels?.stdout ?? ["warn", "error"],
30+
file: config.levels?.file ?? ["info", "warn", "error"],
31+
};
32+
33+
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
34+
35+
const buffers = new Map<string, LogEntry[]>();
36+
const streams = new Map<string, fs.WriteStream>();
37+
38+
const getStream = (fileId: string) => {
39+
const key = singleFile ? "all" : fileId;
40+
if (!streams.has(key)) {
41+
const filePath = path.join(logDir, singleFile ? "combined.log" : `${fileId}.log`);
42+
streams.set(key, fs.createWriteStream(filePath, { flags: "a" }));
43+
}
44+
return streams.get(key)!;
45+
};
46+
47+
const write = (fileId: string, level: LogLevel, message: string) => {
48+
const entry: LogEntry = { timestamp: new Date().toISOString(), level, message };
49+
50+
// Console output if enabled
51+
if (levels.stdout.includes(level)) {
52+
const fn = level === "error" ? console.error : console.log;
53+
fn(`[${fileId}] [${entry.timestamp}] [${level.toUpperCase()}] ${entry.message}`);
54+
}
55+
56+
// File output
57+
if (levels.file.includes(level)) {
58+
if (mode === "memory") {
59+
if (!buffers.has(fileId)) buffers.set(fileId, []);
60+
buffers.get(fileId)!.push(entry);
61+
} else {
62+
getStream(fileId).write(`[${entry.timestamp}] [${level.toUpperCase()}] ${entry.message}\n`);
63+
}
64+
}
65+
};
66+
67+
const flush = () => {
68+
if (mode === "memory") {
69+
for (const [fileId, entries] of buffers.entries()) {
70+
const filePath = path.join(logDir, singleFile ? "combined.log" : `${fileId}.log`);
71+
const lines = entries.map(e => `[${e.timestamp}] [${e.level.toUpperCase()}] ${e.message}`);
72+
fs.writeFileSync(filePath, lines.join("\n") + "\n", { flag: "a" });
73+
}
74+
}
75+
for (const s of streams.values()) s.end();
76+
};
77+
78+
return {
79+
info: (fileId: string, msg: string) => write(fileId, "info", msg),
80+
warn: (fileId: string, msg: string) => write(fileId, "warn", msg),
81+
error: (fileId: string, msg: string) => write(fileId, "error", msg),
82+
debug: (fileId: string, msg: string) => write(fileId, "debug", msg),
83+
flush,
84+
};
85+
};
86+
87+
export const globalLogger = createLogger();

0 commit comments

Comments
 (0)