diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index 176963b40e..b189e077a0 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS remote_agent_codebases ( name VARCHAR(255) NOT NULL, repository_url VARCHAR(500), default_cwd VARCHAR(500) NOT NULL, + default_branch TEXT DEFAULT 'main', ai_assistant_type VARCHAR(20) DEFAULT 'claude', allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE, commands JSONB DEFAULT '{}'::jsonb, diff --git a/migrations/022_add_default_branch_to_codebases.sql b/migrations/022_add_default_branch_to_codebases.sql new file mode 100644 index 0000000000..5d1281898a --- /dev/null +++ b/migrations/022_add_default_branch_to_codebases.sql @@ -0,0 +1,8 @@ +-- Add default_branch column to remote_agent_codebases. +-- NULL means "not yet detected"; syncWorkspace falls back to auto-detection +-- (pre-existing behaviour). New clones set this via the branch-detect path in +-- clone.ts. Using no DEFAULT so existing rows stay NULL rather than being +-- silently set to 'main' (which could trigger an unwanted hard-reset for +-- managed clones on a non-main branch). +ALTER TABLE remote_agent_codebases + ADD COLUMN IF NOT EXISTS default_branch TEXT; diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 485706d040..09e7b17b9a 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -215,6 +215,20 @@ export class SqliteAdapter implements IDatabase { } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_session_columns_failed'); } + + // Codebases columns + try { + const codebaseCols = this.db.prepare("PRAGMA table_info('remote_agent_codebases')").all() as { + name: string; + }[]; + const codebaseColNames = new Set(codebaseCols.map(c => c.name)); + + if (!codebaseColNames.has('default_branch')) { + this.db.run('ALTER TABLE remote_agent_codebases ADD COLUMN default_branch TEXT'); + } + } catch (e: unknown) { + getLog().warn({ err: e as Error }, 'db.sqlite_migration_codebases_columns_failed'); + } } /** diff --git a/packages/core/src/db/codebases.test.ts b/packages/core/src/db/codebases.test.ts index 26c269a085..be062e8091 100644 --- a/packages/core/src/db/codebases.test.ts +++ b/packages/core/src/db/codebases.test.ts @@ -35,6 +35,7 @@ describe('codebases', () => { name: 'test-project', repository_url: 'https://github.com/user/repo', default_cwd: '/workspace/test-project', + default_branch: 'main', ai_assistant_type: 'claude', commands: { plan: { path: '.claude/commands/plan.md', description: 'Plan feature' } }, created_at: new Date(), @@ -54,8 +55,8 @@ describe('codebases', () => { expect(result).toEqual(mockCodebase); expect(mockQuery).toHaveBeenCalledWith( - 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *', - ['test-project', 'https://github.com/user/repo', '/workspace/test-project', 'claude'] + 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, default_branch, ai_assistant_type) VALUES ($1, $2, $3, $4, $5) RETURNING *', + ['test-project', 'https://github.com/user/repo', '/workspace/test-project', null, 'claude'] ); }); @@ -73,8 +74,25 @@ describe('codebases', () => { expect(result).toEqual(codebaseWithoutOptional); expect(mockQuery).toHaveBeenCalledWith( - 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *', - ['test-project', null, '/workspace/test-project', 'claude'] + 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, default_branch, ai_assistant_type) VALUES ($1, $2, $3, $4, $5) RETURNING *', + ['test-project', null, '/workspace/test-project', null, 'claude'] + ); + }); + + test('creates codebase with explicit default_branch', async () => { + mockQuery.mockResolvedValueOnce( + createQueryResult([{ ...mockCodebase, default_branch: 'develop' }]) + ); + + await createCodebase({ + name: 'test-project', + default_cwd: '/workspace/test-project', + default_branch: 'develop', + }); + + expect(mockQuery).toHaveBeenCalledWith( + 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, default_branch, ai_assistant_type) VALUES ($1, $2, $3, $4, $5) RETURNING *', + ['test-project', null, '/workspace/test-project', 'develop', 'claude'] ); }); @@ -296,6 +314,7 @@ describe('codebases', () => { id: 'cb-123', name: 'test-repo', default_cwd: '/workspace/test-repo', + default_branch: null, ai_assistant_type: 'claude', repository_url: null, commands: {}, diff --git a/packages/core/src/db/codebases.ts b/packages/core/src/db/codebases.ts index f3947fb6c1..5d3b1463e9 100644 --- a/packages/core/src/db/codebases.ts +++ b/packages/core/src/db/codebases.ts @@ -16,12 +16,19 @@ export async function createCodebase(data: { name: string; repository_url?: string; default_cwd: string; + default_branch?: string; ai_assistant_type?: string; }): Promise { const assistantType = data.ai_assistant_type ?? 'claude'; const result = await pool.query( - 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *', - [data.name, data.repository_url ?? null, data.default_cwd, assistantType] + 'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, default_branch, ai_assistant_type) VALUES ($1, $2, $3, $4, $5) RETURNING *', + [ + data.name, + data.repository_url ?? null, + data.default_cwd, + data.default_branch ?? null, + assistantType, + ] ); if (!result.rows[0]) { throw new Error('Failed to create codebase: INSERT succeeded but no row returned'); diff --git a/packages/core/src/handlers/clone.test.ts b/packages/core/src/handlers/clone.test.ts index c913c1a78c..a62cebe012 100644 --- a/packages/core/src/handlers/clone.test.ts +++ b/packages/core/src/handlers/clone.test.ts @@ -131,6 +131,7 @@ function makeCodebase( name: string; repository_url: string | null; default_cwd: string; + default_branch: string | null; ai_assistant_type: string; }> = {} ): object { @@ -139,6 +140,7 @@ function makeCodebase( name: 'owner/repo', repository_url: 'https://github.com/owner/repo', default_cwd: '/home/test/.archon/workspaces/owner/repo/source', + default_branch: null, ai_assistant_type: 'claude', commands: {}, created_at: new Date(), @@ -301,6 +303,70 @@ describe('cloneRepository', () => { }); }); + // ── Branch detection after clone ────────────────────────────────────── + describe('branch detection after clone', () => { + test('stores detected branch in createCodebase call', async () => { + spyExecFileAsync.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === '-C' && args[2] === 'rev-parse') { + return Promise.resolve({ stdout: 'master\n', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType); + + await cloneRepository('https://github.com/owner/repo'); + + const createArg = mockCreateCodebase.mock.calls[0]?.[0] as { default_branch?: string }; + expect(createArg.default_branch).toBe('master'); + }); + + test('stores undefined (null in DB) when HEAD is detached', async () => { + spyExecFileAsync.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === '-C' && args[2] === 'rev-parse') { + return Promise.resolve({ stdout: 'HEAD\n', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType); + + await cloneRepository('https://github.com/owner/repo'); + + const createArg = mockCreateCodebase.mock.calls[0]?.[0] as { default_branch?: string }; + expect(createArg.default_branch).toBeUndefined(); + }); + + test('does not throw and stores undefined when rev-parse fails', async () => { + spyExecFileAsync.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === '-C' && args[2] === 'rev-parse') { + return Promise.reject(new Error('not a git repo')); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType); + + const result = await cloneRepository('https://github.com/owner/repo'); + expect(result).toBeDefined(); + + const createArg = mockCreateCodebase.mock.calls[0]?.[0] as { default_branch?: string }; + expect(createArg.default_branch).toBeUndefined(); + }); + + test('stores undefined when rev-parse returns empty string', async () => { + spyExecFileAsync.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === '-C' && args[2] === 'rev-parse') { + return Promise.resolve({ stdout: '', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType); + + await cloneRepository('https://github.com/owner/repo'); + + const createArg = mockCreateCodebase.mock.calls[0]?.[0] as { default_branch?: string }; + expect(createArg.default_branch).toBeUndefined(); + }); + }); + // ── Already-cloned directory ─────────────────────────────────────────── describe('pre-existing clone', () => { beforeEach(() => { diff --git a/packages/core/src/handlers/clone.ts b/packages/core/src/handlers/clone.ts index 366a951b8a..da8c2eafca 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -40,7 +40,8 @@ export interface RegisterResult { async function registerRepoAtPath( targetPath: string, name: string, - repositoryUrl: string | null + repositoryUrl: string | null, + defaultBranch?: string ): Promise { // Auto-detect assistant type based on SDK folder conventions. // Built-in providers use well-known folders (.claude/, .codex/). @@ -125,6 +126,7 @@ async function registerRepoAtPath( name, repository_url: repositoryUrl ?? undefined, default_cwd: targetPath, + default_branch: defaultBranch, ai_assistant_type: suggestedAssistant, }); @@ -279,7 +281,31 @@ export async function cloneRepository(repoUrl: string): Promise await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]); getLog().debug({ path: targetPath }, 'safe_directory_added'); - const result = await registerRepoAtPath(targetPath, `${ownerName}/${repoName}`, workingUrl); + // Detect the actual branch checked out after clone (may differ from 'main' for repos with + // a non-default HEAD). Non-fatal: if detection fails, NULL is stored and syncWorkspace + // auto-detects the branch at runtime (pre-existing behavior). + let detectedBranch: string | undefined; + try { + const { stdout } = await execFileAsync( + 'git', + ['-C', targetPath, 'rev-parse', '--abbrev-ref', 'HEAD'], + { timeout: 5000 } + ); + const branch = stdout.trim(); + if (branch && branch !== 'HEAD') { + detectedBranch = branch; + } + } catch (err) { + // Non-fatal — store NULL so syncWorkspace falls back to auto-detection + getLog().debug({ path: targetPath, err }, 'clone.branch_detect_failed'); + } + + const result = await registerRepoAtPath( + targetPath, + `${ownerName}/${repoName}`, + workingUrl, + detectedBranch + ); getLog().info({ url: workingUrl, targetPath }, 'clone_completed'); return result; } diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index ab8165ca7e..f167fbe095 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -207,6 +207,7 @@ function makeCodebase(name: string, id = `id-${name}`): Codebase { name, repository_url: null, default_cwd: `/repos/${name}`, + default_branch: null, ai_assistant_type: 'claude', commands: {}, created_at: new Date(), @@ -830,6 +831,7 @@ function makeCodebaseForSync() { name: 'test-repo', repository_url: 'https://github.com/test/repo', default_cwd: '/repos/test-repo', + default_branch: null, ai_assistant_type: 'claude', commands: {}, created_at: new Date(), diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index d5eb9397b3..797f28dbcd 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -27,7 +27,7 @@ import { toError } from '../utils/error'; import { getAgentProvider, getProviderCapabilities } from '@archon/providers'; import { getArchonHome, getArchonWorkspacesPath } from '@archon/paths'; import { syncArchonToWorktree } from '../utils/worktree-sync'; -import { syncWorkspace, toRepoPath } from '@archon/git'; +import { syncWorkspace, toRepoPath, toBranchName } from '@archon/git'; import type { WorkspaceSyncResult } from '@archon/git'; import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; import { findWorkflow } from '@archon/workflows/router'; @@ -410,7 +410,10 @@ async function discoverAllWorkflows(conversation: Conversation): Promise): Codebase { return { id: 'cb-1', name: 'test-repo', + repository_url: null, default_cwd: '/workspace/test-repo', + default_branch: null, + ai_assistant_type: 'claude', commands: {}, created_at: new Date(), updated_at: new Date(), diff --git a/packages/core/src/orchestrator/orchestrator.test.ts b/packages/core/src/orchestrator/orchestrator.test.ts index de4618ed15..d01f571681 100644 --- a/packages/core/src/orchestrator/orchestrator.test.ts +++ b/packages/core/src/orchestrator/orchestrator.test.ts @@ -5,6 +5,7 @@ import { makeTestWorkflow, makeTestWorkflowList } from '@archon/workflows/test-u import type { Conversation, Codebase, Session } from '../types'; import { ConversationNotFoundError } from '../types'; import type { WorkflowDefinition } from '@archon/workflows/schemas/workflow'; +import type { BranchName } from '@archon/git'; // ─── Mock setup (BEFORE importing module under test) ───────────────────────── @@ -126,6 +127,25 @@ mock.module('../utils/worktree-sync', () => ({ syncArchonToWorktree: mockSyncArchonToWorktree, })); +// Git workspace sync mock +const mockSyncWorkspace = mock(() => + Promise.resolve({ + branch: 'main' as BranchName, + synced: true, + previousHead: 'abc12345', + newHead: 'abc12345', + updated: false, + }) +); +const mockToRepoPath = mock((p: string) => p); +const mockToBranchName = mock((b: string) => b); + +mock.module('@archon/git', () => ({ + syncWorkspace: mockSyncWorkspace, + toRepoPath: mockToRepoPath, + toBranchName: mockToBranchName, +})); + // Orchestrator (isolation & dispatch) mocks const mockValidateAndResolveIsolation = mock(() => Promise.resolve({ status: 'existing', cwd: '/workspace/project', env: null }) @@ -215,6 +235,7 @@ const mockCodebase: Codebase = { name: 'test-project', repository_url: 'https://github.com/user/repo', default_cwd: '/workspace/test-project', + default_branch: 'main', ai_assistant_type: 'claude', commands: {}, created_at: new Date(), @@ -278,6 +299,9 @@ function clearAllMocks(): void { mockExecuteWorkflow.mockClear(); mockFindWorkflow.mockClear(); mockSyncArchonToWorktree.mockClear(); + mockSyncWorkspace.mockClear(); + mockToRepoPath.mockClear(); + mockToBranchName.mockClear(); mockValidateAndResolveIsolation.mockClear(); mockDispatchBackgroundWorkflow.mockClear(); mockBuildOrchestratorPrompt.mockClear(); @@ -650,6 +674,58 @@ describe('orchestrator-agent handleMessage', () => { }); }); + // ─── syncWorkspace branch forwarding ────────────────────────────────── + + describe('syncWorkspace branch forwarding', () => { + test('passes configured default_branch to syncWorkspace when set', async () => { + const codbaseWithBranch: Codebase = { + ...mockCodebase, + default_branch: 'develop', + default_cwd: '/home/test/.archon/workspaces/owner/repo/source', + }; + mockGetOrCreateConversation.mockResolvedValue({ + ...mockConversationWithProject, + codebase_id: 'codebase-789', + }); + mockGetCodebase.mockResolvedValue(codbaseWithBranch); + mockClient.sendQuery.mockImplementation(async function* () { + yield { type: 'result', sessionId: 'session-id' }; + }); + + await handleMessage(platform, 'chat-456', 'help'); + + expect(mockSyncWorkspace).toHaveBeenCalledWith( + expect.any(String), + 'develop', + expect.any(Object) + ); + }); + + test('passes undefined branch to syncWorkspace when default_branch is null', async () => { + const codebaseNoBranch: Codebase = { + ...mockCodebase, + default_branch: null, + default_cwd: '/home/test/.archon/workspaces/owner/repo/source', + }; + mockGetOrCreateConversation.mockResolvedValue({ + ...mockConversationWithProject, + codebase_id: 'codebase-789', + }); + mockGetCodebase.mockResolvedValue(codebaseNoBranch); + mockClient.sendQuery.mockImplementation(async function* () { + yield { type: 'result', sessionId: 'session-id' }; + }); + + await handleMessage(platform, 'chat-456', 'help'); + + expect(mockSyncWorkspace).toHaveBeenCalledWith( + expect.any(String), + undefined, + expect.any(Object) + ); + }); + }); + // ─── Session Management ──────────────────────────────────────────────── describe('session management', () => { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 74966e3b2c..7f6db8a81f 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -58,6 +58,7 @@ export interface Codebase { name: string; repository_url: string | null; default_cwd: string; + default_branch: string | null; ai_assistant_type: string; commands: Record; created_at: Date; diff --git a/packages/server/src/routes/schemas/codebase.schemas.ts b/packages/server/src/routes/schemas/codebase.schemas.ts index d2880a6be1..cbac16c6c7 100644 --- a/packages/server/src/routes/schemas/codebase.schemas.ts +++ b/packages/server/src/routes/schemas/codebase.schemas.ts @@ -15,6 +15,7 @@ export const codebaseSchema = z name: z.string(), repository_url: z.string().nullable(), default_cwd: z.string(), + default_branch: z.string().nullable(), ai_assistant_type: z.string(), commands: z.record(codebaseCommandSchema), created_at: z.string(),