import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, symlink } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import repositoryModule from '../src/main/repository-service.cjs'; const { RepositoryService } = repositoryModule; function status(head = 'a'.repeat(40)) { return { head, shortHead: head.slice(0, 7), clean: true, counts: { changed: 0, conflicts: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }; } const remote = { id: 1, name: 'Portfolio', full_name: 'Jens/Portfolio', owner: { login: 'Jens' }, private: true, default_branch: 'main', html_url: 'https://gitea.example/Jens/Portfolio', clone_url: 'https://gitea.example/Jens/Portfolio.git', ssh_url: 'git@gitea.example:Jens/Portfolio.git' }; function service() { return new RepositoryService({ data: { preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] } }, {}, {}); } test('a synchronized commit is deployable when the server is unknown or older', () => { const current = status(); const unknown = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ { id: 'prod', branch: 'main', state: { liveSha: null, healthy: null } } ]); assert.equal(unknown.readyToDeploy, true); const older = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ { id: 'prod', branch: 'main', state: { liveSha: 'b'.repeat(40), healthy: true } } ]); assert.equal(older.readyToDeploy, true); }); test('a healthy commit already live on the server is not offered for deployment again', () => { const current = status(); const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ { id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: true } } ]); assert.equal(repository.readyToDeploy, false); }); test('an unhealthy live commit remains eligible for a controlled redeploy', () => { const current = status(); const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [ { id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: false } } ]); assert.equal(repository.readyToDeploy, true); }); test('repository discovery is bounded, skips generated trees and ignores inaccessible roots', async (context) => { const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-repositories-')); context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))); await mkdir(path.join(root, 'group', 'app', '.git'), { recursive: true }); await mkdir(path.join(root, 'node_modules', 'ignored', '.git'), { recursive: true }); await mkdir(path.join(root, 'too', 'deep', 'repository', '.git'), { recursive: true }); try { await symlink(path.join(root, 'group'), path.join(root, 'linked'), 'junction'); } catch {} const instance = service(); const found = await instance.discoverInRoot(root, 2); assert.deepEqual(found, [await import('node:fs/promises').then(({ realpath }) => realpath(path.join(root, 'group', 'app')))]); assert.deepEqual(await instance.discoverInRoot(path.join(root, 'missing')), []); const all = await instance.discoverAll([root, root, '', null]); assert.equal(all.length, 2); assert.equal(new Set(all).size, 2); assert.ok(all.includes(found[0])); }); test('a repository reached through a directory junction is discovered once', async (context) => { const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-junction-')); context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))); const elsewhere = path.join(root, 'elsewhere', 'service'); await mkdir(path.join(elsewhere, '.git'), { recursive: true }); await mkdir(path.join(root, 'workspace'), { recursive: true }); try { await symlink(elsewhere, path.join(root, 'workspace', 'linked-service'), 'junction'); } catch { context.skip('this platform does not allow creating directory links'); return; } const { realpath } = await import('node:fs/promises'); const found = await service().discoverInRoot(path.join(root, 'workspace'), 3); assert.deepEqual(found, [await realpath(elsewhere)]); }); test('local descriptors preserve Git failures and watch paths are defensive copies', async () => { const instance = new RepositoryService({ data: {} }, { status: async (localPath) => { if (localPath.endsWith('bad')) throw new Error('not a repository'); return { ...status(), root: `${localPath}/canonical`, remoteUrl: remote.clone_url }; } }, {}); const descriptors = await instance.getLocalDescriptors(['good', 'bad']); assert.equal(descriptors[0].localPath, 'good/canonical'); assert.equal(descriptors[1].error, 'not a repository'); instance.lastKnownLocalPaths = ['one']; const watched = instance.getWatchPaths(); watched.push('two'); assert.deepEqual(instance.getWatchPaths(), ['one']); }); test('refresh links explicit and remote-matched repositories and retains unmatched locals', async () => { const diagnostics = []; const store = { data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: ['root'], repositoryMappings: { 'jens/portfolio': 'C:/explicit' }, preferences: { preferredCloneProtocol: 'https' }, favorites: ['jens/portfolio'] }, getToken: () => 'token', getDeploymentProfiles: (name) => name === remote.full_name ? [{ id: 'prod', branch: 'main' }] : [], getDeploymentState: () => ({ liveSha: null, healthy: null }) }; const secondRemote = { ...remote, id: 2, name: 'Other', full_name: 'Jens/Other', clone_url: 'https://gitea.example/Jens/Other.git' }; const instance = new RepositoryService(store, { status: async (localPath) => ({ ...status(localPath.includes('unmatched') ? 'b'.repeat(40) : 'a'.repeat(40)), root: localPath, remoteUrl: localPath.includes('matched') ? secondRemote.clone_url : remote.clone_url }) }, { listRepositories: async () => [remote, secondRemote] }, { debug: async (...args) => diagnostics.push(args) }); instance.discoverAll = async () => ['C:/matched', 'C:/unmatched']; const repositories = await instance.refresh(); const explicit = repositories.find((item) => item.fullName === remote.full_name); const matched = repositories.find((item) => item.fullName === secondRemote.full_name); const unmatched = repositories.find((item) => item.linkState === 'unmatched-local'); assert.equal(explicit.localPath, 'C:/explicit'); assert.equal(explicit.favorite, true); assert.equal(explicit.preferredCloneUrl, remote.clone_url); assert.equal(matched.localPath, 'C:/matched'); assert.equal(unmatched.localPath, 'C:/unmatched'); assert.deepEqual(instance.getWatchPaths().sort(), ['C:/explicit', 'C:/matched', 'C:/unmatched'].sort()); assert.equal(diagnostics[0][0], 'repositories.refresh.completed'); }); test('resolving one repository reads only that repository, not the whole workspace', async () => { const scanned = []; const store = { data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: ['root'], repositoryMappings: { 'jens/portfolio': 'C:/explicit' }, preferences: { preferredCloneProtocol: 'https' }, favorites: [] }, getToken: () => 'token', getDeploymentProfiles: () => [{ id: 'prod', branch: 'main' }], getDeploymentState: () => ({ liveSha: null, healthy: null }) }; const instance = new RepositoryService(store, { status: async (localPath) => { scanned.push(localPath); return { ...status(), root: localPath, remoteUrl: remote.clone_url }; } }, { listRepositories: async () => [remote, { ...remote, id: 2, full_name: 'Jens/Other', name: 'Other' }] }); instance.discoverAll = async () => ['C:/explicit', 'C:/other', 'C:/third']; await instance.refresh(); const duringRefresh = scanned.length; assert.equal(duringRefresh, 3); scanned.length = 0; const resolved = await instance.resolveByFullName(remote.full_name); assert.equal(resolved.fullName, remote.full_name); assert.equal(resolved.localPath, 'C:/explicit'); assert.equal(resolved.deploymentProfiles[0].id, 'prod'); assert.deepEqual(scanned, ['C:/explicit']); assert.equal(await instance.resolveByFullName(''), null); }); test('resolving an unknown repository still falls back to a full refresh', async () => { const store = { data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'https' }, favorites: [] }, getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null }; const instance = new RepositoryService(store, { status: async (localPath) => ({ ...status(), root: localPath, remoteUrl: '' }) }, { listRepositories: async () => [remote] }); instance.discoverAll = async () => ['C:/loose-checkout']; const local = await instance.resolveByFullName('loose-checkout'); assert.equal(local.linkState, 'unmatched-local'); assert.equal(await instance.resolveByFullName('Jens/Missing'), null); }); test('refresh remains local-only without configured Gitea credentials', async () => { const store = { data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] }, getToken: () => '', getDeploymentProfiles: () => [], getDeploymentState: () => null }; const instance = new RepositoryService(store, {}, { listRepositories: async () => { throw new Error('must not call'); } }); instance.discoverAll = async () => []; assert.deepEqual(await instance.refresh(), []); }); test('refresh uses last-known Gitea repositories after a transient remote failure', async () => { const warnings = []; let remoteAvailable = true; const store = { data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] }, getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null }; const instance = new RepositoryService(store, {}, { listRepositories: async () => { if (!remoteAvailable) throw new Error('Gitea timed out'); return [remote]; } }, { debug: async () => {}, warning: async (...args) => warnings.push(args) }); instance.discoverAll = async () => []; const fresh = await instance.refresh(); remoteAvailable = false; const degraded = await instance.refresh({ force: true }); assert.equal(fresh[0].remoteStale, false); assert.equal(degraded[0].fullName, remote.full_name); assert.equal(degraded[0].remoteStale, true); assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out'); assert.ok(degraded[0].remoteLastRefreshedAt); assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded'); }); test('initial Gitea failure remains visible when no safe cache exists', async () => { const store = { data: { gitea: { baseUrl: 'https://gitea.example' } }, getToken: () => 'token' }; const instance = new RepositoryService(store, {}, { listRepositories: async () => { throw new Error('Gitea unavailable'); } }); await assert.rejects(() => instance.refresh(), /Gitea unavailable/); }); test('refresh coalesces concurrent work and briefly reuses remote and discovery results', async () => { let remoteCalls = 0; let discoveryCalls = 0; const store = { data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] }, getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null }; const instance = new RepositoryService(store, {}, { listRepositories: async () => { remoteCalls += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return [remote]; } }); instance.discoverAll = async () => { discoveryCalls += 1; return []; }; const [first, second] = await Promise.all([instance.refresh(), instance.refresh()]); assert.deepEqual(first, second); await instance.refresh(); assert.equal(remoteCalls, 1); assert.equal(discoveryCalls, 1); await instance.refresh({ force: true }); assert.equal(remoteCalls, 2); assert.equal(discoveryCalls, 2); }); test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => { const instance = service(); const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []); assert.equal(conflicted.attentionReason, 'Merge conflict'); assert.equal(conflicted.readyToDeploy, false); const behind = instance.decorate(remote, { localPath: 'repo', status: { ...status(), branch: { ...status().branch, behind: 1 } } }, []); assert.equal(behind.attentionReason, '1 commit behind remote'); const broken = instance.decorate(remote, { localPath: 'repo', status: null, error: 'broken checkout' }, []); assert.equal(broken.attentionReason, 'broken checkout'); const remoteOnly = instance.decorate({ ...remote, ssh_url: '', clone_url: '' }, null, []); assert.equal(remoteOnly.linkState, 'remote-only'); assert.equal(remoteOnly.preferredCloneUrl, ''); });