Skip to content

Commit 833a4b8

Browse files
committed
feat: handle store config conflicts during git pull
Resolve remote name dynamically from branch tracking config instead of hardcoding 'origin'. Detect and surface sync-settings.json / machines.json conflicts after pull, with a dialog for manual resolution. Auto-commit config file writes so changes are tracked in store git history.
1 parent d505ef6 commit 833a4b8

File tree

8 files changed

+391
-7
lines changed

8 files changed

+391
-7
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ pnpm install && pnpm build && pnpm start
7777
On the setup screen, point to your existing data directory (clone your store repo first if needed).
7878

7979
The app will automatically:
80+
8081
- Assign a unique machine ID and name (based on hostname)
8182
- Restore all shared settings from `sync-settings.json`
8283
- Auto-link repos and services that have known paths for this machine

packages/server/src/routes/sync.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import type { FastifyInstance } from 'fastify';
22
import type { SyncLogEntry } from '../types/index.js';
33
import type { AppState } from '../app-state.js';
44
import { mapRows } from '../db/index.js';
5-
import { pullStoreChanges, pushStoreChanges, getStoreRemoteUrl } from '../services/store-git.js';
5+
import {
6+
pullStoreChanges,
7+
pushStoreChanges,
8+
getStoreRemoteUrl,
9+
resolveStoreConfigConflict,
10+
} from '../services/store-git.js';
11+
import { restoreSettingsFromFile } from '../services/sync-settings.js';
612

713
interface SyncLogEntryWithRepo extends SyncLogEntry {
814
repoName: string | null;
@@ -30,6 +36,39 @@ export function registerSyncRoutes(app: FastifyInstance, state: AppState): void
3036
}
3137
});
3238

39+
// Resolve a store config conflict (sync-settings.json or machines.json)
40+
app.post<{
41+
Params: { file: string };
42+
Body: { content: string };
43+
}>('/api/store/resolve-config/:file', async (req, reply) => {
44+
if (!state.db) return reply.code(503).send({ error: 'Not configured' });
45+
const db = state.db;
46+
47+
const file = req.params.file;
48+
if (file !== 'sync-settings.json' && file !== 'machines.json') {
49+
return reply.code(400).send({ error: 'Invalid file name' });
50+
}
51+
52+
const { content } = req.body;
53+
if (typeof content !== 'string') {
54+
return reply.code(400).send({ error: 'Missing content' });
55+
}
56+
57+
try {
58+
await resolveStoreConfigConflict(file, content);
59+
60+
// Reload settings into DB if sync-settings.json was resolved
61+
if (file === 'sync-settings.json') {
62+
restoreSettingsFromFile(db);
63+
}
64+
65+
return { resolved: true };
66+
} catch (err) {
67+
const message = err instanceof Error ? err.message : 'Resolve failed';
68+
return reply.code(500).send({ error: message });
69+
}
70+
});
71+
3372
// Push store changes to remote
3473
app.post('/api/store/push', async (_req, reply) => {
3574
if (!state.db) return reply.code(503).send({ error: 'Not configured' });

packages/server/src/services/machines.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { getRepoEnabledFilePatterns } from '../db/index.js';
2121
import { applyOverridesForRepo, applyOverridesForService } from './sync-settings.js';
2222
import { getServiceDefinition, registerCustomDefinition } from './service-definitions.js';
2323
import { scanServiceFiles } from './service-scanner.js';
24+
import { queueStoreCommit } from './store-git.js';
2425

2526
const MACHINES_FILE = 'machines.json';
2627

@@ -56,6 +57,7 @@ export function writeMachinesFile(data: MachinesFile): void {
5657
services: sortKeys(data.services),
5758
};
5859
fs.writeFileSync(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
60+
queueStoreCommit('Update machines.json');
5961
}
6062

6163
function sortKeys<T>(obj: Record<string, T>): Record<string, T> {

packages/server/src/services/store-git.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,126 @@ async function resolveRemote(): Promise<string | null> {
196196
return remotes.find((r) => r.name === 'origin')?.name ?? remotes[0].name;
197197
}
198198

199-
export async function pullStoreChanges(): Promise<{ pulled: boolean; message: string }> {
199+
export interface StoreConfigConflict {
200+
file: 'sync-settings.json' | 'machines.json';
201+
content: string; // raw content with git conflict markers
202+
ours: string; // content of the "ours" (HEAD) side
203+
theirs: string; // content of the "theirs" (incoming) side
204+
}
205+
206+
export interface PullResult {
207+
pulled: boolean;
208+
message: string;
209+
storeConflicts?: StoreConfigConflict[];
210+
}
211+
212+
/** Parse conflict markers from a conflicted file, returning ours and theirs content. */
213+
function parseConflictSides(content: string): { ours: string; theirs: string } {
214+
// Collect lines from each side by scanning conflict blocks
215+
const oursLines: string[] = [];
216+
const theirsLines: string[] = [];
217+
let inOurs = false;
218+
let inTheirs = false;
219+
220+
for (const line of content.split('\n')) {
221+
if (line.startsWith('<<<<<<<')) {
222+
inOurs = true;
223+
inTheirs = false;
224+
} else if (line.startsWith('=======')) {
225+
inOurs = false;
226+
inTheirs = true;
227+
} else if (line.startsWith('>>>>>>>')) {
228+
inOurs = false;
229+
inTheirs = false;
230+
} else if (inOurs) {
231+
oursLines.push(line);
232+
} else if (inTheirs) {
233+
theirsLines.push(line);
234+
} else {
235+
// Context lines outside conflict blocks — include in both
236+
oursLines.push(line);
237+
theirsLines.push(line);
238+
}
239+
}
240+
241+
return { ours: oursLines.join('\n'), theirs: theirsLines.join('\n') };
242+
}
243+
244+
export async function pullStoreChanges(): Promise<PullResult> {
200245
const remote = await resolveRemote();
201246
if (!remote) {
202247
return { pulled: false, message: 'No remote configured' };
203248
}
204249

205-
const branch = await git!.branchLocal();
206-
await git!.pull(remote, branch.current);
250+
if (!git) {
251+
git = createGit(config.storePath);
252+
}
253+
254+
const branch = await git.branchLocal();
255+
try {
256+
await git.pull(remote, branch.current);
257+
} catch (err) {
258+
// If git pull itself throws (e.g. merge conflict), check status for conflicted files
259+
const status = await git.status();
260+
const conflictedPaths = status.conflicted;
261+
262+
const knownConfigFiles = ['sync-settings.json', 'machines.json'] as const;
263+
const storeConflicts: StoreConfigConflict[] = [];
264+
265+
for (const filePath of conflictedPaths) {
266+
const basename = path.basename(filePath) as (typeof knownConfigFiles)[number];
267+
if (!knownConfigFiles.includes(basename)) continue;
268+
269+
const fullPath = path.join(config.storePath, filePath);
270+
let content = '';
271+
try {
272+
content = await fs.readFile(fullPath, 'utf-8');
273+
} catch {
274+
continue;
275+
}
276+
277+
const { ours, theirs } = parseConflictSides(content);
278+
storeConflicts.push({ file: basename, content, ours, theirs });
279+
}
280+
281+
if (storeConflicts.length > 0) {
282+
return {
283+
pulled: true,
284+
message: `Pulled from ${remote}/${branch.current} with config conflicts`,
285+
storeConflicts,
286+
};
287+
}
288+
289+
// Re-throw if no config conflicts (other errors like network issues)
290+
throw err;
291+
}
292+
207293
return { pulled: true, message: `Pulled from ${remote}/${branch.current}` };
208294
}
209295

296+
/**
297+
* Resolve a store config conflict by writing the chosen content to disk,
298+
* then staging and committing the resolution.
299+
*/
300+
export async function resolveStoreConfigConflict(
301+
file: 'sync-settings.json' | 'machines.json',
302+
content: string,
303+
): Promise<void> {
304+
if (!git) {
305+
git = createGit(config.storePath);
306+
}
307+
308+
const fullPath = path.join(config.storePath, file);
309+
await fs.writeFile(fullPath, content, 'utf-8');
310+
await git.add(file);
311+
312+
// Check if all conflicts are resolved before committing
313+
const status = await git.status();
314+
if (status.conflicted.length === 0) {
315+
await git.commit(`Resolve conflict in ${file}`);
316+
}
317+
}
318+
210319
export async function pushStoreChanges(): Promise<{ pushed: boolean; message: string }> {
211320
const remote = await resolveRemote();
212321
if (!remote) {

packages/server/src/services/sync-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { config } from '../config.js';
66
import { mapRows } from '../db/index.js';
77
import { DEFAULT_SETTINGS } from '../db/schema.js';
88
import type { Repo, ServiceConfig } from '../types/index.js';
9+
import { queueStoreCommit } from './store-git.js';
910

1011
// ── Types ────────────────────────────────────────────────────────────
1112

@@ -136,6 +137,7 @@ export function writeSyncSettingsFile(data: SyncSettingsFile): void {
136137
};
137138

138139
fs.writeFileSync(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
140+
queueStoreCommit('Update sync-settings.json');
139141
}
140142

141143
// ── Read DB state into file sections ─────────────────────────────────

packages/ui/src/components/layout.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Button } from '@/components/ui/button';
2-
import { api } from '@/lib/api';
2+
import { api, type StoreConfigConflict } from '@/lib/api';
33
import { cn } from '@/lib/utils';
44
import {
55
Database,
@@ -17,6 +17,7 @@ import { useEffect, useRef, useState } from 'react';
1717
import { Link, useLocation } from 'react-router-dom';
1818
import { toast } from 'sonner';
1919
import { ConfirmDialog } from './confirm-dialog';
20+
import { StoreConfigConflictDialog } from './store-config-conflict-dialog';
2021
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
2122
import { UpdateBanner } from './update-banner';
2223
import { useMachine } from '@/hooks/use-machines';
@@ -36,6 +37,9 @@ export function Layout({ children, dataDir }: { children: React.ReactNode; dataD
3637
const [resetOpen, setResetOpen] = useState(false);
3738
const [resetting, setResetting] = useState(false);
3839
const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
40+
const [storeConfigConflicts, setStoreConfigConflicts] = useState<StoreConfigConflict[] | null>(
41+
null,
42+
);
3943
const { machineName } = useMachine();
4044

4145
useEffect(() => {
@@ -86,7 +90,10 @@ export function Layout({ children, dataDir }: { children: React.ReactNode; dataD
8690
setPulling(true);
8791
try {
8892
const result = await api.store.pull();
89-
if (result.pulled) {
93+
if (result.storeConflicts && result.storeConflicts.length > 0) {
94+
setStoreConfigConflicts(result.storeConflicts);
95+
toast.warning('Pull completed with config conflicts — please resolve them');
96+
} else if (result.pulled) {
9097
toast.success(result.message);
9198
} else {
9299
toast.warning(result.message);
@@ -296,6 +303,13 @@ export function Layout({ children, dataDir }: { children: React.ReactNode; dataD
296303
description="Push all committed store changes to the remote repository?"
297304
confirmLabel="Push"
298305
/>
306+
307+
{storeConfigConflicts && storeConfigConflicts.length > 0 && (
308+
<StoreConfigConflictDialog
309+
conflicts={storeConfigConflicts}
310+
onResolved={() => setStoreConfigConflicts(null)}
311+
/>
312+
)}
299313
</div>
300314
);
301315
}

0 commit comments

Comments
 (0)