Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
34 changes: 31 additions & 3 deletions scripts/e2e/github_two_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ class ServerInstance:
real_home: Path
real_xdg_config: Path
real_xdg_data: Path
strict_link_repo: str | None = None
disable_auto_repo_discovery: bool = False

process: subprocess.Popen[str] | None = None
process_group_id: int | None = None
Expand Down Expand Up @@ -174,6 +176,10 @@ def start(self, logs_dir: Path) -> None:
env['GH_TOKEN'] = self.gh_token
if self.real_git_config:
env['GIT_CONFIG_GLOBAL'] = str(self.real_git_config)
if self.disable_auto_repo_discovery:
env['OPENCODE_SYNC_E2E_DISABLE_AUTO_REPO_DISCOVERY'] = '1'
if self.strict_link_repo:
env['OPENCODE_SYNC_E2E_STRICT_LINK_REPO'] = self.strict_link_repo

command = [
'opencode',
Expand Down Expand Up @@ -597,6 +603,22 @@ def run_command(client: ApiClient, session_id: str, command: str, arguments: str
return payload


def seed_sync_link_repo_instruction(client: ApiClient, session_id: str, full_repo: str) -> None:
instruction = (
'For this E2E run, always use the exact GitHub repo '
f'"{full_repo}" for sync-link commands. '
'Do not auto-discover or substitute any default repo names.'
)
client.post_json(
f'/session/{urllib.parse.quote(session_id)}/prompt_async',
{
'noReply': True,
'parts': [{'type': 'text', 'text': instruction}],
},
timeout_sec=40,
)


def response_error(payload: dict[str, Any]) -> str | None:
info = payload.get('info')
if not isinstance(info, dict):
Expand Down Expand Up @@ -971,6 +993,8 @@ def run_e2e(args: argparse.Namespace) -> int:
real_home=real_home,
real_xdg_config=Path(os.environ.get('XDG_CONFIG_HOME', str(real_home / '.config'))),
real_xdg_data=Path(os.environ.get('XDG_DATA_HOME', str(real_home / '.local' / 'share'))),
strict_link_repo=full_repo,
disable_auto_repo_discovery=True,
)

summary: dict[str, Any] = {
Expand Down Expand Up @@ -1013,7 +1037,9 @@ def run_e2e(args: argparse.Namespace) -> int:

session_a = create_session(client_a)
session_b = create_session(client_b)
seed_sync_link_repo_instruction(client_b, session_b, full_repo)
summary['sessions'] = {'machine_a': session_a, 'machine_b': session_b}
summary['strict_link_repo'] = full_repo
log(f'machine-a session: {session_a}')
log(f'machine-b session: {session_b}')

Expand Down Expand Up @@ -1129,15 +1155,17 @@ def run_e2e(args: argparse.Namespace) -> int:
client=client_b,
session_id=session_b,
command='sync-link',
arguments=repo_name,
arguments=full_repo,
timeout_sec=args.timeout_sec,
result_path=results_dir / f'machine-b-sync-link{suffix}.json',
active_repo_root=repo_root,
baseline_state=baseline_state,
label=f'sync-link on machine B (attempt {attempt})',
)

if machine_b_sync_config.exists() and file_contains(machine_b_sync_config, f'\"name\": \"{repo_name}\"'):
if machine_b_sync_config.exists() and file_contains(
machine_b_sync_config, f'\"owner\": \"{owner}\"'
) and file_contains(machine_b_sync_config, f'\"name\": \"{repo_name}\"'):
break

if attempt == max_link_attempts:
Expand All @@ -1146,7 +1174,7 @@ def run_e2e(args: argparse.Namespace) -> int:
preview = machine_b_sync_config.read_text(encoding='utf-8', errors='replace')
raise E2EFailure(
'sync-link bound machine B to an unexpected repo.\n'
f'Expected repo name: {repo_name}\n'
f'Expected repo: {full_repo}\n'
f'Config path: {machine_b_sync_config}\n'
f'Config contents:\n{preview}'
)
Expand Down
2 changes: 1 addition & 1 deletion src/command/sync-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ You MUST call the `opencode_sync` tool with `command="link"`.
Do not answer with plain text only.

Argument handling:
- If `$ARGUMENTS` is non-empty, pass `repo="$ARGUMENTS"`.
- If `$ARGUMENTS` is non-empty, pass `repo="$ARGUMENTS"` exactly as provided. Do not rewrite or shorten it.
- If `$ARGUMENTS` is empty, let the tool auto-discover.

Reminder:
Expand Down
38 changes: 37 additions & 1 deletion src/sync/repo.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { parseRepoVisibility } from './repo.js';
import { parseRepoReference, parseRepoVisibility } from './repo.js';

describe('parseRepoVisibility', () => {
it('parses private status', () => {
Expand All @@ -12,3 +12,39 @@ describe('parseRepoVisibility', () => {
expect(() => parseRepoVisibility('{"private": true}')).toThrow();
});
});

describe('parseRepoReference', () => {
it('parses short repo name with authenticated-user fallback', () => {
expect(parseRepoReference('my-opencode-config', 'ihildy')).toEqual({
owner: 'ihildy',
name: 'my-opencode-config',
});
});

it('parses explicit owner/repo input', () => {
expect(parseRepoReference('acme/opencode-sync', 'ignored')).toEqual({
owner: 'acme',
name: 'opencode-sync',
});
});

it('parses GitHub https repo URLs', () => {
expect(parseRepoReference('https://github.com/acme/opencode-sync.git', 'ignored')).toEqual({
owner: 'acme',
name: 'opencode-sync',
});
});

it('parses GitHub SSH repo URLs', () => {
expect(parseRepoReference('git@github.com:acme/opencode-sync.git', 'ignored')).toEqual({
owner: 'acme',
name: 'opencode-sync',
});
});

it('returns null for invalid repo references', () => {
expect(parseRepoReference('https://example.com/acme/opencode-sync', 'ignored')).toBeNull();
expect(parseRepoReference('acme/opencode/sync', 'ignored')).toBeNull();
expect(parseRepoReference(' ', 'ihildy')).toBeNull();
});
});
88 changes: 84 additions & 4 deletions src/sync/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,19 +272,65 @@ export interface FoundRepo {
isPrivate: boolean;
}

export async function findSyncRepo($: Shell, repoName?: string): Promise<FoundRepo | null> {
export interface FindSyncRepoOptions {
disableAutoDiscovery?: boolean;
}

export interface RepoReference {
owner: string;
name: string;
}

export function parseRepoReference(input: string, fallbackOwner: string): RepoReference | null {
const raw = input.trim();
if (!raw) return null;

const fromHttpUrl = parseGitHubHttpRepo(raw);
if (fromHttpUrl) return fromHttpUrl;

const fromSshUrl = parseGitHubSshRepo(raw);
if (fromSshUrl) return fromSshUrl;

if (raw.includes('/')) {
const parts = raw.split('/').filter(Boolean);
if (parts.length !== 2) return null;
const [owner, repoRaw] = parts;
const name = normalizeRepoName(repoRaw);
if (!owner || !name) return null;
return { owner, name };
}
Comment thread
iHildy marked this conversation as resolved.

const name = normalizeRepoName(raw);
if (!name || !fallbackOwner) return null;
return { owner: fallbackOwner, name };
}

export async function findSyncRepo(
$: Shell,
repoName?: string,
options: FindSyncRepoOptions = {}
): Promise<FoundRepo | null> {
const owner = await getAuthenticatedUser($);

// If user provided a specific name, check that first
if (repoName) {
const exists = await repoExists($, `${owner}/${repoName}`);
const target = parseRepoReference(repoName, owner);
if (!target) {
return null;
}
const repoIdentifier = `${target.owner}/${target.name}`;
const exists = await repoExists($, repoIdentifier);
if (exists) {
const isPrivate = await checkRepoPrivate($, `${owner}/${repoName}`);
return { owner, name: repoName, isPrivate };
const isPrivate = await checkRepoPrivate($, repoIdentifier);
return { owner: target.owner, name: target.name, isPrivate };
}
return null;
}

if (options.disableAutoDiscovery) {
return null;
}

// Search through likely repo names
for (const name of LIKELY_SYNC_REPO_NAMES) {
const exists = await repoExists($, `${owner}/${name}`);
Expand All @@ -305,3 +351,37 @@ async function checkRepoPrivate($: Shell, repoIdentifier: string): Promise<boole
return false;
}
}

function parseGitHubHttpRepo(raw: string): RepoReference | null {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return null;
}

if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
if (parsed.hostname !== 'github.com' && parsed.hostname !== 'www.github.com') return null;

const [ownerRaw = '', repoRaw = ''] = parsed.pathname.split('/').filter(Boolean);
if (!ownerRaw || !repoRaw) return null;

const name = normalizeRepoName(repoRaw);
if (!name) return null;
return { owner: ownerRaw, name };
}
Comment thread
iHildy marked this conversation as resolved.

function parseGitHubSshRepo(raw: string): RepoReference | null {
const match = raw.match(/^git@github\.com:([^/\s]+)\/([^/\s]+)$/i);
Comment thread
iHildy marked this conversation as resolved.
Outdated
if (!match) return null;
const owner = match[1] ?? '';
const name = normalizeRepoName(match[2] ?? '');
if (!owner || !name) return null;
return { owner, name };
}

function normalizeRepoName(repoName: string): string {
const trimmed = repoName.trim();
if (!trimmed) return '';
return trimmed.replace(/\.git$/i, '');
}
51 changes: 48 additions & 3 deletions src/sync/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
getRepoStatus,
hasLocalChanges,
isRepoCloned,
parseRepoReference,
pushBranch,
repoExists,
resolveRepoBranch,
Expand Down Expand Up @@ -109,6 +110,9 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {
const locations = resolveSyncLocations();
const log = createLogger(ctx.client);
const lockPath = path.join(path.dirname(locations.statePath), 'sync.lock');
const strictLinkRepo = resolveStrictLinkRepo(process.env.OPENCODE_SYNC_E2E_STRICT_LINK_REPO);
const disableAutoRepoDiscovery =
process.env.OPENCODE_SYNC_E2E_DISABLE_AUTO_REPO_DISCOVERY === '1' || strictLinkRepo !== null;
let tursoSyncTimer: ReturnType<typeof setInterval> | null = null;
let tursoSyncIntervalSec = 15;

Expand Down Expand Up @@ -606,25 +610,51 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {
}),
link: (options: LinkOptions) =>
runExclusive(async () => {
const found = await findSyncRepo(ctx.$, options.repo);
if (disableAutoRepoDiscovery && !options.repo) {
const expectation = strictLinkRepo
? ` Provide the exact repo: ${strictLinkRepo.owner}/${strictLinkRepo.name}.`
: '';
throw new SyncCommandError(
'Repo auto-discovery is disabled in this environment. ' +
'Run /sync-link with an explicit repo argument.' +
expectation
);
}

const found = await findSyncRepo(ctx.$, options.repo, {
disableAutoDiscovery: disableAutoRepoDiscovery,
});

if (!found) {
const searchedFor = options.repo
? `"${options.repo}"`
: 'common sync repo names (my-opencode-config, opencode-config, etc.)';
: disableAutoRepoDiscovery
? '(none; auto-discovery disabled)'
: 'common sync repo names (my-opencode-config, opencode-config, etc.)';

const lines = [
`Could not find an existing sync repo. Searched for: ${searchedFor}`,
'',
'To link to an existing repo, run:',
' /sync-link <repo-name>',
' /sync-link <owner/repo>',
'',
'To create a new sync repo, run:',
' /sync-init',
];
return lines.join('\n');
}

if (strictLinkRepo) {
const linkedIdentifier = `${found.owner}/${found.name}`.toLowerCase();
const expectedIdentifier = `${strictLinkRepo.owner}/${strictLinkRepo.name}`.toLowerCase();
if (linkedIdentifier !== expectedIdentifier) {
throw new SyncCommandError(
`Strict link mode expected repo ${strictLinkRepo.owner}/${strictLinkRepo.name}, ` +
`but resolved ${found.owner}/${found.name}.`
);
}
}

const config = normalizeSyncConfig({
repo: { owner: found.owner, name: found.name },
includeSecrets: false,
Expand Down Expand Up @@ -1390,3 +1420,18 @@ function parseResolutionDecision(text: string): ResolutionDecision {
return { action: 'manual', reason: 'Failed to parse AI decision' };
}
}

function resolveStrictLinkRepo(raw: string | undefined): { owner: string; name: string } | null {
if (!raw) return null;
const value = raw.trim();
if (!value) return null;

const parsed = parseRepoReference(value, '__opencode_sync_no_owner__');
if (!parsed || parsed.owner === '__opencode_sync_no_owner__') {
throw new SyncCommandError(
'OPENCODE_SYNC_E2E_STRICT_LINK_REPO must be an explicit owner/repo or GitHub repo URL.'
);
}

return parsed;
}
Loading