@aprovan/hardcopy 0.1.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/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
# Conflict Resolution
|
|
2
|
+
|
|
3
|
+
## Problem Statement
|
|
4
|
+
|
|
5
|
+
Current `push` command compares local file against cached DB state, ignoring concurrent remote changes. This causes silent overwrites.
|
|
6
|
+
|
|
7
|
+
### Example Scenario
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Last Sync: "Pictures of: Jacob Steve Gramins"
|
|
11
|
+
Local Edit: "Pictures of:\n- Jacob Steve Gramins" (added bullet)
|
|
12
|
+
Remote Edit: "**Pictures of**: Jacob Steve Gramins" (added bold)
|
|
13
|
+
Push Result: Remote gets local version, remote edit lost
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Both edits had semantic intent that should be preserved. Neither user wanted to lose the other's change.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Core Issue: Three-Way State Problem
|
|
21
|
+
|
|
22
|
+
Push currently performs two-way diff:
|
|
23
|
+
```
|
|
24
|
+
Local File → compare ← DB Cache (last synced state)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Should perform three-way diff:
|
|
28
|
+
```
|
|
29
|
+
DB Cache (base)
|
|
30
|
+
↑
|
|
31
|
+
/ \
|
|
32
|
+
Local File Remote Current
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Changes:
|
|
36
|
+
1. Fetch remote state before push
|
|
37
|
+
2. Detect divergence from common base
|
|
38
|
+
3. Decide resolution strategy
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## CRDT Limitations
|
|
43
|
+
|
|
44
|
+
CRDTs (Loro) work well when:
|
|
45
|
+
- Both sides use CRDT operations
|
|
46
|
+
- We control edit granularity
|
|
47
|
+
|
|
48
|
+
Here, we don't control remote inputs:
|
|
49
|
+
- GitHub UI sends full text replacements
|
|
50
|
+
- We receive before/after strings, not ops
|
|
51
|
+
- Character-level merge loses semantic intent
|
|
52
|
+
|
|
53
|
+
**Example Failure**:
|
|
54
|
+
```
|
|
55
|
+
Base: "Task: Fix bug"
|
|
56
|
+
Local: "Task: Fix critical bug" (added "critical")
|
|
57
|
+
Remote: "Task: Fix the bug" (added "the")
|
|
58
|
+
CRDT: "Task: Fix criticalthe bug" (nonsense merge)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
CRDT is appropriate for:
|
|
62
|
+
- List operations (labels, assignees)
|
|
63
|
+
- Key-value fields (state, milestone)
|
|
64
|
+
- Concurrent local edits (multiple files)
|
|
65
|
+
|
|
66
|
+
Not ideal for:
|
|
67
|
+
- Free-text body merges from external sources
|
|
68
|
+
- Semantic conflict detection
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Semantic Diff Alternative
|
|
73
|
+
|
|
74
|
+
[diffsitter](https://github.com/afnanenayet/diffsitter) uses tree-sitter for AST-level diffs.
|
|
75
|
+
|
|
76
|
+
**Pros**:
|
|
77
|
+
- Understands structural changes
|
|
78
|
+
- Better signal-to-noise ratio
|
|
79
|
+
- Language-aware (code, markdown)
|
|
80
|
+
|
|
81
|
+
**Cons**:
|
|
82
|
+
- Markdown AST is shallow (less helpful)
|
|
83
|
+
- Adds tree-sitter dependency
|
|
84
|
+
- Still doesn't resolve semantic conflicts
|
|
85
|
+
|
|
86
|
+
**Verdict**: Useful for display/debugging, not primary merge strategy.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Proposed Design
|
|
91
|
+
|
|
92
|
+
### Phase 1: Conflict Detection
|
|
93
|
+
|
|
94
|
+
Modify `push` to fetch remote before comparing.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
interface SyncState {
|
|
98
|
+
base: string; // DB cache (last sync)
|
|
99
|
+
local: string; // Current file
|
|
100
|
+
remote: string; // Fetched remote
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
enum ConflictStatus {
|
|
104
|
+
CLEAN, // local changed, remote unchanged
|
|
105
|
+
REMOTE_ONLY, // remote changed, local unchanged
|
|
106
|
+
DIVERGED, // both changed (conflict)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function detectConflict(state: SyncState): ConflictStatus {
|
|
110
|
+
const localChanged = state.local !== state.base;
|
|
111
|
+
const remoteChanged = state.remote !== state.base;
|
|
112
|
+
|
|
113
|
+
if (!localChanged && !remoteChanged) return ConflictStatus.CLEAN;
|
|
114
|
+
if (localChanged && !remoteChanged) return ConflictStatus.CLEAN;
|
|
115
|
+
if (!localChanged && remoteChanged) return ConflictStatus.REMOTE_ONLY;
|
|
116
|
+
return ConflictStatus.DIVERGED;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Phase 2: Resolution Strategies
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
type ResolutionStrategy =
|
|
124
|
+
| 'auto-merge' // compatible changes, merge
|
|
125
|
+
| 'local-wins' // user flag: --force
|
|
126
|
+
| 'remote-wins' // discard local
|
|
127
|
+
| 'manual' // write conflict markers
|
|
128
|
+
| 'prompt' // interactive resolution
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Auto-merge** (when possible):
|
|
132
|
+
- Changes don't overlap
|
|
133
|
+
- Different fields changed
|
|
134
|
+
- Additive operations (both add labels)
|
|
135
|
+
|
|
136
|
+
**Manual** (fallback):
|
|
137
|
+
- Write conflict file to `.hardcopy/conflicts/`
|
|
138
|
+
- Block push until resolved
|
|
139
|
+
- Show in `hardcopy status`
|
|
140
|
+
|
|
141
|
+
### Phase 3: Conflict Markers
|
|
142
|
+
|
|
143
|
+
For diverged text fields, generate diff3-style markers:
|
|
144
|
+
|
|
145
|
+
```markdown
|
|
146
|
+
<<<<<<< LOCAL
|
|
147
|
+
Pictures of:
|
|
148
|
+
- Jacob Steve Gramins
|
|
149
|
+
||||||| BASE
|
|
150
|
+
Pictures of: Jacob Steve Gramins
|
|
151
|
+
=======
|
|
152
|
+
**Pictures of**: Jacob Steve Gramins
|
|
153
|
+
>>>>>>> REMOTE
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Store in `.hardcopy/conflicts/{nodeId}.md` with metadata.
|
|
157
|
+
|
|
158
|
+
### Phase 4: CLI Integration
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
hardcopy push [pattern] # fails on conflict
|
|
162
|
+
hardcopy push --force [pattern] # local-wins
|
|
163
|
+
hardcopy push --sync [pattern] # fetch, then push
|
|
164
|
+
hardcopy conflicts # list conflicts
|
|
165
|
+
hardcopy resolve <nodeId> # interactive resolution
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Implementation Steps
|
|
171
|
+
|
|
172
|
+
### Step 1: Remote Fetch Before Push
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
async push(pattern?: string): Promise<PushStats> {
|
|
176
|
+
const diffs = await this.diff(pattern);
|
|
177
|
+
|
|
178
|
+
for (const diff of diffs) {
|
|
179
|
+
// NEW: fetch current remote state
|
|
180
|
+
const remote = await provider.fetch({ nodeId: diff.nodeId });
|
|
181
|
+
const base = await db.getNode(diff.nodeId);
|
|
182
|
+
|
|
183
|
+
const status = detectConflict({
|
|
184
|
+
base: base.attrs.body,
|
|
185
|
+
local: diff.changes.find(c => c.field === 'body')?.newValue,
|
|
186
|
+
remote: remote.attrs.body,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (status === ConflictStatus.DIVERGED) {
|
|
190
|
+
await this.writeConflict(diff.nodeId, { base, local, remote });
|
|
191
|
+
stats.conflicts++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ... existing push logic
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Step 2: Provider Interface Update
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
interface Provider {
|
|
204
|
+
// Existing
|
|
205
|
+
fetch(request: FetchRequest): Promise<FetchResult>;
|
|
206
|
+
push(node: Node, changes: Change[]): Promise<PushResult>;
|
|
207
|
+
|
|
208
|
+
// New: single-node fetch for conflict check
|
|
209
|
+
fetchNode(nodeId: string): Promise<Node | null>;
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Step 3: Conflict Storage
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
.hardcopy/
|
|
217
|
+
├── db.sqlite
|
|
218
|
+
├── crdt/
|
|
219
|
+
└── conflicts/
|
|
220
|
+
└── github:owner:repo#42.md
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Conflict file format:
|
|
224
|
+
```yaml
|
|
225
|
+
---
|
|
226
|
+
nodeId: github:owner/repo#42
|
|
227
|
+
type: github.Issue
|
|
228
|
+
field: body
|
|
229
|
+
detectedAt: 2026-02-21T10:30:00Z
|
|
230
|
+
---
|
|
231
|
+
<<<<<<< LOCAL
|
|
232
|
+
...
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Step 4: Status Integration
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
$ hardcopy status
|
|
239
|
+
Conflicts:
|
|
240
|
+
(use "hardcopy resolve <id>" to resolve)
|
|
241
|
+
|
|
242
|
+
conflict: github:owner/repo#42 (body)
|
|
243
|
+
|
|
244
|
+
Changes not pushed:
|
|
245
|
+
...
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Alternative: Operational Transform
|
|
251
|
+
|
|
252
|
+
If we controlled both ends, OT would work:
|
|
253
|
+
```
|
|
254
|
+
local ops: INSERT(10, "critical ")
|
|
255
|
+
remote ops: INSERT(6, "the ")
|
|
256
|
+
transform: INSERT(10 + len("the "), "critical ")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Not viable here—we only get resulting text from remote.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Decision Matrix
|
|
264
|
+
|
|
265
|
+
| Approach | Pros | Cons | Use When |
|
|
266
|
+
|----------|------|------|----------|
|
|
267
|
+
| CRDT | Auto-merge, offline-first | Semantic blindness | Lists, metadata |
|
|
268
|
+
| Semantic diff | Structural awareness | Doesn't resolve | Debug, display |
|
|
269
|
+
| Three-way diff | Standard, familiar | Manual resolution | Text conflicts |
|
|
270
|
+
| LLM resolution | Understands intent | Cost, latency | Complex conflicts |
|
|
271
|
+
|
|
272
|
+
**Recommendation**: Hybrid approach
|
|
273
|
+
1. Three-way diff for detection
|
|
274
|
+
2. CRDT for compatible merges (lists, maps)
|
|
275
|
+
3. Conflict markers + manual for text divergence
|
|
276
|
+
4. Optional LLM for suggested resolution
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Milestones
|
|
281
|
+
|
|
282
|
+
- [ ] Add `fetchNode` to provider interface
|
|
283
|
+
- [ ] Implement three-way conflict detection in `push`
|
|
284
|
+
- [ ] Create `.hardcopy/conflicts/` storage
|
|
285
|
+
- [ ] Add `hardcopy conflicts` command
|
|
286
|
+
- [ ] Add `hardcopy resolve` command
|
|
287
|
+
- [ ] Update `hardcopy status` to show conflicts
|
|
288
|
+
- [ ] Add `--force` flag for local-wins
|
|
289
|
+
- [ ] Document conflict resolution workflow
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
# Technical Implementation Plan
|
|
294
|
+
|
|
295
|
+
## Overview
|
|
296
|
+
|
|
297
|
+
Implement hybrid conflict resolution:
|
|
298
|
+
1. **Three-way diff** for conflict detection
|
|
299
|
+
2. **CRDT** for list/map fields (labels, assignees, metadata)
|
|
300
|
+
3. **Conflict markers** for text field divergence
|
|
301
|
+
4. **CLI commands** for conflict management
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Task 1: Types and Interfaces
|
|
306
|
+
|
|
307
|
+
**File**: `src/types.ts` (create if needed, or add to existing)
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// Conflict detection
|
|
311
|
+
export enum ConflictStatus {
|
|
312
|
+
CLEAN = 'clean', // No conflict, safe to push
|
|
313
|
+
REMOTE_ONLY = 'remote', // Remote changed, local unchanged
|
|
314
|
+
DIVERGED = 'diverged', // Both changed, conflict
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface ThreeWayState {
|
|
318
|
+
base: unknown; // DB cached value (last sync)
|
|
319
|
+
local: unknown; // Current file value
|
|
320
|
+
remote: unknown; // Fetched remote value
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface FieldConflict {
|
|
324
|
+
field: string;
|
|
325
|
+
status: ConflictStatus;
|
|
326
|
+
base: unknown;
|
|
327
|
+
local: unknown;
|
|
328
|
+
remote: unknown;
|
|
329
|
+
canAutoMerge: boolean; // true for lists, false for text
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface ConflictInfo {
|
|
333
|
+
nodeId: string;
|
|
334
|
+
nodeType: string;
|
|
335
|
+
filePath: string;
|
|
336
|
+
detectedAt: number;
|
|
337
|
+
fields: FieldConflict[];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Extended push stats
|
|
341
|
+
export interface PushStats {
|
|
342
|
+
pushed: number;
|
|
343
|
+
skipped: number;
|
|
344
|
+
conflicts: number; // NEW
|
|
345
|
+
errors: string[];
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Task 2: Provider Interface Extension
|
|
352
|
+
|
|
353
|
+
**File**: `src/provider.ts`
|
|
354
|
+
|
|
355
|
+
Add `fetchNode` method to Provider interface:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
interface Provider {
|
|
359
|
+
name: string;
|
|
360
|
+
nodeTypes: string[];
|
|
361
|
+
edgeTypes: string[];
|
|
362
|
+
|
|
363
|
+
fetch(request: FetchRequest): Promise<FetchResult>;
|
|
364
|
+
push(node: Node, changes: Change[]): Promise<PushResult>;
|
|
365
|
+
|
|
366
|
+
// NEW: Fetch single node for conflict detection
|
|
367
|
+
// Returns null if node doesn't exist remotely
|
|
368
|
+
fetchNode(nodeId: string): Promise<Node | null>;
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
**Implementation for GitHub provider** (`src/providers/github.ts`):
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
async fetchNode(nodeId: string): Promise<Node | null> {
|
|
376
|
+
// nodeId format: "github:owner/repo#123"
|
|
377
|
+
const match = nodeId.match(/^github:([^/]+)\/([^#]+)#(\d+)$/);
|
|
378
|
+
if (!match) return null;
|
|
379
|
+
|
|
380
|
+
const [, owner, repo, number] = match;
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const response = await this.octokit.issues.get({
|
|
384
|
+
owner,
|
|
385
|
+
repo,
|
|
386
|
+
issue_number: parseInt(number, 10),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return this.issueToNode(response.data);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
if (err.status === 404) return null;
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Task 3: Conflict Detection Module
|
|
400
|
+
|
|
401
|
+
**File**: `src/conflict.ts` (new file)
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
import { FieldConflict, ConflictStatus, ThreeWayState, ConflictInfo } from './types';
|
|
405
|
+
import { Node, Change } from './provider';
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Detect conflict status for a single field
|
|
409
|
+
*/
|
|
410
|
+
export function detectFieldConflict(
|
|
411
|
+
field: string,
|
|
412
|
+
state: ThreeWayState,
|
|
413
|
+
): FieldConflict {
|
|
414
|
+
const localChanged = !valuesEqual(state.local, state.base);
|
|
415
|
+
const remoteChanged = !valuesEqual(state.remote, state.base);
|
|
416
|
+
|
|
417
|
+
let status: ConflictStatus;
|
|
418
|
+
if (!localChanged && !remoteChanged) {
|
|
419
|
+
status = ConflictStatus.CLEAN;
|
|
420
|
+
} else if (localChanged && !remoteChanged) {
|
|
421
|
+
status = ConflictStatus.CLEAN;
|
|
422
|
+
} else if (!localChanged && remoteChanged) {
|
|
423
|
+
status = ConflictStatus.REMOTE_ONLY;
|
|
424
|
+
} else {
|
|
425
|
+
// Both changed - check if they changed to same value
|
|
426
|
+
status = valuesEqual(state.local, state.remote)
|
|
427
|
+
? ConflictStatus.CLEAN
|
|
428
|
+
: ConflictStatus.DIVERGED;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
field,
|
|
433
|
+
status,
|
|
434
|
+
base: state.base,
|
|
435
|
+
local: state.local,
|
|
436
|
+
remote: state.remote,
|
|
437
|
+
canAutoMerge: isListField(field),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* List fields can be auto-merged via CRDT
|
|
443
|
+
*/
|
|
444
|
+
function isListField(field: string): boolean {
|
|
445
|
+
return ['labels', 'assignees'].includes(field);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Deep equality check
|
|
450
|
+
*/
|
|
451
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
452
|
+
if (a === b) return true;
|
|
453
|
+
if (a == null && b == null) return true;
|
|
454
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
455
|
+
if (a.length !== b.length) return false;
|
|
456
|
+
const sortedA = [...a].sort();
|
|
457
|
+
const sortedB = [...b].sort();
|
|
458
|
+
return sortedA.every((v, i) => valuesEqual(v, sortedB[i]));
|
|
459
|
+
}
|
|
460
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Detect conflicts for all editable fields
|
|
465
|
+
*/
|
|
466
|
+
export function detectConflicts(
|
|
467
|
+
baseNode: Node,
|
|
468
|
+
localParsed: { attrs: Record<string, unknown>; body: string },
|
|
469
|
+
remoteNode: Node,
|
|
470
|
+
editableFields: string[],
|
|
471
|
+
): FieldConflict[] {
|
|
472
|
+
const conflicts: FieldConflict[] = [];
|
|
473
|
+
const baseAttrs = baseNode.attrs as Record<string, unknown>;
|
|
474
|
+
const remoteAttrs = remoteNode.attrs as Record<string, unknown>;
|
|
475
|
+
|
|
476
|
+
for (const field of editableFields) {
|
|
477
|
+
const state: ThreeWayState = {
|
|
478
|
+
base: field === 'body' ? baseAttrs.body : baseAttrs[field],
|
|
479
|
+
local: field === 'body' ? localParsed.body : localParsed.attrs[field],
|
|
480
|
+
remote: field === 'body' ? remoteAttrs.body : remoteAttrs[field],
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const conflict = detectFieldConflict(field, state);
|
|
484
|
+
if (conflict.status !== ConflictStatus.CLEAN) {
|
|
485
|
+
conflicts.push(conflict);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return conflicts;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Check if any conflicts require manual resolution
|
|
494
|
+
*/
|
|
495
|
+
export function hasUnresolvableConflicts(conflicts: FieldConflict[]): boolean {
|
|
496
|
+
return conflicts.some(c =>
|
|
497
|
+
c.status === ConflictStatus.DIVERGED && !c.canAutoMerge
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Auto-merge list fields using set union
|
|
503
|
+
* Returns merged value for lists, null for non-mergeable
|
|
504
|
+
*/
|
|
505
|
+
export function autoMergeField(conflict: FieldConflict): unknown | null {
|
|
506
|
+
if (!conflict.canAutoMerge || conflict.status !== ConflictStatus.DIVERGED) {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Set union for lists
|
|
511
|
+
const baseSet = new Set(conflict.base as unknown[] ?? []);
|
|
512
|
+
const localAdded = (conflict.local as unknown[] ?? []).filter(v => !baseSet.has(v));
|
|
513
|
+
const remoteAdded = (conflict.remote as unknown[] ?? []).filter(v => !baseSet.has(v));
|
|
514
|
+
|
|
515
|
+
// Union: keep all from base, add new from both
|
|
516
|
+
const merged = [...baseSet, ...localAdded, ...remoteAdded];
|
|
517
|
+
return [...new Set(merged)]; // dedupe
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Generate diff3-style conflict markers for text
|
|
522
|
+
*/
|
|
523
|
+
export function generateConflictMarkers(conflict: FieldConflict): string {
|
|
524
|
+
const local = String(conflict.local ?? '');
|
|
525
|
+
const base = String(conflict.base ?? '');
|
|
526
|
+
const remote = String(conflict.remote ?? '');
|
|
527
|
+
|
|
528
|
+
return `<<<<<<< LOCAL
|
|
529
|
+
${local}
|
|
530
|
+
||||||| BASE
|
|
531
|
+
${base}
|
|
532
|
+
=======
|
|
533
|
+
${remote}
|
|
534
|
+
>>>>>>> REMOTE`;
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## Task 4: Conflict Storage
|
|
541
|
+
|
|
542
|
+
**File**: `src/conflict-store.ts` (new file)
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
import { mkdir, writeFile, readFile, readdir, unlink } from 'fs/promises';
|
|
546
|
+
import { join, basename } from 'path';
|
|
547
|
+
import { ConflictInfo, FieldConflict } from './types';
|
|
548
|
+
import { generateConflictMarkers } from './conflict';
|
|
549
|
+
|
|
550
|
+
export class ConflictStore {
|
|
551
|
+
private conflictsDir: string;
|
|
552
|
+
|
|
553
|
+
constructor(hardcopyDir: string) {
|
|
554
|
+
this.conflictsDir = join(hardcopyDir, 'conflicts');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async initialize(): Promise<void> {
|
|
558
|
+
await mkdir(this.conflictsDir, { recursive: true });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Write conflict to file
|
|
563
|
+
* Filename: sanitized nodeId + .conflict.md
|
|
564
|
+
*/
|
|
565
|
+
async write(info: ConflictInfo): Promise<string> {
|
|
566
|
+
const filename = this.nodeIdToFilename(info.nodeId);
|
|
567
|
+
const filepath = join(this.conflictsDir, filename);
|
|
568
|
+
|
|
569
|
+
const content = this.formatConflict(info);
|
|
570
|
+
await writeFile(filepath, content, 'utf-8');
|
|
571
|
+
|
|
572
|
+
return filepath;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* List all conflicts
|
|
577
|
+
*/
|
|
578
|
+
async list(): Promise<ConflictInfo[]> {
|
|
579
|
+
try {
|
|
580
|
+
const files = await readdir(this.conflictsDir);
|
|
581
|
+
const conflicts: ConflictInfo[] = [];
|
|
582
|
+
|
|
583
|
+
for (const file of files) {
|
|
584
|
+
if (!file.endsWith('.conflict.md')) continue;
|
|
585
|
+
const content = await readFile(join(this.conflictsDir, file), 'utf-8');
|
|
586
|
+
const info = this.parseConflict(content);
|
|
587
|
+
if (info) conflicts.push(info);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return conflicts;
|
|
591
|
+
} catch {
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Get specific conflict
|
|
598
|
+
*/
|
|
599
|
+
async get(nodeId: string): Promise<ConflictInfo | null> {
|
|
600
|
+
const filename = this.nodeIdToFilename(nodeId);
|
|
601
|
+
const filepath = join(this.conflictsDir, filename);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const content = await readFile(filepath, 'utf-8');
|
|
605
|
+
return this.parseConflict(content);
|
|
606
|
+
} catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Delete conflict (after resolution)
|
|
613
|
+
*/
|
|
614
|
+
async delete(nodeId: string): Promise<void> {
|
|
615
|
+
const filename = this.nodeIdToFilename(nodeId);
|
|
616
|
+
const filepath = join(this.conflictsDir, filename);
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
await unlink(filepath);
|
|
620
|
+
} catch {
|
|
621
|
+
// Ignore if doesn't exist
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Check if node has unresolved conflict
|
|
627
|
+
*/
|
|
628
|
+
async has(nodeId: string): Promise<boolean> {
|
|
629
|
+
const conflict = await this.get(nodeId);
|
|
630
|
+
return conflict !== null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private nodeIdToFilename(nodeId: string): string {
|
|
634
|
+
// Sanitize: github:owner/repo#42 -> github_owner_repo_42.conflict.md
|
|
635
|
+
return nodeId.replace(/[:/# ]/g, '_') + '.conflict.md';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private formatConflict(info: ConflictInfo): string {
|
|
639
|
+
const frontmatter = [
|
|
640
|
+
'---',
|
|
641
|
+
`nodeId: "${info.nodeId}"`,
|
|
642
|
+
`type: ${info.nodeType}`,
|
|
643
|
+
`filePath: "${info.filePath}"`,
|
|
644
|
+
`detectedAt: ${new Date(info.detectedAt).toISOString()}`,
|
|
645
|
+
`fields: [${info.fields.map(f => `"${f.field}"`).join(', ')}]`,
|
|
646
|
+
'---',
|
|
647
|
+
'',
|
|
648
|
+
].join('\n');
|
|
649
|
+
|
|
650
|
+
const sections = info.fields
|
|
651
|
+
.filter(f => f.status === 'diverged')
|
|
652
|
+
.map(f => `## ${f.field}\n\n${generateConflictMarkers(f)}`)
|
|
653
|
+
.join('\n\n');
|
|
654
|
+
|
|
655
|
+
return frontmatter + sections;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private parseConflict(content: string): ConflictInfo | null {
|
|
659
|
+
// Parse YAML frontmatter
|
|
660
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
661
|
+
if (!match) return null;
|
|
662
|
+
|
|
663
|
+
const frontmatter = match[1];
|
|
664
|
+
const nodeIdMatch = frontmatter.match(/nodeId:\s*"([^"]+)"/);
|
|
665
|
+
const typeMatch = frontmatter.match(/type:\s*(\S+)/);
|
|
666
|
+
const filePathMatch = frontmatter.match(/filePath:\s*"([^"]+)"/);
|
|
667
|
+
const detectedAtMatch = frontmatter.match(/detectedAt:\s*(\S+)/);
|
|
668
|
+
const fieldsMatch = frontmatter.match(/fields:\s*\[(.*?)\]/);
|
|
669
|
+
|
|
670
|
+
if (!nodeIdMatch) return null;
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
nodeId: nodeIdMatch[1],
|
|
674
|
+
nodeType: typeMatch?.[1] ?? 'unknown',
|
|
675
|
+
filePath: filePathMatch?.[1] ?? '',
|
|
676
|
+
detectedAt: detectedAtMatch ? new Date(detectedAtMatch[1]).getTime() : Date.now(),
|
|
677
|
+
fields: [], // Full parsing would require parsing the body sections
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## Task 5: Update Push Logic
|
|
686
|
+
|
|
687
|
+
**File**: `src/hardcopy.ts`
|
|
688
|
+
|
|
689
|
+
Modify the `push` method to integrate conflict detection:
|
|
690
|
+
|
|
691
|
+
```typescript
|
|
692
|
+
import { detectConflicts, hasUnresolvableConflicts, autoMergeField } from './conflict';
|
|
693
|
+
import { ConflictStore } from './conflict-store';
|
|
694
|
+
import { ConflictInfo, ConflictStatus } from './types';
|
|
695
|
+
|
|
696
|
+
// Add to Hardcopy class:
|
|
697
|
+
private conflictStore: ConflictStore | null = null;
|
|
698
|
+
|
|
699
|
+
private getConflictStore(): ConflictStore {
|
|
700
|
+
if (!this.conflictStore) {
|
|
701
|
+
this.conflictStore = new ConflictStore(join(this.root, '.hardcopy'));
|
|
702
|
+
}
|
|
703
|
+
return this.conflictStore;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Replace push method:
|
|
707
|
+
async push(filePath?: string, options: { force?: boolean } = {}): Promise<PushStats> {
|
|
708
|
+
const config = await this.loadConfig();
|
|
709
|
+
const db = this.getDatabase();
|
|
710
|
+
const crdt = this.getCRDTStore();
|
|
711
|
+
const conflictStore = this.getConflictStore();
|
|
712
|
+
await conflictStore.initialize();
|
|
713
|
+
|
|
714
|
+
const stats: PushStats = { pushed: 0, skipped: 0, conflicts: 0, errors: [] };
|
|
715
|
+
const diffs = await this.diff(filePath);
|
|
716
|
+
|
|
717
|
+
for (const diff of diffs) {
|
|
718
|
+
if (diff.changes.length === 0) {
|
|
719
|
+
stats.skipped++;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const provider = this.findProviderForNode(diff.nodeId);
|
|
724
|
+
if (!provider) {
|
|
725
|
+
stats.errors.push(`No provider for ${diff.nodeId}`);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const dbNode = await db.getNode(diff.nodeId);
|
|
730
|
+
if (!dbNode) {
|
|
731
|
+
stats.errors.push(`Node not found: ${diff.nodeId}`);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Check for existing unresolved conflict
|
|
736
|
+
if (await conflictStore.has(diff.nodeId)) {
|
|
737
|
+
stats.errors.push(`Unresolved conflict for ${diff.nodeId}. Run 'hardcopy resolve' first.`);
|
|
738
|
+
stats.conflicts++;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
// FETCH REMOTE STATE
|
|
744
|
+
const remoteNode = await provider.fetchNode(diff.nodeId);
|
|
745
|
+
if (!remoteNode) {
|
|
746
|
+
stats.errors.push(`Remote node not found: ${diff.nodeId}`);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// DETECT CONFLICTS
|
|
751
|
+
const format = getFormat(dbNode.type);
|
|
752
|
+
if (!format) {
|
|
753
|
+
stats.errors.push(`Unknown format: ${dbNode.type}`);
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Parse local file for comparison
|
|
758
|
+
const localContent = await readFile(diff.filePath, 'utf-8');
|
|
759
|
+
const localParsed = parseFile(localContent, 'generic');
|
|
760
|
+
|
|
761
|
+
const fieldConflicts = detectConflicts(
|
|
762
|
+
dbNode,
|
|
763
|
+
localParsed,
|
|
764
|
+
remoteNode,
|
|
765
|
+
format.editableFields,
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
// Handle conflicts
|
|
769
|
+
if (fieldConflicts.length > 0) {
|
|
770
|
+
if (options.force) {
|
|
771
|
+
// Force mode: local wins, skip conflict handling
|
|
772
|
+
console.log(`Forcing push for ${diff.nodeId} (local-wins)`);
|
|
773
|
+
} else if (hasUnresolvableConflicts(fieldConflicts)) {
|
|
774
|
+
// Unresolvable conflict: write conflict file, skip push
|
|
775
|
+
const conflictInfo: ConflictInfo = {
|
|
776
|
+
nodeId: diff.nodeId,
|
|
777
|
+
nodeType: dbNode.type,
|
|
778
|
+
filePath: diff.filePath,
|
|
779
|
+
detectedAt: Date.now(),
|
|
780
|
+
fields: fieldConflicts,
|
|
781
|
+
};
|
|
782
|
+
await conflictStore.write(conflictInfo);
|
|
783
|
+
console.log(`Conflict detected for ${diff.nodeId}. Run 'hardcopy conflicts' to view.`);
|
|
784
|
+
stats.conflicts++;
|
|
785
|
+
continue;
|
|
786
|
+
} else {
|
|
787
|
+
// All conflicts are auto-mergeable (lists)
|
|
788
|
+
for (const conflict of fieldConflicts) {
|
|
789
|
+
const merged = autoMergeField(conflict);
|
|
790
|
+
if (merged !== null) {
|
|
791
|
+
// Update the change with merged value
|
|
792
|
+
const change = diff.changes.find(c => c.field === conflict.field);
|
|
793
|
+
if (change) {
|
|
794
|
+
change.newValue = merged;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// PUSH
|
|
802
|
+
const result = await provider.push(dbNode, diff.changes);
|
|
803
|
+
if (result.success) {
|
|
804
|
+
// Update local node with changes
|
|
805
|
+
const updatedAttrs = { ...dbNode.attrs };
|
|
806
|
+
for (const change of diff.changes) {
|
|
807
|
+
updatedAttrs[change.field] = change.newValue;
|
|
808
|
+
}
|
|
809
|
+
await db.upsertNode({
|
|
810
|
+
...dbNode,
|
|
811
|
+
attrs: updatedAttrs,
|
|
812
|
+
syncedAt: Date.now(),
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Update CRDT
|
|
816
|
+
const doc = await crdt.loadOrCreate(diff.nodeId);
|
|
817
|
+
if (format) {
|
|
818
|
+
const bodyChange = diff.changes.find((c) => c.field === 'body');
|
|
819
|
+
if (bodyChange) {
|
|
820
|
+
setDocContent(doc, bodyChange.newValue as string);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
await crdt.save(diff.nodeId, doc);
|
|
824
|
+
|
|
825
|
+
// Clear any resolved conflict
|
|
826
|
+
await conflictStore.delete(diff.nodeId);
|
|
827
|
+
|
|
828
|
+
stats.pushed++;
|
|
829
|
+
} else {
|
|
830
|
+
stats.errors.push(`Push failed for ${diff.nodeId}: ${result.error}`);
|
|
831
|
+
}
|
|
832
|
+
} catch (err) {
|
|
833
|
+
stats.errors.push(`Error pushing ${diff.nodeId}: ${err}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return stats;
|
|
838
|
+
}
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
---
|
|
842
|
+
|
|
843
|
+
## Task 6: CLI Commands
|
|
844
|
+
|
|
845
|
+
**File**: `src/cli.ts`
|
|
846
|
+
|
|
847
|
+
### Update push command:
|
|
848
|
+
|
|
849
|
+
```typescript
|
|
850
|
+
program
|
|
851
|
+
.command('push [pattern]')
|
|
852
|
+
.description('Push local changes to remotes (supports glob patterns)')
|
|
853
|
+
.option('--dry-run', 'Show what would be pushed without actually pushing')
|
|
854
|
+
.option('--force', 'Force push, overwriting remote changes (local-wins)')
|
|
855
|
+
.action(async (pattern?: string, options?: { dryRun?: boolean; force?: boolean }) => {
|
|
856
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
857
|
+
await hc.initialize();
|
|
858
|
+
try {
|
|
859
|
+
await hc.loadConfig();
|
|
860
|
+
|
|
861
|
+
if (options?.dryRun) {
|
|
862
|
+
// ... existing dry-run logic
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const stats = await hc.push(pattern, { force: options?.force });
|
|
866
|
+
|
|
867
|
+
console.log(`Pushed ${stats.pushed} changes, skipped ${stats.skipped}`);
|
|
868
|
+
|
|
869
|
+
if (stats.conflicts > 0) {
|
|
870
|
+
console.log(`\n${stats.conflicts} conflict(s) detected.`);
|
|
871
|
+
console.log(' (use "hardcopy conflicts" to view)');
|
|
872
|
+
console.log(' (use "hardcopy push --force" to override)');
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (stats.errors.length > 0) {
|
|
876
|
+
console.error('Errors:');
|
|
877
|
+
for (const err of stats.errors) {
|
|
878
|
+
console.error(` ${err}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} finally {
|
|
882
|
+
await hc.close();
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
### Add conflicts command:
|
|
888
|
+
|
|
889
|
+
```typescript
|
|
890
|
+
program
|
|
891
|
+
.command('conflicts')
|
|
892
|
+
.description('List unresolved conflicts')
|
|
893
|
+
.action(async () => {
|
|
894
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
895
|
+
await hc.initialize();
|
|
896
|
+
try {
|
|
897
|
+
const conflicts = await hc.listConflicts();
|
|
898
|
+
|
|
899
|
+
if (conflicts.length === 0) {
|
|
900
|
+
console.log('No conflicts.');
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
console.log('Unresolved conflicts:\n');
|
|
905
|
+
console.log(' (use "hardcopy resolve <nodeId>" to resolve)');
|
|
906
|
+
console.log(' (use "hardcopy push --force" to override with local)\n');
|
|
907
|
+
|
|
908
|
+
for (const conflict of conflicts) {
|
|
909
|
+
const fields = conflict.fields.map(f => f.field).join(', ');
|
|
910
|
+
console.log(` ${conflict.nodeId}`);
|
|
911
|
+
console.log(` fields: ${fields}`);
|
|
912
|
+
console.log(` file: ${conflict.filePath}`);
|
|
913
|
+
console.log();
|
|
914
|
+
}
|
|
915
|
+
} finally {
|
|
916
|
+
await hc.close();
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
### Add resolve command:
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
program
|
|
925
|
+
.command('resolve <nodeId>')
|
|
926
|
+
.description('Resolve a conflict')
|
|
927
|
+
.option('--local', 'Accept local version')
|
|
928
|
+
.option('--remote', 'Accept remote version')
|
|
929
|
+
.option('--show', 'Show conflict details without resolving')
|
|
930
|
+
.action(async (nodeId: string, options?: { local?: boolean; remote?: boolean; show?: boolean }) => {
|
|
931
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
932
|
+
await hc.initialize();
|
|
933
|
+
try {
|
|
934
|
+
const conflict = await hc.getConflict(nodeId);
|
|
935
|
+
|
|
936
|
+
if (!conflict) {
|
|
937
|
+
console.log(`No conflict found for ${nodeId}`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (options?.show) {
|
|
942
|
+
// Display conflict details
|
|
943
|
+
console.log(`Conflict: ${conflict.nodeId}`);
|
|
944
|
+
console.log(`Type: ${conflict.nodeType}`);
|
|
945
|
+
console.log(`File: ${conflict.filePath}`);
|
|
946
|
+
console.log(`Detected: ${new Date(conflict.detectedAt).toISOString()}\n`);
|
|
947
|
+
|
|
948
|
+
for (const field of conflict.fields) {
|
|
949
|
+
console.log(`--- ${field.field} ---`);
|
|
950
|
+
console.log(`Base:\n${field.base}\n`);
|
|
951
|
+
console.log(`Local:\n${field.local}\n`);
|
|
952
|
+
console.log(`Remote:\n${field.remote}\n`);
|
|
953
|
+
}
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (options?.local) {
|
|
958
|
+
await hc.resolveConflict(nodeId, 'local');
|
|
959
|
+
console.log(`Resolved ${nodeId} with local version.`);
|
|
960
|
+
console.log('Run "hardcopy push" to push changes.');
|
|
961
|
+
} else if (options?.remote) {
|
|
962
|
+
await hc.resolveConflict(nodeId, 'remote');
|
|
963
|
+
console.log(`Resolved ${nodeId} with remote version.`);
|
|
964
|
+
console.log('Local file updated.');
|
|
965
|
+
} else {
|
|
966
|
+
// Interactive mode (future: could prompt user)
|
|
967
|
+
console.log('Specify --local or --remote to resolve, or --show to view details.');
|
|
968
|
+
}
|
|
969
|
+
} finally {
|
|
970
|
+
await hc.close();
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
---
|
|
976
|
+
|
|
977
|
+
## Task 7: Hardcopy Class Methods
|
|
978
|
+
|
|
979
|
+
**File**: `src/hardcopy.ts`
|
|
980
|
+
|
|
981
|
+
Add these methods to the Hardcopy class:
|
|
982
|
+
|
|
983
|
+
```typescript
|
|
984
|
+
/**
|
|
985
|
+
* List all unresolved conflicts
|
|
986
|
+
*/
|
|
987
|
+
async listConflicts(): Promise<ConflictInfo[]> {
|
|
988
|
+
const store = this.getConflictStore();
|
|
989
|
+
await store.initialize();
|
|
990
|
+
return store.list();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Get a specific conflict
|
|
995
|
+
*/
|
|
996
|
+
async getConflict(nodeId: string): Promise<ConflictInfo | null> {
|
|
997
|
+
const store = this.getConflictStore();
|
|
998
|
+
await store.initialize();
|
|
999
|
+
return store.get(nodeId);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Resolve a conflict
|
|
1004
|
+
* @param nodeId - The node with conflict
|
|
1005
|
+
* @param resolution - 'local' keeps local, 'remote' pulls remote
|
|
1006
|
+
*/
|
|
1007
|
+
async resolveConflict(
|
|
1008
|
+
nodeId: string,
|
|
1009
|
+
resolution: 'local' | 'remote',
|
|
1010
|
+
): Promise<void> {
|
|
1011
|
+
const store = this.getConflictStore();
|
|
1012
|
+
const db = this.getDatabase();
|
|
1013
|
+
const conflict = await store.get(nodeId);
|
|
1014
|
+
|
|
1015
|
+
if (!conflict) {
|
|
1016
|
+
throw new Error(`No conflict found for ${nodeId}`);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (resolution === 'local') {
|
|
1020
|
+
// Local wins: just delete the conflict, push will proceed
|
|
1021
|
+
await store.delete(nodeId);
|
|
1022
|
+
} else {
|
|
1023
|
+
// Remote wins: update local file with remote content
|
|
1024
|
+
const provider = this.findProviderForNode(nodeId);
|
|
1025
|
+
if (!provider) {
|
|
1026
|
+
throw new Error(`No provider for ${nodeId}`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const remoteNode = await provider.fetchNode(nodeId);
|
|
1030
|
+
if (!remoteNode) {
|
|
1031
|
+
throw new Error(`Remote node not found: ${nodeId}`);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Update DB with remote
|
|
1035
|
+
await db.upsertNode({
|
|
1036
|
+
...remoteNode,
|
|
1037
|
+
syncedAt: Date.now(),
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Re-render the file from remote
|
|
1041
|
+
const config = await this.loadConfig();
|
|
1042
|
+
for (const view of config.views) {
|
|
1043
|
+
if (conflict.filePath.startsWith(view.path)) {
|
|
1044
|
+
await this.renderNodeToFile(remoteNode, view, join(this.root, view.path));
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Delete conflict
|
|
1050
|
+
await store.delete(nodeId);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
---
|
|
1056
|
+
|
|
1057
|
+
## Task 8: Update Status Command
|
|
1058
|
+
|
|
1059
|
+
**File**: `src/cli.ts`
|
|
1060
|
+
|
|
1061
|
+
Modify status to show conflicts:
|
|
1062
|
+
|
|
1063
|
+
```typescript
|
|
1064
|
+
.action(async (options: { short?: boolean }) => {
|
|
1065
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
1066
|
+
await hc.initialize();
|
|
1067
|
+
try {
|
|
1068
|
+
await hc.loadConfig();
|
|
1069
|
+
const status = await hc.status();
|
|
1070
|
+
const conflicts = await hc.listConflicts();
|
|
1071
|
+
|
|
1072
|
+
if (options.short) {
|
|
1073
|
+
// Git-like short status
|
|
1074
|
+
for (const conflict of conflicts) {
|
|
1075
|
+
console.log(`C ${conflict.filePath}`);
|
|
1076
|
+
}
|
|
1077
|
+
for (const file of status.changedFiles) {
|
|
1078
|
+
const marker = file.status === 'new' ? 'A' : 'M';
|
|
1079
|
+
console.log(`${marker} ${file.path}`);
|
|
1080
|
+
}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Full status
|
|
1085
|
+
if (conflicts.length > 0) {
|
|
1086
|
+
console.log('Conflicts:');
|
|
1087
|
+
console.log(' (use "hardcopy resolve <id>" to resolve)\n');
|
|
1088
|
+
for (const conflict of conflicts) {
|
|
1089
|
+
const fields = conflict.fields.map(f => f.field).join(', ');
|
|
1090
|
+
console.log(` conflict: ${conflict.nodeId} (${fields})`);
|
|
1091
|
+
}
|
|
1092
|
+
console.log();
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (status.changedFiles.length > 0) {
|
|
1096
|
+
console.log('Changes not pushed:');
|
|
1097
|
+
console.log(' (use "hardcopy push <file>" to push changes)');
|
|
1098
|
+
console.log(' (use "hardcopy diff <file>" to see changes)\n');
|
|
1099
|
+
for (const file of status.changedFiles) {
|
|
1100
|
+
const marker = file.status === 'new' ? 'new file:' : 'modified:';
|
|
1101
|
+
console.log(` ${marker} ${file.path}`);
|
|
1102
|
+
}
|
|
1103
|
+
console.log();
|
|
1104
|
+
} else if (conflicts.length === 0) {
|
|
1105
|
+
console.log('No local changes\n');
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ... rest of status
|
|
1109
|
+
} finally {
|
|
1110
|
+
await hc.close();
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
---
|
|
1116
|
+
|
|
1117
|
+
## Task 9: Tests
|
|
1118
|
+
|
|
1119
|
+
**File**: `src/__tests__/conflict.test.ts` (new file)
|
|
1120
|
+
|
|
1121
|
+
```typescript
|
|
1122
|
+
import { describe, it, expect } from 'vitest';
|
|
1123
|
+
import {
|
|
1124
|
+
detectFieldConflict,
|
|
1125
|
+
detectConflicts,
|
|
1126
|
+
hasUnresolvableConflicts,
|
|
1127
|
+
autoMergeField,
|
|
1128
|
+
generateConflictMarkers,
|
|
1129
|
+
} from '../conflict';
|
|
1130
|
+
import { ConflictStatus } from '../types';
|
|
1131
|
+
|
|
1132
|
+
describe('detectFieldConflict', () => {
|
|
1133
|
+
it('returns CLEAN when nothing changed', () => {
|
|
1134
|
+
const result = detectFieldConflict('body', {
|
|
1135
|
+
base: 'hello',
|
|
1136
|
+
local: 'hello',
|
|
1137
|
+
remote: 'hello',
|
|
1138
|
+
});
|
|
1139
|
+
expect(result.status).toBe(ConflictStatus.CLEAN);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it('returns CLEAN when only local changed', () => {
|
|
1143
|
+
const result = detectFieldConflict('body', {
|
|
1144
|
+
base: 'hello',
|
|
1145
|
+
local: 'hello world',
|
|
1146
|
+
remote: 'hello',
|
|
1147
|
+
});
|
|
1148
|
+
expect(result.status).toBe(ConflictStatus.CLEAN);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it('returns REMOTE_ONLY when only remote changed', () => {
|
|
1152
|
+
const result = detectFieldConflict('body', {
|
|
1153
|
+
base: 'hello',
|
|
1154
|
+
local: 'hello',
|
|
1155
|
+
remote: 'hello world',
|
|
1156
|
+
});
|
|
1157
|
+
expect(result.status).toBe(ConflictStatus.REMOTE_ONLY);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('returns DIVERGED when both changed differently', () => {
|
|
1161
|
+
const result = detectFieldConflict('body', {
|
|
1162
|
+
base: 'hello',
|
|
1163
|
+
local: 'hello local',
|
|
1164
|
+
remote: 'hello remote',
|
|
1165
|
+
});
|
|
1166
|
+
expect(result.status).toBe(ConflictStatus.DIVERGED);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it('returns CLEAN when both changed to same value', () => {
|
|
1170
|
+
const result = detectFieldConflict('body', {
|
|
1171
|
+
base: 'hello',
|
|
1172
|
+
local: 'hello world',
|
|
1173
|
+
remote: 'hello world',
|
|
1174
|
+
});
|
|
1175
|
+
expect(result.status).toBe(ConflictStatus.CLEAN);
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
describe('autoMergeField', () => {
|
|
1180
|
+
it('merges list additions from both sides', () => {
|
|
1181
|
+
const conflict = {
|
|
1182
|
+
field: 'labels',
|
|
1183
|
+
status: ConflictStatus.DIVERGED,
|
|
1184
|
+
base: ['bug'],
|
|
1185
|
+
local: ['bug', 'urgent'],
|
|
1186
|
+
remote: ['bug', 'help-wanted'],
|
|
1187
|
+
canAutoMerge: true,
|
|
1188
|
+
};
|
|
1189
|
+
const result = autoMergeField(conflict);
|
|
1190
|
+
expect(result).toEqual(expect.arrayContaining(['bug', 'urgent', 'help-wanted']));
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it('returns null for non-mergeable fields', () => {
|
|
1194
|
+
const conflict = {
|
|
1195
|
+
field: 'body',
|
|
1196
|
+
status: ConflictStatus.DIVERGED,
|
|
1197
|
+
base: 'hello',
|
|
1198
|
+
local: 'hello local',
|
|
1199
|
+
remote: 'hello remote',
|
|
1200
|
+
canAutoMerge: false,
|
|
1201
|
+
};
|
|
1202
|
+
expect(autoMergeField(conflict)).toBeNull();
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
describe('generateConflictMarkers', () => {
|
|
1207
|
+
it('generates diff3-style markers', () => {
|
|
1208
|
+
const conflict = {
|
|
1209
|
+
field: 'body',
|
|
1210
|
+
status: ConflictStatus.DIVERGED,
|
|
1211
|
+
base: 'base text',
|
|
1212
|
+
local: 'local text',
|
|
1213
|
+
remote: 'remote text',
|
|
1214
|
+
canAutoMerge: false,
|
|
1215
|
+
};
|
|
1216
|
+
const result = generateConflictMarkers(conflict);
|
|
1217
|
+
expect(result).toContain('<<<<<<< LOCAL');
|
|
1218
|
+
expect(result).toContain('local text');
|
|
1219
|
+
expect(result).toContain('||||||| BASE');
|
|
1220
|
+
expect(result).toContain('base text');
|
|
1221
|
+
expect(result).toContain('=======');
|
|
1222
|
+
expect(result).toContain('remote text');
|
|
1223
|
+
expect(result).toContain('>>>>>>> REMOTE');
|
|
1224
|
+
});
|
|
1225
|
+
});
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
---
|
|
1229
|
+
|
|
1230
|
+
## File Summary
|
|
1231
|
+
|
|
1232
|
+
| File | Action | Description |
|
|
1233
|
+
|------|--------|-------------|
|
|
1234
|
+
| `src/types.ts` | Create/Update | Add ConflictStatus, FieldConflict, ConflictInfo types |
|
|
1235
|
+
| `src/provider.ts` | Update | Add `fetchNode` to Provider interface |
|
|
1236
|
+
| `src/providers/github.ts` | Update | Implement `fetchNode` |
|
|
1237
|
+
| `src/conflict.ts` | Create | Core conflict detection and merge logic |
|
|
1238
|
+
| `src/conflict-store.ts` | Create | File-based conflict storage |
|
|
1239
|
+
| `src/hardcopy.ts` | Update | Integrate conflict detection into push, add resolve methods |
|
|
1240
|
+
| `src/cli.ts` | Update | Add `conflicts`, `resolve` commands; update `status`, `push` |
|
|
1241
|
+
| `src/__tests__/conflict.test.ts` | Create | Unit tests |
|
|
1242
|
+
|
|
1243
|
+
---
|
|
1244
|
+
|
|
1245
|
+
## Execution Order
|
|
1246
|
+
|
|
1247
|
+
1. **Types** (zero dependencies)
|
|
1248
|
+
2. **Conflict module** (depends on types)
|
|
1249
|
+
3. **Conflict store** (depends on types, conflict)
|
|
1250
|
+
4. **Provider interface** (zero dependencies)
|
|
1251
|
+
5. **GitHub provider** (depends on provider interface)
|
|
1252
|
+
6. **Hardcopy class** (depends on all above)
|
|
1253
|
+
7. **CLI commands** (depends on hardcopy class)
|
|
1254
|
+
8. **Tests** (parallel with above)
|