File size: 3,782 Bytes
a5d1c1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61b1da1
a5d1c1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { projectConfigManager } from '../config/project-config.js';
import { cacheService, computeSha256 } from './cache-service.js';
import { CliApiClient } from '../client/api-client.js';

export interface SyncResult {
  updated: number;
  unchanged: number;
  removed: number;
  total: number;
  dirtySkipped?: boolean;
}

export function parseRepoUrl(repoUrl: string): { owner: string; repo: string } {
  const clean = repoUrl.trim();
  const ghMatch = clean.match(/(?:https?:\/\/)?(?:www\.)?github\.com\/([^\/]+)\/([^\/\?#]+)/i);
  if (ghMatch) {
    return {
      owner: ghMatch[1],
      repo: ghMatch[2].replace(/\.git$/i, ''),
    };
  }

  const parts = clean.split('/');
  if (parts.length === 2 && parts[0] && parts[1]) {
    return {
      owner: parts[0],
      repo: parts[1].replace(/\.git$/i, ''),
    };
  }

  throw new Error(`Invalid GitHub repository format '${repoUrl}'. Expected 'owner/repo' or 'https://github.com/owner/repo'`);
}

export class SyncService {
  async sync(
    workspaceRoot: string,
    options?: { force?: boolean; client?: CliApiClient }
  ): Promise<SyncResult> {
    const config = projectConfigManager.readConfig(workspaceRoot);
    const { owner, repo } = parseRepoUrl(config.source.repository);
    const targetBranch = config.source.branch || 'main';

    // 1. Dirty state collision guard
    const dirty = cacheService.computeDirtyState(workspaceRoot);
    if (dirty.isDirty && !options?.force) {
      const allDirty = [...dirty.modified, ...dirty.added, ...dirty.deleted];
      throw new Error(
        `You have uncommitted local modifications in:\n` +
          allDirty.map((f) => `  • ${f}`).join('\n') +
          `\n\nSync halted to prevent accidental overwrite. Use 'evb push' / 'evb propose' to publish, or 'evb sync --force' to overwrite.`
      );
    }

    const apiUrl = projectConfigManager.getApiUrl(workspaceRoot);
    const client = options?.client || new CliApiClient(apiUrl);

    // 2. Fetch remote tree
    const treeResponse = await client.getFiles(owner, repo, undefined, true, targetBranch);
    const remoteFiles = treeResponse.files;

    const oldState = projectConfigManager.readState(workspaceRoot);
    const oldBaseline = oldState?.files || {};

    let updated = 0;
    let unchanged = 0;
    const newFileHashes: Record<string, string> = {};
    const remotePathSet = new Set<string>();

    // 3. Sync files
    for (const remoteFile of remoteFiles) {
      remotePathSet.add(remoteFile.path);

      const fileContentRes = await client.getFileContent(
        owner,
        repo,
        remoteFile.path,
        targetBranch
      );

      const content = fileContentRes.content;
      const sha256 = computeSha256(content);
      newFileHashes[remoteFile.path] = sha256;

      const currentLocalContent = cacheService.readCachedFile(workspaceRoot, remoteFile.path);
      if (currentLocalContent === content) {
        unchanged++;
      } else {
        cacheService.writeCachedFile(workspaceRoot, remoteFile.path, content);
        updated++;
      }
    }

    // 4. Remove deleted remote files from cache
    let removed = 0;
    const localCachedFiles = cacheService.listCachedFiles(workspaceRoot);
    for (const localFile of localCachedFiles) {
      if (!remotePathSet.has(localFile)) {
        cacheService.deleteCachedFile(workspaceRoot, localFile);
        removed++;
      }
    }

    // 5. Update state.yaml
    projectConfigManager.writeState(workspaceRoot, {
      last_sync: new Date().toISOString(),
      source_revision: targetBranch,
      cached_files: remoteFiles.length,
      files: newFileHashes,
    });

    return {
      updated,
      unchanged,
      removed,
      total: remoteFiles.length,
    };
  }
}

export const syncService = new SyncService();