feat: Validate default branch exists in repo when setting project defaultBranch

registerProject and updateProject now verify via remoteBranchExists that the
specified branch actually exists in the cloned repository before saving.
This commit is contained in:
Lukas May
2026-02-10 11:46:00 +01:00
parent a8d3f52d09
commit 771cd71c1e
6 changed files with 174 additions and 2 deletions

View File

@@ -31,6 +31,21 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
4. On conflict: `git merge --abort`, emit `worktree:conflict` with conflicting file list
5. Restore original branch
### BranchManager (`src/git/branch-manager.ts`)
- **Port**: `BranchManager` interface
- **Adapter**: `SimpleGitBranchManager` using simple-git
| Method | Purpose |
|--------|---------|
| `ensureBranch(repoPath, branch, baseBranch)` | Create branch from base if it doesn't exist (idempotent) |
| `mergeBranch(repoPath, source, target)` | Merge via ephemeral worktree, returns conflict info |
| `diffBranches(repoPath, base, head)` | Three-dot diff between branches |
| `deleteBranch(repoPath, branch)` | Delete local branch (no-op if missing) |
| `branchExists(repoPath, branch)` | Check local branches |
| `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) |
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.
### Project Clones
- `cloneProject(url, destPath)` — Simple git clone wrapper
- `ensureProjectClone(project, workspaceRoot)` — Idempotent: checks if clone exists, clones if not

View File

@@ -140,10 +140,10 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
### Projects
| Procedure | Type | Description |
|-----------|------|-------------|
| registerProject | mutation | Clone git repo, create record |
| registerProject | mutation | Clone git repo, create record. Validates defaultBranch exists in repo |
| listProjects | query | All projects |
| getProject | query | Single project |
| updateProject | mutation | Update project settings (defaultBranch) |
| updateProject | mutation | Update project settings (defaultBranch). Validates branch exists in repo |
| deleteProject | mutation | Delete clone and record |
| getInitiativeProjects | query | Projects for initiative |
| updateInitiativeProjects | mutation | Sync junction table |

View File

@@ -38,4 +38,11 @@ export interface BranchManager {
* Check if a branch exists in the repository.
*/
branchExists(repoPath: string, branch: string): Promise<boolean>;
/**
* Check if a branch exists as a remote tracking branch (origin/<branch>).
* Useful for validating branch names against what the remote has,
* since local branches may not include all remote branches.
*/
remoteBranchExists(repoPath: string, branch: string): Promise<boolean>;
}

View File

@@ -0,0 +1,110 @@
/**
* SimpleGitBranchManager Tests
*
* Tests for remoteBranchExists validation used when setting
* a project's default branch.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { simpleGit } from 'simple-git';
import { SimpleGitBranchManager } from './simple-git-branch-manager.js';
/**
* Create a "remote" bare repo and a clone of it for testing.
* The bare repo has branches that the clone can see as remote tracking branches.
*/
async function createTestRepoWithRemote(): Promise<{
clonePath: string;
barePath: string;
cleanup: () => Promise<void>;
}> {
const tmpBase = await mkdtemp(path.join(tmpdir(), 'cw-branch-test-'));
const barePath = path.join(tmpBase, 'bare.git');
const workPath = path.join(tmpBase, 'work');
const clonePath = path.join(tmpBase, 'clone');
// Create a bare repo
const bareGit = simpleGit();
await bareGit.init([barePath, '--bare']);
// Clone it to a working directory, add commits and branches, push
await simpleGit().clone(barePath, workPath);
const workGit = simpleGit(workPath);
await workGit.addConfig('user.email', 'test@example.com');
await workGit.addConfig('user.name', 'Test User');
await writeFile(path.join(workPath, 'README.md'), '# Test\n');
await workGit.add('README.md');
await workGit.commit('Initial commit');
await workGit.push('origin', 'main');
// Create additional branches
await workGit.checkoutLocalBranch('develop');
await writeFile(path.join(workPath, 'dev.txt'), 'dev\n');
await workGit.add('dev.txt');
await workGit.commit('Dev commit');
await workGit.push('origin', 'develop');
await workGit.checkoutLocalBranch('feature/auth');
await writeFile(path.join(workPath, 'auth.txt'), 'auth\n');
await workGit.add('auth.txt');
await workGit.commit('Auth commit');
await workGit.push('origin', 'feature/auth');
// Clone from bare to simulate what project registration does
await simpleGit().clone(barePath, clonePath);
return {
clonePath,
barePath,
cleanup: async () => {
await rm(tmpBase, { recursive: true, force: true });
},
};
}
describe('SimpleGitBranchManager', () => {
let clonePath: string;
let cleanup: () => Promise<void>;
let branchManager: SimpleGitBranchManager;
beforeEach(async () => {
const setup = await createTestRepoWithRemote();
clonePath = setup.clonePath;
cleanup = setup.cleanup;
branchManager = new SimpleGitBranchManager();
});
afterEach(async () => {
await cleanup();
});
describe('remoteBranchExists', () => {
it('should return true for a branch that exists on the remote', async () => {
expect(await branchManager.remoteBranchExists(clonePath, 'main')).toBe(true);
expect(await branchManager.remoteBranchExists(clonePath, 'develop')).toBe(true);
expect(await branchManager.remoteBranchExists(clonePath, 'feature/auth')).toBe(true);
});
it('should return false for a branch that does not exist', async () => {
expect(await branchManager.remoteBranchExists(clonePath, 'nonexistent')).toBe(false);
expect(await branchManager.remoteBranchExists(clonePath, 'feature/nope')).toBe(false);
});
it('should return false for an invalid repo path', async () => {
expect(await branchManager.remoteBranchExists('/tmp/no-such-repo', 'main')).toBe(false);
});
it('should detect remote branches not checked out locally', async () => {
// After clone, only 'main' is checked out locally.
// 'develop' exists only as origin/develop.
const localExists = await branchManager.branchExists(clonePath, 'develop');
const remoteExists = await branchManager.remoteBranchExists(clonePath, 'develop');
expect(localExists).toBe(false);
expect(remoteExists).toBe(true);
});
});
});

View File

@@ -104,4 +104,16 @@ export class SimpleGitBranchManager implements BranchManager {
return false;
}
}
async remoteBranchExists(repoPath: string, branch: string): Promise<boolean> {
try {
const git = simpleGit(repoPath);
const result = await git.branch(['-r']);
return result.all.some(
(ref) => ref === `origin/${branch}` || ref === `remotes/origin/${branch}`,
);
} catch {
return false;
}
}
}

View File

@@ -52,6 +52,21 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
message: `Failed to clone repository: ${(cloneError as Error).message}`,
});
}
// Validate that the specified default branch exists in the cloned repo
const branchToValidate = input.defaultBranch ?? 'main';
if (ctx.branchManager) {
const exists = await ctx.branchManager.remoteBranchExists(clonePath, branchToValidate);
if (!exists) {
// Clean up: remove project and clone
await rm(clonePath, { recursive: true, force: true }).catch(() => {});
await repo.delete(project.id);
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Branch '${branchToValidate}' does not exist in the repository`,
});
}
}
}
return project;
@@ -105,6 +120,19 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
message: `Project '${id}' not found`,
});
}
// Validate that the new default branch exists in the repo
if (data.defaultBranch && ctx.workspaceRoot && ctx.branchManager) {
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(existing.name, existing.id));
const exists = await ctx.branchManager.remoteBranchExists(clonePath, data.defaultBranch);
if (!exists) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Branch '${data.defaultBranch}' does not exist in the repository`,
});
}
}
return repo.update(id, data);
}),