Skip to content

Commit 97d5512

Browse files
committed
feat: add Git merge driver support and refactor merge logic
- Add Git merge driver mode with 3-way merge support (%A %O %B) - Extract common merge logic into reusable processMerge utility - Replace StrategyStatus enum with const object for better minification - Add comprehensive unit tests for merge-processor module - Maintain backward compatibility with existing CLI workflows - Auto-detect Git merge mode vs standard conflict resolution mode BREAKING: None - fully backward compatible
1 parent aabf5ec commit 97d5512

14 files changed

+681
-146
lines changed

.changeset/git-merge-driver.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"git-json-resolver": minor
3+
---
4+
5+
Add Git merge driver support and improve architecture
6+
7+
- **New feature**: CLI now supports Git merge driver mode when called with 3 positional arguments (%A %O %B)
8+
- **Refactored**: Extracted common merge logic into reusable `processMerge` utility (DRY principle)
9+
- **Testing**: Added comprehensive unit tests for merge-processor module
10+
- **Backward compatible**: Existing CLI workflows continue to work unchanged
11+
- **Auto-detection**: Automatically detects Git merge mode vs. standard conflict resolution mode
12+
- **Full configuration**: All existing config options (rules, strategies, matchers) work in Git merge mode
13+
- **Exit codes**: Proper Git merge driver exit codes (0 for success, 1 for conflicts)
14+
15+
Usage:
16+
17+
```bash
18+
git config merge.json-resolver.driver "npx git-json-resolver %A %O %B"
19+
```
20+
21+
This enables automatic JSON conflict resolution during Git merges using the same powerful rule-based strategies.

README.md

Lines changed: 169 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@ A Git-aware conflict resolver for **JSON-first structured data**.
1919
## Features
2020

2121
-**Primary focus on JSON** (first-class support)
22-
- 🔄 **YAML, XML, TOML** supported via conversion
23-
- 🧩 **Rule-based strategies** (per path/pattern)
24-
- 🗂️ Handles small configs to **huge repos** (optimizations built-in)
25-
- 🔌 **Pluggable matcher** abstraction (picomatch, micromatch supported out of the box with peerDependency)
26-
- 🛠️ Configurable trade-offs for **speed vs. memory**
27-
- 🗃️ **Planned**: extend configuration to use **different strategies per file** (ideas and edge cases welcome!)
22+
- 🔄 **YAML, XML, TOML, JSON5** supported via conversion
23+
- 🧩 **Rule-based strategies** with path/pattern matching
24+
- 📁 **Multiple file parallel processing** with include/exclude patterns
25+
- 🔌 **Pluggable matcher** abstraction (picomatch, micromatch, or custom)
26+
- 🛠️ **CLI and programmatic API** support
27+
- 📝 **Conflict sidecar files** for unresolved conflicts
28+
- 🔄 **Backup and restore** functionality
29+
- 📊 **Configurable logging** (memory or file-based)
30+
- 🔀 **Git merge driver** support for seamless Git integration
2831

2932
## Installation
3033

@@ -46,6 +49,24 @@ yarn add git-json-resolver
4649

4750
## Quick Start
4851

52+
### CLI Usage
53+
54+
```bash
55+
# Initialize config file
56+
npx git-json-resolver --init
57+
58+
# Run with default config
59+
npx git-json-resolver
60+
61+
# Run with options
62+
npx git-json-resolver --include "**/*.json" --debug --sidecar
63+
64+
# Restore from backups
65+
npx git-json-resolver --restore .merge-backups
66+
```
67+
68+
### Git Integration
69+
4970
Add a custom merge driver to your Git config:
5071

5172
```bash
@@ -57,73 +78,172 @@ Update `.gitattributes` to use it for JSON files:
5778

5879
```gitattributes
5980
*.json merge=json-resolver
81+
*.yaml merge=json-resolver
82+
*.yml merge=json-resolver
83+
*.toml merge=json-resolver
84+
*.xml merge=json-resolver
6085
```
6186

62-
## Example Config
87+
**How it works:**
88+
89+
- Git automatically calls the merge driver during conflicts
90+
- Uses same configuration and strategies as CLI mode
91+
- Supports 3-way merge (ours, base, theirs)
92+
- Returns proper exit codes (0 = success, 1 = conflicts)
93+
94+
## Configuration
95+
96+
### Programmatic API
6397

6498
```ts
6599
import { resolveConflicts } from "git-json-resolver";
66100

67-
const result = resolveConflicts({
68-
filePath: "package.json",
69-
rules: [
70-
{ pattern: "dependencies.*", strategy: "ours" },
71-
{ pattern: "version", strategy: "theirs", important: true },
72-
{ pattern: "scripts.build", strategy: "manual" },
73-
],
74-
matcher: "picomatch", // default
75-
optimize: {
76-
cacheMatchers: true,
77-
streamMode: false, // set true for very large repos
101+
await resolveConflicts({
102+
defaultStrategy: ["merge", "ours"],
103+
rules: {
104+
"dependencies.*": ["ours"],
105+
version: ["theirs!"], // ! marks as important
106+
"scripts.build": ["skip"],
78107
},
108+
include: ["**/*.json", "**/*.yaml"],
109+
exclude: ["**/node_modules/**"],
110+
matcher: "picomatch",
111+
debug: true,
112+
writeConflictSidecar: true,
113+
backupDir: ".merge-backups",
79114
});
80115
```
81116

82-
### Upcoming: File-Specific Strategies
83-
84-
We are exploring the ability to define **per-file strategy sets** in config, e.g.:
85-
86-
```ts
87-
rulesByFile: {
88-
"package.json": { version: ["theirs"], dependencies: ["ours"] },
89-
"*.config.json": { "*": ["merge"] },
90-
},
117+
### Config File (`git-json-resolver.config.js`)
118+
119+
```js
120+
module.exports = {
121+
defaultStrategy: ["merge", "ours"],
122+
rules: {
123+
// Exact path matching
124+
"package.json": {
125+
version: ["theirs!"],
126+
dependencies: ["ours"],
127+
},
128+
// Pattern matching
129+
"*.config.json": {
130+
"*": ["merge"],
131+
},
132+
},
133+
// Alternative: byStrategy format
134+
byStrategy: {
135+
ours: ["dependencies.*", "devDependencies.*"],
136+
"theirs!": ["version", "name"],
137+
},
138+
include: ["**/*.json", "**/*.yaml", "**/*.yml"],
139+
exclude: ["**/node_modules/**", "**/dist/**"],
140+
matcher: "picomatch",
141+
debug: false,
142+
writeConflictSidecar: false,
143+
loggerConfig: {
144+
mode: "memory", // or "stream"
145+
logDir: "logs",
146+
levels: {
147+
stdout: ["warn", "error"],
148+
file: ["info", "warn", "error"],
149+
},
150+
},
151+
};
91152
```
92153

93-
This raises interesting questions/edge cases:
94-
95-
- How to merge file-level vs. global rules?
96-
- Should `include/exclude` still apply if a file is explicitly listed?
97-
- Should conflicting rules between file + global fall back to default strategy or error?
98-
99-
We welcome ideas & edge cases here!
100-
101154
## Supported Strategies
102155

156+
- **merge** → deep merge objects/arrays where possible
103157
- **ours** → take current branch value
104158
- **theirs** → take incoming branch value
105-
- **manual** → mark for human resolution
106-
- **drop** → remove the key entirely
107-
- **custom** → user-defined resolver function
159+
- **base** → revert to common ancestor
160+
- **skip** → leave unresolved (creates conflict entry)
161+
- **drop** → remove the field entirely
162+
- **non-empty** → prefer non-empty value (ours > theirs > base)
163+
- **update** → update with theirs if field exists in ours
164+
- **concat** → concatenate arrays from both sides
165+
- **unique** → merge arrays and remove duplicates
166+
- **custom** → user-defined resolver functions
167+
168+
### Strategy Priority
169+
170+
- Strategies marked with `!` (important) are applied first
171+
- Multiple strategies can be specified as fallbacks
172+
- Custom strategies can be defined via `customStrategies` config
108173

109174
## Supported Formats
110175

111176
- **JSON** (native)
112-
- **YAML, XML, TOML** → converted to JSON → resolved → converted back
177+
- **JSON5** → via `json5` peer dependency
178+
- **YAML** → via `yaml` peer dependency
179+
- **TOML** → via `smol-toml` peer dependency
180+
- **XML** → via `fast-xml-parser` peer dependency
181+
182+
All non-JSON formats are converted to JSON → resolved → converted back to original format.
183+
184+
## CLI Options
185+
186+
```bash
187+
# File patterns
188+
--include "**/*.json,**/*.yaml" # Comma-separated patterns
189+
--exclude "**/node_modules/**" # Exclusion patterns
190+
191+
# Matcher selection
192+
--matcher picomatch # picomatch, micromatch, or custom
193+
194+
# Debug and logging
195+
--debug # Enable verbose logging
196+
--sidecar # Write conflict sidecar files
197+
198+
# Utilities
199+
--init # Create starter config file
200+
--restore .merge-backups # Restore from backup directory
201+
```
113202

114-
## Performance & Optimization
203+
## Architecture
115204

116-
- **Matcher caching** for repeated patterns
117-
- **Streaming mode** for very large repos (low memory footprint)
118-
- Trade-offs are configurable via `optimize` field
205+
- **Modular design**: Separate concerns (parsing, merging, serialization)
206+
- **Reusable utilities**: Common merge logic extracted for maintainability
207+
- **Optimized bundle**: Constants over enums for better minification
208+
- **Comprehensive testing**: Full test coverage with vitest
209+
- **Type-safe**: Full TypeScript support with proper type inference
210+
211+
## Advanced Features
212+
213+
### Pattern Matching
214+
215+
- **Exact paths**: `"package.json"`, `"src.config.database.host"`
216+
- **Field matching**: `"[version]"` → matches any `version` field
217+
- **Glob patterns**: `"dependencies.*"`, `"**.config.**"`
218+
- **Wildcards**: `"*.json"`, `"src/**/*.config.js"`
219+
220+
### Custom Strategies
221+
222+
```ts
223+
import { StrategyStatus } from "git-json-resolver";
224+
225+
const config = {
226+
customStrategies: {
227+
"semantic-version": ({ ours, theirs }) => {
228+
// Custom logic for semantic version resolution
229+
if (isNewerVersion(theirs, ours)) {
230+
return { status: StrategyStatus.OK, value: theirs };
231+
}
232+
return { status: StrategyStatus.CONTINUE };
233+
},
234+
},
235+
rules: {
236+
version: ["semantic-version", "theirs"],
237+
},
238+
};
239+
```
119240

120-
## Roadmap
241+
### Logging & Debugging
121242

122-
- [ ] Richer strategies via plugins (e.g., semantic version resolver)
123-
- [ ] CLI UX improvements
124-
- [ ] Pluggable format converters customizations
125-
- [ ] VSCode integration for previewing merge resolutions
126-
- [ ] **Per-file strategies support** (current RFC)
243+
- **Memory mode**: Fast, in-memory logging
244+
- **Stream mode**: File-based logging for large operations
245+
- **Per-file logs**: Separate log files for each processed file
246+
- **Debug mode**: Detailed conflict information and strategy traces
127247

128248
## Contributing
129249

lib/src/cli.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { resolveConflicts } from "./index";
77
import type { Config } from "./types";
88
import { DEFAULT_CONFIG } from "./normalizer";
99
import { restoreBackups } from "./utils";
10+
import { resolveGitMergeFiles } from "./merge-processor";
1011

1112
const CONFIG_FILENAME = "git-json-resolver.config.js";
1213

@@ -66,15 +67,32 @@ module.exports = ${JSON.stringify(DEFAULT_CONFIG, null, 2)};
6667
*/
6768
export const parseArgs = (
6869
argv: string[],
69-
): { overrides: Partial<Config>; init?: boolean; restore?: string } => {
70+
): {
71+
overrides: Partial<Config>;
72+
init?: boolean;
73+
restore?: string;
74+
gitMergeFiles?: [string, string, string];
75+
} => {
7076
const overrides: Partial<Config> = {};
7177
let init = false;
7278
let restore: string | undefined;
79+
let gitMergeFiles: [string, string, string] | undefined;
80+
81+
// Check for Git merge driver mode (3 positional arguments)
82+
const positionalArgs = argv.slice(2).filter(arg => !arg.startsWith("--"));
83+
if (positionalArgs.length === 3) {
84+
gitMergeFiles = [positionalArgs[0], positionalArgs[1], positionalArgs[2]];
85+
}
7386

7487
for (let i = 2; i < argv.length; i++) {
7588
const arg = argv[i];
7689
const next = argv[i + 1];
7790

91+
// Skip positional arguments in Git merge mode
92+
if (gitMergeFiles && !arg.startsWith("--")) {
93+
continue;
94+
}
95+
7896
switch (arg) {
7997
case "--include":
8098
overrides.include = next?.split(",") ?? [];
@@ -107,12 +125,12 @@ export const parseArgs = (
107125
}
108126
}
109127
}
110-
return { overrides, init, restore };
128+
return { overrides, init, restore, gitMergeFiles };
111129
};
112130

113131
(async () => {
114132
try {
115-
const { overrides, init, restore } = parseArgs(process.argv);
133+
const { overrides, init, restore, gitMergeFiles } = parseArgs(process.argv);
116134

117135
if (init) {
118136
initConfig(process.cwd());
@@ -131,6 +149,14 @@ export const parseArgs = (
131149
process.exit(0);
132150
}
133151

152+
// Git merge driver mode: handle 3-way merge
153+
if (gitMergeFiles) {
154+
const [oursPath, basePath, theirsPath] = gitMergeFiles;
155+
await resolveGitMergeFiles(oursPath, basePath, theirsPath, finalConfig);
156+
return; // resolveGitMergeFiles handles process.exit
157+
}
158+
159+
// Standard mode: process files with conflict markers
134160
await resolveConflicts(finalConfig);
135161
} catch (err) {
136162
console.error("Failed:", err);

lib/src/conflict-helper.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi } from "vitest";
2-
import { DROP } from "./merger";
32
import { reconstructConflict } from "./conflict-helper";
3+
import { DROP } from "./utils";
44

55
// Mock serializer to keep things deterministic
66
vi.mock("./file-serializer", () => ({

lib/src/conflict-helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { serialize } from "./file-serializer";
2-
import { DROP } from "./merger";
2+
import { DROP } from "./utils";
33

44
/** Remove DROP, replace undefined with conflict markers */
55
const preprocessForConflicts = (node: any, path: string, conflicts: string[] = []): any => {

0 commit comments

Comments
 (0)