Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ Best-effort session artifact sync via Git paths:
- `~/.local/share/opencode/storage/session_diff/`

This mode can conflict with concurrent writers.
`/sync-link` restores the repo's session snapshot on a new machine. On an already linked machine,
`/sync-pull` reapplies that snapshot even when Git has no new commits, replacing local session
artifacts. Restart opencode after either command to load the restored sessions.

Large `opencode.db` files and legacy files under `storage/message/` are represented as a small,
versioned pointer plus 40 MiB parts once they exceed 50 MiB. Parts live in the plugin-owned
Expand Down
17 changes: 17 additions & 0 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ export async function syncRepoToLocal(
}
}

export async function syncSessionArtifactsRepoToLocal(
plan: SyncPlan,
options: { chunkOptions?: ChunkOptions } = {}
): Promise<boolean> {
let restored = false;
for (const item of plan.items) {
if (!item.preserveWhenMissing || !(await pathExists(item.repoPath))) continue;
if (item.chunkLargeFiles) {
await copyChunkableItemFromRepo(item, plan.repoRoot, options.chunkOptions);
} else {
await copyItem(item.repoPath, item.localPath, item.type);
}
restored = true;
}
return restored;
}

export async function syncLocalToRepo(
plan: SyncPlan,
overrides: Record<string, unknown> | null,
Expand Down
84 changes: 83 additions & 1 deletion src/sync/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { promisify } from 'node:util';

import type { PluginInput } from '@opencode-ai/plugin';
import { describe, expect, it } from 'vitest';
import { loadState, loadSyncConfig, writeSyncConfig } from './config.js';
import { loadState, loadSyncConfig, writeState, writeSyncConfig } from './config.js';
import { resolveSyncLocations } from './paths.js';
import { createSyncService } from './service.js';

Expand Down Expand Up @@ -110,6 +110,88 @@ async function withIsolatedEnvironment(run: (root: string) => Promise<void>): Pr
}

describe('explicit Git remote service flow', () => {
it('restores sessions on link and an up-to-date pull', async () => {
await withIsolatedEnvironment(async (root) => {
const testShell = createTestShell();
const writeSqlite = async (dbPath: string, sql: string): Promise<void> => {
const script =
'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); db.executescript(sys.argv[2]); db.commit(); db.close()';
await testShell`python3 -c ${script} ${dbPath} ${sql}`.quiet();
};
const readSessionTitle = async (dbPath: string): Promise<string> => {
const script =
'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); row = db.execute("SELECT title FROM session WHERE id = ?", ("ses_issue_51",)).fetchone(); print(row[0] if row else ""); db.close()';
return (await testShell`python3 -c ${script} ${dbPath}`.text()).trim();
};
const remotePath = path.join(root, 'sync-remote.git');
await testShell`git init --bare ${remotePath}`.quiet();

const machineAHome = path.join(root, 'machine-a');
useIsolatedHome(machineAHome);
const machineALocations = resolveSyncLocations();
await fs.mkdir(machineALocations.configRoot, { recursive: true });
await fs.writeFile(path.join(machineALocations.configRoot, 'opencode.json'), '{}\n');
const machineADbPath = path.join(machineALocations.xdg.dataDir, 'opencode', 'opencode.db');
await fs.mkdir(path.dirname(machineADbPath), { recursive: true });
await writeSqlite(
machineADbPath,
"CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT); INSERT INTO session VALUES ('ses_issue_51', 'Machine A');"
);

const machineAService = createSyncService({ client: createClient(), $: testShell });
await machineAService.init({
repo: remotePath,
branch: 'main',
includeSecrets: true,
includeSessions: true,
acknowledgePrivateRemote: true,
});

useIsolatedHome(path.join(root, 'machine-unacknowledged'));
const unacknowledgedLocations = resolveSyncLocations();
const unacknowledgedService = createSyncService({ client: createClient(), $: testShell });
await expect(
unacknowledgedService.link({ repo: remotePath, branch: 'main' })
).rejects.toThrow('privacy cannot be verified');
await expect(
fs.stat(path.join(unacknowledgedLocations.xdg.dataDir, 'opencode', 'opencode.db'))
).rejects.toMatchObject({ code: 'ENOENT' });

const machineBHome = path.join(root, 'machine-b');
useIsolatedHome(machineBHome);
const machineBLocations = resolveSyncLocations();
const machineBDbPath = path.join(machineBLocations.xdg.dataDir, 'opencode', 'opencode.db');
const machineBService = createSyncService({ client: createClient(), $: testShell });
await machineBService.link({
repo: remotePath,
branch: 'main',
acknowledgePrivateRemote: true,
});

await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A');
const stateAfterLink = await loadState(machineBLocations);
expect(stateAfterLink.lastRemoteUpdate).toBeDefined();
await writeState(machineBLocations, { ...stateAfterLink, lastPull: undefined });
await expect(machineBService.status()).resolves.toContain('Last pull: never');

await writeSqlite(machineBDbPath, 'DELETE FROM session;');

await fs.writeFile(
path.join(machineBLocations.configRoot, 'opencode.json'),
'{"theme":"local"}\n'
);

await expect(machineBService.pull()).resolves.toContain('Restart opencode to load them');
await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A');
await expect(machineBService.status()).resolves.toMatch(/Last pull: \d{4}-\d\d-\d\dT/u);
const stateAfterPull = await loadState(machineBLocations);
expect(stateAfterPull.lastRemoteUpdate).toBe(stateAfterLink.lastRemoteUpdate);
await expect(
fs.readFile(path.join(machineBLocations.configRoot, 'opencode.json'), 'utf8')
).resolves.toContain('local');
});
}, 30_000);

it('initializes, links, pushes, and pulls through a local bare remote', async () => {
await withIsolatedEnvironment(async (root) => {
const testShell = createTestShell();
Expand Down
18 changes: 17 additions & 1 deletion src/sync/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs';
import path from 'node:path';

import type { PluginInput } from '@opencode-ai/plugin';
import { syncLocalToRepo, syncRepoToLocal } from './apply.js';
import { syncLocalToRepo, syncRepoToLocal, syncSessionArtifactsRepoToLocal } from './apply.js';
import { generateCommitMessage } from './commit.js';
import type { NormalizedSyncConfig } from './config.js';
import {
Expand Down Expand Up @@ -936,6 +936,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {
await acknowledgePrivateRemote(locations, syncedConfig);
}
await ensureSensitiveSyncPolicy(ctx, locations, syncedConfig);
const sessionPlan = buildSyncPlan(syncedConfig, locations, repoRoot);
await syncSessionArtifactsRepoToLocal(sessionPlan);
}
if (syncedConfig && isTursoSessionBackend(syncedConfig)) {
const setup = await runTursoSetup(syncedConfig, { allowLogin: true });
Expand Down Expand Up @@ -991,8 +993,22 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {

const update = await fetchAndFastForward(ctx.$, repoRoot, branch);
if (!update.updated) {
const plan = buildSyncPlan(config, locations, repoRoot);
const restoredSessions = await syncSessionArtifactsRepoToLocal(plan);
const tursoSummary = await runForegroundTursoCycle(config, 'pull-up-to-date');
ensureTursoSyncLoop(config);
if (restoredSessions) {
await updateState(locations, { lastPull: new Date().toISOString() });
await showToast(
ctx.client,
'Sessions restored. Restart opencode to load them.',
'info'
);
return [
'Remote sessions restored. Restart opencode to load them.',
...(tursoSummary ? [tursoSummary] : []),
].join('\n');
}
if (tursoSummary) {
return ['Already up to date.', tursoSummary].join('\n');
}
Expand Down
Loading