Skip to content

Commit 678c78a

Browse files
committed
feat: add sync command to synchronize configurations from team repository
- Implemented SyncCommand class for synchronizing configurations. - Added SyncEngine class to handle the core synchronization logic. - Introduced validation and error handling for sync operations. - Created integration tests for init and sync commands, covering various scenarios including tag filtering and error handling. - Developed unit tests for GitManager to ensure proper git operations and error handling.
1 parent cdf009f commit 678c78a

File tree

8 files changed

+1640
-6
lines changed

8 files changed

+1640
-6
lines changed

src/cli/commands/init.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { BaseCommand, type CommonOptions } from '../base-command.js';
2+
import { GitManager } from '../../core/git/git-manager.js';
3+
import { FileSystemManager } from '../../core/fs/fs-manager.js';
4+
import { LockfileManager } from '../../core/lockfile/lockfile-manager.js';
5+
import { SyncEngine } from '../../core/sync/sync-engine.js';
6+
import { DiscoveryEngine } from '../../core/discovery/discovery-engine.js';
7+
import {
8+
SyncAlreadyInitializedError,
9+
SyncNoConfigsFoundError,
10+
GitError,
11+
} from '../../utils/errors.js';
12+
import { getLockfilePath, getTempDir } from '../../utils/path-utils.js';
13+
import type { Lockfile, ConfigScope, ConfigEntry } from '../../types/index.js';
14+
15+
/**
16+
* Options for init command
17+
*/
18+
export interface InitCommandOptions extends CommonOptions {
19+
/** Repository URL */
20+
repository: string;
21+
/** Git ref (branch/tag/commit) */
22+
ref?: string;
23+
/** Scope of installation */
24+
scope?: ConfigScope;
25+
/** Initial tag filter */
26+
tags?: string[];
27+
/** Initial exclude tag filter */
28+
excludeTags?: string[];
29+
/** Force re-initialization */
30+
force?: boolean;
31+
}
32+
33+
/**
34+
* Init command: Initialize team configurations from a repository
35+
*/
36+
export class InitCommand extends BaseCommand<InitCommandOptions> {
37+
private gitManager: GitManager;
38+
private fsManager: FileSystemManager;
39+
private lockfileManager: LockfileManager;
40+
private syncEngine: SyncEngine;
41+
private discoveryEngine: DiscoveryEngine;
42+
43+
constructor(options: InitCommandOptions) {
44+
super(options);
45+
this.gitManager = new GitManager();
46+
this.fsManager = new FileSystemManager();
47+
this.lockfileManager = new LockfileManager();
48+
this.syncEngine = new SyncEngine();
49+
this.discoveryEngine = new DiscoveryEngine();
50+
}
51+
52+
/**
53+
* Validate prerequisites
54+
*/
55+
protected async validate(): Promise<void> {
56+
// Check if already initialized
57+
const scope = this.resolveScope();
58+
const lockfilePath = getLockfilePath(scope);
59+
60+
if ((await this.fsManager.fileExists(lockfilePath)) && !this.options.force) {
61+
const lockfile = await this.lockfileManager.read(lockfilePath);
62+
throw new SyncAlreadyInitializedError(lockfile.repository);
63+
}
64+
}
65+
66+
/**
67+
* Execute the init command
68+
*/
69+
async execute(): Promise<void> {
70+
const { repository, ref = 'main', tags, excludeTags } = this.options;
71+
const scope = this.resolveScope();
72+
73+
this.logger.info(`Initializing team configurations from: ${repository}`);
74+
75+
let tempDir: string | null = null;
76+
77+
try {
78+
// Check if already initialized (unless force flag is set)
79+
const lockfilePath = getLockfilePath(scope);
80+
if ((await this.fsManager.fileExists(lockfilePath)) && !this.options.force) {
81+
const lockfile = await this.lockfileManager.read(lockfilePath);
82+
throw new SyncAlreadyInitializedError(lockfile.repository);
83+
}
84+
85+
// 1. Clone repository to temporary location
86+
tempDir = getTempDir();
87+
this.logger.info(`Cloning repository to temporary location...`);
88+
await this.gitManager.clone(repository, tempDir);
89+
90+
// 2. Checkout specified ref
91+
if (ref) {
92+
this.logger.info(`Checking out ref: ${ref}`);
93+
await this.gitManager.checkout(tempDir, ref);
94+
}
95+
96+
// 3. Get current commit SHA
97+
const commit = await this.gitManager.getCurrentCommit(tempDir);
98+
this.logger.debug(`Current commit: ${commit}`);
99+
100+
// 4. Discover configurations
101+
this.logger.info('Discovering configurations...');
102+
const discoveryResult = await this.discoveryEngine.discover({
103+
baseDir: tempDir,
104+
computeHash: true,
105+
validate: true,
106+
});
107+
108+
if (discoveryResult.configs.length === 0) {
109+
throw new SyncNoConfigsFoundError(repository);
110+
}
111+
112+
this.logger.info(`Found ${discoveryResult.configs.length} configurations`);
113+
114+
// 5. Create initial lockfile with empty configs (will be populated after sync)
115+
const lockfile: Lockfile = {
116+
version: '1.0.0',
117+
repository,
118+
ref,
119+
commit,
120+
scope,
121+
lastSync: new Date().toISOString(),
122+
tags,
123+
excludeTags,
124+
configs: {}, // Empty initially - will be populated after sync
125+
};
126+
127+
// Reuse lockfilePath from earlier
128+
await this.lockfileManager.write(lockfilePath, lockfile);
129+
this.logger.debug('Initial lockfile created');
130+
131+
// 6. Perform initial sync
132+
this.logger.info('Syncing configurations...');
133+
const syncResult = await this.syncEngine.sync({
134+
sourceDir: tempDir,
135+
lockfile,
136+
options: {
137+
tags,
138+
excludeTags,
139+
dryRun: false,
140+
noValidate: false,
141+
},
142+
});
143+
144+
// 7. Update lockfile with synced configurations
145+
lockfile.configs = this.configsToRecord(syncResult.added);
146+
lockfile.lastSync = new Date().toISOString();
147+
await this.lockfileManager.write(lockfilePath, lockfile);
148+
this.logger.debug('Lockfile updated with synced configs');
149+
150+
// 8. Display results
151+
this.displayResults(
152+
syncResult.added.length,
153+
syncResult.errors.length,
154+
syncResult.conflicts.length
155+
);
156+
157+
// 9. Cleanup
158+
this.logger.debug('Cleaning up temporary files...');
159+
if (tempDir) {
160+
await this.fsManager.removeDir(tempDir);
161+
}
162+
163+
this.logger.info('Initialization completed successfully!');
164+
this.logger.info(`Run 'oct status' to see the current state`);
165+
} catch (error) {
166+
// Cleanup on error
167+
if (tempDir) {
168+
try {
169+
await this.fsManager.removeDir(tempDir);
170+
} catch {
171+
// Ignore cleanup errors
172+
}
173+
}
174+
175+
if (error instanceof GitError) {
176+
this.logger.error(`Git operation failed: ${error.message}`);
177+
this.logger.error('Make sure the repository URL is correct and you have access');
178+
} else if (error instanceof Error) {
179+
this.logger.error(`Initialization failed: ${error.message}`);
180+
}
181+
182+
throw error;
183+
}
184+
}
185+
186+
/**
187+
* Convert configs array to record for lockfile
188+
*/
189+
private configsToRecord(configs: ConfigEntry[]): Record<string, ConfigEntry> {
190+
const record: Record<string, ConfigEntry> = {};
191+
for (const config of configs) {
192+
record[config.name] = config;
193+
}
194+
return record;
195+
}
196+
197+
/**
198+
* Display sync results
199+
*/
200+
private displayResults(added: number, errors: number, conflicts: number): void {
201+
this.logger.info('');
202+
this.logger.info('Sync Results:');
203+
this.logger.info(` Added: ${added} configurations`);
204+
205+
if (conflicts > 0) {
206+
this.logger.warn(` Conflicts: ${conflicts} (personal configs take precedence)`);
207+
}
208+
209+
if (errors > 0) {
210+
this.logger.error(` Errors: ${errors} configurations failed`);
211+
}
212+
213+
this.logger.info('');
214+
}
215+
}

0 commit comments

Comments
 (0)