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
1 change: 0 additions & 1 deletion opencode.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-20250514",
"instructions": [
"AGENTS.local.md"
],
Expand Down
3 changes: 2 additions & 1 deletion src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export const EXIT_CODES = {
INVALID_ARGUMENTS: 2,
GIT_REPO_NOT_FOUND: 3,
NETWORK_ERROR: 4,
FILESYSTEM_ERROR: 5
FILESYSTEM_ERROR: 5,
GIT_ERROR: 6
} as const;

export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];
Expand Down
119 changes: 115 additions & 4 deletions src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { EXIT_CODES } from './cli/types.ts';
import type { RepositoryInfo } from './repository.ts';
import type { ServiceContainer } from './services/types.ts';
import { createServiceContainer } from './services/container.ts';
import { WorktreeOperations } from './worktree.ts';
import { loadConfig } from './config.ts';

export class RepositoryInitError extends Error {
constructor(message: string, public readonly code: number = EXIT_CODES.GENERAL_ERROR) {
Expand Down Expand Up @@ -120,7 +122,7 @@ export class InitOperations {

// Validate targetName characters
if (trimmedTarget.includes('/') || trimmedTarget.includes('\\') || trimmedTarget.includes('@')) {
throw new RepositoryInitError('Invalid repository name: contains invalid characters', EXIT_CODES.INVALID_ARGUMENTS);
throw new RepositoryInitError('Repository name: contains invalid characters', EXIT_CODES.INVALID_ARGUMENTS);
}

repoName = trimmedTarget;
Expand Down Expand Up @@ -157,15 +159,21 @@ export class InitOperations {
this.services.logger.log('Fetching all remote branches...');
await this.fetchAllRemoteBranches(bareDir);

this.services.logger.log(`Repository initialized successfully in ${targetDir}`);

return {
const repoInfo: RepositoryInfo = {
rootDir: targetDir,
gitDir: bareDir,
type: 'bare',
bareDir: bareDir
};

// Detect and create default branch worktree
this.services.logger.log('Creating default branch worktree...');
await this.createDefaultBranchWorktree(repoInfo);

this.services.logger.log(`Repository initialized successfully in ${targetDir}`);

return repoInfo;

} catch (error) {
if (error instanceof GitError) {
// Check if it's a network-related error
Expand Down Expand Up @@ -228,6 +236,109 @@ export class InitOperations {
throw new GitError(`Failed to fetch branches: ${error instanceof Error ? error.message : 'Unknown error'}`, '', -1);
}
}

/**
* Detects the default branch from the bare repository's HEAD file
*/
private async detectDefaultBranch(bareDir: string): Promise<string> {
const headFilePath = join(bareDir, 'HEAD');

try {
const headContent = await this.services.fs.readFile(headFilePath, 'utf8');
const headLine = headContent.trim();

// HEAD file should contain something like "ref: refs/heads/main"
const match = headLine.match(/^ref:\s*refs\/heads\/(.+)$/);
if (match && match[1]) {
return match[1];
}

// Fallback: try to detect from remote HEAD
const result = await this.services.git.executeCommandWithResult(bareDir, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
if (result.exitCode === 0) {
const remoteHeadMatch = result.stdout.trim().match(/refs\/remotes\/origin\/(.+)$/);
if (remoteHeadMatch && remoteHeadMatch[1]) {
return remoteHeadMatch[1];
}
}

// Last resort fallback
throw new Error('Could not determine default branch');
} catch (error) {
// If we can't detect the default branch, try common defaults
const commonDefaults = ['main', 'master', 'develop', 'development'];

for (const branch of commonDefaults) {
try {
const result = await this.services.git.executeCommandWithResult(bareDir, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`]);
if (result.exitCode === 0) {
this.services.logger.warn(`Could not detect default branch from HEAD file, using found branch: ${branch}`);
return branch;
}
} catch {
// Continue trying other branches
}
}

throw new RepositoryInitError(
`Could not determine default branch: ${error instanceof Error ? error.message : 'Unknown error'}. ` +
'Make sure the repository has a valid default branch.',
EXIT_CODES.GIT_ERROR
);
}
}

/**
* Creates a worktree for the default branch and sets up upstream tracking
*/
private async createDefaultBranchWorktree(repoInfo: RepositoryInfo): Promise<void> {
try {
// Detect the default branch
const defaultBranch = await this.detectDefaultBranch(repoInfo.gitDir);
this.services.logger.log(`Detected default branch: ${defaultBranch}`);

// Load config to get worktree directory setting
const config = await loadConfig(repoInfo);

// Create worktree for the default branch
const worktreeOps = new WorktreeOperations(this.services);

// Change to the repository directory temporarily to ensure relative paths work correctly
const originalCwd = process.cwd();
this.services.fs.chdir(repoInfo.rootDir);

try {
// Resolve branch and create worktree
const resolution = await worktreeOps.resolveBranch(repoInfo, defaultBranch, config);
const worktreePath = join(repoInfo.rootDir, config.worktreeDir, defaultBranch);

await worktreeOps.createWorktree(repoInfo, resolution, worktreePath);

// Set up upstream tracking for the created branch
await this.setupUpstreamTracking(repoInfo, defaultBranch, worktreePath);

this.services.logger.log(`Created worktree for default branch '${defaultBranch}' with upstream tracking`);
} finally {
// Restore original working directory
this.services.fs.chdir(originalCwd);
}
} catch (error) {
// Don't fail the entire init process if worktree creation fails
this.services.logger.warn(`Warning: Failed to create default branch worktree: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}

/**
* Sets up upstream tracking for a worktree branch
*/
private async setupUpstreamTracking(repoInfo: RepositoryInfo, branchName: string, worktreePath: string): Promise<void> {
try {
// Set the upstream branch to track origin/branchName
await this.services.git.executeCommandInDir(worktreePath, ['branch', '--set-upstream-to', `origin/${branchName}`]);
} catch (error) {
this.services.logger.warn(`Warning: Failed to set upstream tracking for branch '${branchName}': ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/services/implementations/NodeFileSystemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,8 @@ export class NodeFileSystemService implements FileSystemService {
return false;
}
}

chdir(path: string): void {
process.chdir(path);
}
}
19 changes: 18 additions & 1 deletion src/services/test-implementations/MockFileSystemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export class MockFileSystemService implements FileSystemService {
private directories = new Set<string>();
private accessiblePaths = new Set<string>();
private mockStats = new Map<string, MockStats>();
private currentDirectory = '/';
private fileWrites: Array<{path: string, data: string, encoding?: BufferEncoding}> = [];
private chdirCalls: string[] = [];

// Configuration methods for tests
setFileContent(path: string, content: string): void {
Expand Down Expand Up @@ -47,13 +50,22 @@ export class MockFileSystemService implements FileSystemService {
this.directories.clear();
this.accessiblePaths.clear();
this.mockStats.clear();
this.currentDirectory = '/';
this.fileWrites = [];
this.chdirCalls = [];
}

getFileWrites(): Array<{path: string, data: string, encoding?: BufferEncoding}> {
return [...this.fileWrites];
}

private fileWrites: Array<{path: string, data: string, encoding?: BufferEncoding}> = [];
getChdirCalls(): string[] {
return [...this.chdirCalls];
}

getCurrentDirectory(): string {
return this.currentDirectory;
}

// FileSystemService implementation
async readFile(path: string, _encoding: BufferEncoding = 'utf-8'): Promise<string> {
Expand Down Expand Up @@ -121,4 +133,9 @@ export class MockFileSystemService implements FileSystemService {
return false;
}
}

chdir(path: string): void {
this.chdirCalls.push(path);
this.currentDirectory = path;
}
}
4 changes: 4 additions & 0 deletions src/services/test-implementations/MockLoggerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ export class MockLoggerService implements LoggerService {
hasLog(level: string, message: string): boolean {
return this.logs.some(log => log.level === level && log.message === message);
}

hasLogContaining(level: string, messageSubstring: string): boolean {
return this.logs.some(log => log.level === level && log.message.includes(messageSubstring));
}
}
1 change: 1 addition & 0 deletions src/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface FileSystemService {
exists(path: string): Promise<boolean>;
isDirectory(path: string): Promise<boolean>;
isFile(path: string): Promise<boolean>;
chdir(path: string): void;
}

export interface CommandService {
Expand Down
120 changes: 120 additions & 0 deletions tests/integration/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,126 @@ test('integration: initialized repository should be detected correctly', async (
expect(stdout).toContain('.bare');
});

test('integration: init command should create default branch worktree automatically', async () => {
const targetName = 'default-branch-test';

// Initialize repository
const { stdout, exitCode } = await runWt(`init ${testRepoUrl} ${targetName}`, tempDir);

expect(exitCode).toBe(0);
expect(stdout).toContain('Creating default branch worktree');
expect(stdout).toContain('Detected default branch: main');
expect(stdout).toContain('Created worktree for default branch');

// Verify worktree was created
const targetPath = path.join(tempDir, targetName);
const { stdout: listOutput, exitCode: listExitCode } = await runWt('list', targetPath);

expect(listExitCode).toBe(0);
expect(listOutput).toContain('main');

// Verify the worktree directory exists
const mainWorktreePath = path.join(targetPath, 'main');
try {
await access(mainWorktreePath, constants.F_OK);
const worktreStat = await stat(mainWorktreePath);
expect(worktreStat.isDirectory()).toBe(true);
} catch {
throw new Error(`Expected main worktree directory to exist at ${mainWorktreePath}`);
}

// Verify the README.md file is present in the worktree
const readmePath = path.join(mainWorktreePath, 'README.md');
try {
await access(readmePath, constants.F_OK);
const readmeContent = await readFile(readmePath, 'utf-8');
expect(readmeContent).toContain('test-repo');
} catch {
throw new Error(`Expected README.md to exist in main worktree at ${readmePath}`);
}
});

test('integration: default branch worktree should have upstream tracking', async () => {
const targetName = 'upstream-test';

// Initialize repository
const { exitCode } = await runWt(`init ${testRepoUrl} ${targetName}`, tempDir);
expect(exitCode).toBe(0);

// Check upstream tracking is set up
const mainWorktreePath = path.join(tempDir, targetName, 'main');

// Verify upstream is configured
const { stdout: upstreamOutput } = await execAsync('git branch -vv', { cwd: mainWorktreePath });
expect(upstreamOutput).toContain('origin/main');
});

test('integration: init should handle repositories with different default branches', async () => {
// Create a test repository with 'master' as default branch
const masterRepoPath = path.join(tempDir, 'master-repo.git');
await execAsync(`git init --bare "${masterRepoPath}"`);

// Set the default branch to master in the bare repo
await execAsync(`git symbolic-ref HEAD refs/heads/master`, { cwd: masterRepoPath });

// Create working directory and set up with master branch
const workingDir = path.join(tempDir, 'master-working');
await execAsync(`git clone "${masterRepoPath}" "${workingDir}"`);

// Configure git user
await execAsync('git config user.name "Test User"', { cwd: workingDir });
await execAsync('git config user.email "test@example.com"', { cwd: workingDir });

// Create initial commit on master branch
await writeFile(path.join(workingDir, 'README.md'), '# Master repo\n');
await execAsync('git add README.md', { cwd: workingDir });
await execAsync('git commit -m "Initial commit"', { cwd: workingDir });
await execAsync('git push origin master', { cwd: workingDir });

// Clean up working directory
await rm(workingDir, { recursive: true, force: true });

const masterRepoUrl = `file://${masterRepoPath}`;
const targetName = 'master-branch-test';

// Initialize repository with master default branch
const { stdout, exitCode } = await runWt(`init ${masterRepoUrl} ${targetName}`, tempDir);

expect(exitCode).toBe(0);
expect(stdout).toContain('Detected default branch: master');
expect(stdout).toContain('Created worktree for default branch \'master\'');

// Verify master worktree was created
const targetPath = path.join(tempDir, targetName);
const { stdout: listOutput } = await runWt('list', targetPath);
expect(listOutput).toContain('master');

const masterWorktreePath = path.join(targetPath, 'master');
await access(masterWorktreePath, constants.F_OK);
});

test('integration: init should warn but continue if default branch worktree creation fails', async () => {
// Create a repository with no commits (which might cause worktree creation to fail)
const emptyRepoPath = path.join(tempDir, 'empty-repo.git');
await execAsync(`git init --bare "${emptyRepoPath}"`);

const emptyRepoUrl = `file://${emptyRepoPath}`;
const targetName = 'empty-repo-test';

const { stdout, stderr, exitCode } = await runWt(`init ${emptyRepoUrl} ${targetName}`, tempDir);

// Should still succeed overall even if worktree creation fails
expect(exitCode).toBe(0);
expect(stdout).toContain('Repository initialized successfully');

// Should show warning about worktree creation failure
if (stderr.includes('Warning: Failed to create default branch worktree') ||
stdout.includes('Warning: Failed to create default branch worktree')) {
// This is expected for empty repositories
expect(true).toBe(true);
}
});

test('integration: init command should handle existing directory name conflicts', async () => {
const targetName = 'conflict-test';

Expand Down
Loading
Loading