diff --git a/packages/desktop/src/features/queue/TaskQueue.ts b/packages/desktop/src/features/queue/TaskQueue.ts index 1ff43af..13a4884 100644 --- a/packages/desktop/src/features/queue/TaskQueue.ts +++ b/packages/desktop/src/features/queue/TaskQueue.ts @@ -17,6 +17,7 @@ import type { Session } from '@snowtree/core/types/session'; import type { ToolPanel } from '@snowtree/core/types/panels'; import type { Database as DatabaseService } from '../../infrastructure/database'; import type { Project } from '../../infrastructure/database'; +import { fetchAndCacheRepoInfo } from '../../infrastructure/ipc/git'; interface TaskQueueOptions { sessionManager: SessionManager; @@ -26,6 +27,7 @@ interface TaskQueueOptions { executionTracker: ExecutionTracker; worktreeNameGenerator: WorktreeNameGenerator; getMainWindow: () => Electron.BrowserWindow | null; + gitExecutor: import('../../executors/git').GitExecutor; } interface CreateSessionJob { @@ -312,7 +314,15 @@ export class TaskQueue { baseBranch: actualBaseBranch, statusMessage: undefined, }); - + + // Initialize git cache for the session + try { + await fetchAndCacheRepoInfo(session.id, worktreePath, sessionManager, this.options.gitExecutor); + } catch (error) { + console.warn(`[TaskQueue] Failed to init git cache for session ${session.id}:`, error); + // Non-fatal, continue with session creation + } + // Attach codexConfig to the session object for the panel creation in events.ts if (codexConfig) { (session as Session & { codexConfig?: typeof codexConfig }).codexConfig = codexConfig; diff --git a/packages/desktop/src/features/session/SessionManager.ts b/packages/desktop/src/features/session/SessionManager.ts index 4ea128c..3f13bf8 100644 --- a/packages/desktop/src/features/session/SessionManager.ts +++ b/packages/desktop/src/features/session/SessionManager.ts @@ -416,6 +416,10 @@ export class SessionManager extends EventEmitter { skipContinueNext: dbSession.skip_continue_next || undefined, claudeSessionId: dbSession.claude_session_id || undefined, executionMode: normalizedExecutionMode, + currentBranch: dbSession.current_branch || undefined, + ownerRepo: dbSession.owner_repo || undefined, + isFork: dbSession.is_fork || undefined, + originOwnerRepo: dbSession.origin_owner_repo || undefined, }; } diff --git a/packages/desktop/src/index.ts b/packages/desktop/src/index.ts index f217d4f..bcd58d7 100644 --- a/packages/desktop/src/index.ts +++ b/packages/desktop/src/index.ts @@ -643,7 +643,8 @@ async function initializeServices() { gitDiffManager, executionTracker, worktreeNameGenerator, - getMainWindow: () => mainWindow + getMainWindow: () => mainWindow, + gitExecutor }); const services: AppServices = { diff --git a/packages/desktop/src/infrastructure/ipc/__tests__/gitCacheHandlers.test.ts b/packages/desktop/src/infrastructure/ipc/__tests__/gitCacheHandlers.test.ts index 629edbb..614cd53 100644 --- a/packages/desktop/src/infrastructure/ipc/__tests__/gitCacheHandlers.test.ts +++ b/packages/desktop/src/infrastructure/ipc/__tests__/gitCacheHandlers.test.ts @@ -79,7 +79,7 @@ describe('Git IPC Handlers - Repo Info Cache', () => { }); it('should use cached branch and repo info', async () => { - // Set up cache hit + // Set up cache hit - need BOTH branch AND owner_repo mockSessionManager.db.getSession.mockReturnValue({ current_branch: 'feature-branch', owner_repo: 'owner/repo', @@ -88,19 +88,7 @@ describe('Git IPC Handlers - Repo Info Cache', () => { }); mockGitExecutor.run - // 1. git remote get-url upstream (will fail) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - // 2. git remote get-url origin - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - // 3. gh pr view + // 1. gh pr view (using cached data, no git commands needed) .mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ number: 123, url: 'https://github.com/owner/repo/pull/123', state: 'OPEN', isDraft: false }), @@ -114,8 +102,8 @@ describe('Git IPC Handlers - Repo Info Cache', () => { data: { number: 123, url: 'https://github.com/owner/repo/pull/123', state: 'open' }, }); - // Verify cache was used - should NOT call git branch or fetch repo info - expect(mockGitExecutor.run).toHaveBeenCalledTimes(3); + // Verify cache was used - should only call gh pr view, not git commands + expect(mockGitExecutor.run).toHaveBeenCalledTimes(1); // Verify updateSession was not called (cache hit) expect(mockSessionManager.db.updateSession).not.toHaveBeenCalled(); }); @@ -123,7 +111,7 @@ describe('Git IPC Handlers - Repo Info Cache', () => { it('should cache repo info on first call', async () => { // Cache miss - will call fetchAndCacheRepoInfo mockGitExecutor.run - // 1. git branch --show-current + // 1. git branch --show-current (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ exitCode: 0, stdout: 'feature-branch\n', @@ -141,19 +129,7 @@ describe('Git IPC Handlers - Repo Info Cache', () => { stdout: '', stderr: 'fatal: No such remote', } as MockRunResult) - // 4. git remote get-url upstream (from PR search loop) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - // 5. git remote get-url origin (from PR search loop) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - // 6. gh pr view + // 4. gh pr view (using newly cached data) .mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ number: 123, url: 'https://github.com/owner/repo/pull/123', state: 'OPEN', isDraft: false }), @@ -177,7 +153,7 @@ describe('Git IPC Handlers - Repo Info Cache', () => { }); it('should use cached data for fork workflow', async () => { - // Set up cache with fork info + // Set up cache with fork info - need BOTH branch AND owner_repo mockSessionManager.db.getSession.mockReturnValue({ current_branch: 'feature-branch', owner_repo: 'upstream-owner/repo', @@ -186,13 +162,7 @@ describe('Git IPC Handlers - Repo Info Cache', () => { }); mockGitExecutor.run - // 1. git remote get-url upstream (will succeed for fork) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:upstream-owner/repo.git\n', - stderr: '', - } as MockRunResult) - // 2. gh pr view (with fork-owner:branch format) + // 1. gh pr view (with fork-owner:branch format, using cached data) .mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ number: 456, url: 'https://github.com/upstream-owner/repo/pull/456', state: 'OPEN', isDraft: false }), @@ -207,7 +177,7 @@ describe('Git IPC Handlers - Repo Info Cache', () => { }); // Verify the gh command used the fork-owner:branch format - const ghCall = mockGitExecutor.run.mock.calls[1]; + const ghCall = mockGitExecutor.run.mock.calls[0]; expect(ghCall[0].argv).toContain('fork-owner:feature-branch'); }); }); diff --git a/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts b/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts index 2c35b4c..2a92e6f 100644 --- a/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts +++ b/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts @@ -97,7 +97,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { }); it('should parse SSH remote URL and fetch PR with --repo flag', async () => { - // Use cached data to avoid calling fetchAndCacheRepoInfo + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo mockSessionManager.db.getSession.mockReturnValue({ current_branch: 'feature-branch', owner_repo: 'BohuTANG/blog-hexo', @@ -106,19 +106,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { }); mockGitExecutor.run - // 1. git remote get-url upstream (first in PR search loop, will fail) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - // 2. git remote get-url origin (second in PR search loop) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:BohuTANG/blog-hexo.git\n', - stderr: '', - } as MockRunResult) - // 3. gh pr view + // 1. gh pr view .mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', state: 'OPEN', isDraft: false }), @@ -133,13 +121,14 @@ describe('Git IPC Handlers - Remote Pull Request', () => { }); // Verify gh pr view was called with --repo and branch - const ghPrViewCall = mockGitExecutor.run.mock.calls[2]; + const ghPrViewCall = mockGitExecutor.run.mock.calls[0]; expect(ghPrViewCall[0].argv).toContain('--repo'); expect(ghPrViewCall[0].argv).toContain('BohuTANG/blog-hexo'); expect(ghPrViewCall[0].argv).toContain('feature-branch'); }); it('should parse HTTPS remote URL and fetch PR with --repo flag', async () => { + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo mockSessionManager.db.getSession.mockReturnValue({ current_branch: 'main', owner_repo: 'owner/repo', @@ -148,16 +137,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { }); mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'https://github.com/owner/repo.git\n', - stderr: '', - } as MockRunResult) + // 1. gh pr view .mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'MERGED', isDraft: false }), @@ -171,7 +151,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { data: { number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'merged' }, }); - const ghPrViewCall = mockGitExecutor.run.mock.calls[2]; + const ghPrViewCall = mockGitExecutor.run.mock.calls[0]; expect(ghPrViewCall[0].argv).toContain('owner/repo'); }); @@ -206,13 +186,26 @@ describe('Git IPC Handlers - Remote Pull Request', () => { }); it('should return null when no remote is available', async () => { - // Use cache with no owner_repo (simulating no remotes) - mockSessionManager.db.getSession.mockReturnValue({ - current_branch: 'feature', - owner_repo: null, - is_fork: false, - origin_owner_repo: null, - }); + // Cache miss - need to provide fetchAndCacheRepoInfo mocks + mockGitExecutor.run + // 1. git branch --show-current (from fetchAndCacheRepoInfo) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'feature\n', + stderr: '', + } as MockRunResult) + // 2. git remote get-url origin (from fetchAndCacheRepoInfo, fails) + .mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'fatal: No such remote', + } as MockRunResult) + // 3. git remote get-url upstream (from fetchAndCacheRepoInfo, fails) + .mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'fatal: No such remote', + } as MockRunResult); const result = await mockIpcMain.invoke('sessions:get-remote-pull-request', sessionId); @@ -263,31 +256,25 @@ describe('Git IPC Handlers - Remote Pull Request', () => { it('should detect merged PR state', async () => { mockGitExecutor.run - // 1. git branch --show-current + // 1. git branch --show-current (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ exitCode: 0, stdout: 'merged-branch\n', stderr: '', } as MockRunResult) - // 2. git remote get-url origin (to extract owner) + // 2. git remote get-url origin (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ exitCode: 0, stdout: 'git@github.com:owner/repo.git\n', stderr: '', } as MockRunResult) - // 3. git remote get-url upstream (first in loop, fails) + // 3. git remote get-url upstream (from fetchAndCacheRepoInfo, fails) .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'fatal: No such remote', } as MockRunResult) - // 4. git remote get-url origin (second in loop) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - // 5. gh pr view + // 4. gh pr view .mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ number: 50, url: 'https://github.com/owner/repo/pull/50', state: 'MERGED', isDraft: false }), @@ -348,46 +335,52 @@ describe('Git IPC Handlers - Remote Pull Request', () => { it('should handle empty branch name', async () => { mockGitExecutor.run - // 1. git branch --show-current (returns empty for detached HEAD) + // 1. git branch --show-current (returns empty for detached HEAD) (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ exitCode: 0, stdout: '', // Empty branch (detached HEAD) stderr: '', + } as MockRunResult) + // 2. git remote get-url origin (from fetchAndCacheRepoInfo) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'git@github.com:owner/repo.git\n', + stderr: '', + } as MockRunResult) + // 3. git remote get-url upstream (from fetchAndCacheRepoInfo) + .mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'fatal: No such remote', } as MockRunResult); const result = await mockIpcMain.invoke('sessions:get-remote-pull-request', sessionId); - // Should fallback to gh pr view without --repo when branch is empty - expect(result.success).toBe(true); + // Should return null when branch is empty + expect(result).toEqual({ success: true, data: null }); }); it('should handle SSH URL without .git suffix', async () => { mockGitExecutor.run - // 1. git branch --show-current + // 1. git branch --show-current (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ exitCode: 0, stdout: 'branch\n', stderr: '', } as MockRunResult) - // 2. git remote get-url origin (to extract owner) + // 2. git remote get-url origin (from fetchAndCacheRepoInfo, no .git suffix) .mockResolvedValueOnce({ exitCode: 0, stdout: 'git@github.com:owner/repo\n', // No .git suffix stderr: '', } as MockRunResult) - // 3. git remote get-url upstream (first in loop, fails) + // 3. git remote get-url upstream (from fetchAndCacheRepoInfo, fails) .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'fatal: No such remote', } as MockRunResult) - // 4. git remote get-url origin (second in loop) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:owner/repo\n', // No .git suffix - stderr: '', - } as MockRunResult) - // 5. gh pr view + // 4. gh pr view .mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify({ number: 10, url: 'https://github.com/owner/repo/pull/10', state: 'OPEN', isDraft: false }), @@ -402,7 +395,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { }); // Verify owner/repo was parsed correctly - const ghPrViewCall = mockGitExecutor.run.mock.calls[4]; // Changed from index 2 to 4 + const ghPrViewCall = mockGitExecutor.run.mock.calls[3]; // Changed from index 4 expect(ghPrViewCall[0].argv).toContain('owner/repo'); }); }); @@ -456,17 +449,13 @@ describe('Git IPC Handlers - Commit URL', () => { }); it('should prefer upstream when origin is a fork', async () => { - mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:forkowner/snowtree.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:datafuselabs/snowtree.git\n', - stderr: '', - } as MockRunResult); + // Use cached data to avoid fetchAndCacheRepoInfo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'datafuselabs/snowtree', + is_fork: true, + origin_owner_repo: 'forkowner/snowtree', + }); const result = await mockIpcMain.invoke('sessions:get-commit-github-url', sessionId, { commitHash: 'abc123' }); @@ -584,22 +573,15 @@ describe('Git IPC Handlers - Branch Sync Status', () => { 'Upstream Author', ].join(delimiter); + // Set up cache to indicate this is a fork with both origin and upstream + mockSessionManager.db.getSession.mockReturnValue({ + is_fork: true, + origin_owner_repo: 'forkowner/snowtree', + owner_repo: 'datafuselabs/snowtree', + }); + mockGitExecutor.run.mockImplementation((opts: { argv: string[] }) => { const cmd = opts.argv.join(' '); - if (cmd.includes('remote get-url origin')) { - return Promise.resolve({ - exitCode: 0, - stdout: 'git@github.com:forkowner/snowtree.git\n', - stderr: '', - } as MockRunResult); - } - if (cmd.includes('remote get-url upstream')) { - return Promise.resolve({ - exitCode: 0, - stdout: 'git@github.com:datafuselabs/snowtree.git\n', - stderr: '', - } as MockRunResult); - } if (cmd.includes('show-ref --verify --quiet refs/remotes/upstream/main')) { return Promise.resolve({ exitCode: 0, @@ -659,19 +641,13 @@ describe('Git IPC Handlers - Branch Sync Status', () => { it('should return commits behind main count', async () => { mockGitExecutor.run - // 1. git remote get-url upstream (no upstream) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - // 2. git fetch origin main + // 1. git fetch origin main .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 3. git rev-list HEAD..origin/main --count + // 2. git rev-list HEAD..origin/main --count .mockResolvedValueOnce({ exitCode: 0, stdout: '5\n', @@ -688,19 +664,13 @@ describe('Git IPC Handlers - Branch Sync Status', () => { it('should return 0 when branch is up to date', async () => { mockGitExecutor.run - // 1. git remote get-url upstream (no upstream) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: '', - } as MockRunResult) - // 2. git fetch origin main + // 1. git fetch origin main .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 3. git rev-list HEAD..origin/main --count + // 2. git rev-list HEAD..origin/main --count .mockResolvedValueOnce({ exitCode: 0, stdout: '0\n', @@ -719,19 +689,13 @@ describe('Git IPC Handlers - Branch Sync Status', () => { mockSessionManager.getSession.mockReturnValue({ worktreePath, baseBranch: 'master' }); mockGitExecutor.run - // 1. git remote get-url upstream (no upstream) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: '', - } as MockRunResult) - // 2. git fetch origin master + // 1. git fetch origin master .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 3. git rev-list HEAD..origin/master --count + // 2. git rev-list HEAD..origin/master --count .mockResolvedValueOnce({ exitCode: 0, stdout: '3\n', @@ -746,25 +710,19 @@ describe('Git IPC Handlers - Branch Sync Status', () => { }); // Verify fetch was called with correct branch - const fetchCall = mockGitExecutor.run.mock.calls[1]; // Changed from index 0 to 1 + const fetchCall = mockGitExecutor.run.mock.calls[0]; expect(fetchCall[0].argv).toContain('master'); }); it('should return 0 when origin/main does not exist', async () => { mockGitExecutor.run - // 1. git remote get-url upstream (no upstream) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: '', - } as MockRunResult) - // 2. git fetch origin main + // 1. git fetch origin main .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 3. git rev-list HEAD..origin/main --count (fails) + // 2. git rev-list HEAD..origin/main --count (fails) .mockResolvedValueOnce({ exitCode: 128, // fatal: ambiguous argument stdout: '', @@ -781,19 +739,13 @@ describe('Git IPC Handlers - Branch Sync Status', () => { it('should handle fetch failure gracefully', async () => { mockGitExecutor.run - // 1. git remote get-url upstream (no upstream) - .mockResolvedValueOnce({ - exitCode: 1, - stdout: '', - stderr: '', - } as MockRunResult) - // 2. git fetch origin main (fails) + // 1. git fetch origin main (fails) .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'fatal: could not fetch', } as MockRunResult) - // 3. git rev-list HEAD..origin/main --count (still works with local refs) + // 2. git rev-list HEAD..origin/main --count (still works with local refs) .mockResolvedValueOnce({ exitCode: 0, stdout: '2\n', @@ -810,20 +762,19 @@ describe('Git IPC Handlers - Branch Sync Status', () => { }); it('should use upstream remote in fork workflow', async () => { + // Set up cache to indicate this is a fork + mockSessionManager.db.getSession.mockReturnValue({ + is_fork: true, + }); + mockGitExecutor.run - // 1. git remote get-url upstream (has upstream) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'git@github.com:databendlabs/snowtree.git\n', - stderr: '', - } as MockRunResult) - // 2. git fetch upstream main + // 1. git fetch upstream main .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 3. git rev-list HEAD..upstream/main --count + // 2. git rev-list HEAD..upstream/main --count .mockResolvedValueOnce({ exitCode: 0, stdout: '7\n', @@ -838,7 +789,7 @@ describe('Git IPC Handlers - Branch Sync Status', () => { }); // Verify fetch was called with upstream - const fetchCall = mockGitExecutor.run.mock.calls[1]; + const fetchCall = mockGitExecutor.run.mock.calls[0]; expect(fetchCall[0].argv).toContain('upstream'); expect(fetchCall[0].argv).toContain('main'); }); @@ -928,43 +879,37 @@ describe('Git IPC Handlers - Branch Sync Status', () => { }); mockGitExecutor.run - // 1. git branch --show-current - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'main\n', - stderr: '', - } as MockRunResult) - // 2. git config branch.main.pushRemote (fails, no pushRemote set) + // 1. git config branch.main.pushRemote (fails, no pushRemote set) .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '', } as MockRunResult) - // 3. git config branch.main.remote (fallback, returns origin) + // 2. git config branch.main.remote (fallback, returns origin) .mockResolvedValueOnce({ exitCode: 0, stdout: 'origin\n', stderr: '', } as MockRunResult) - // 4. git fetch origin main + // 3. git fetch origin main .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 5. git show-ref --verify --quiet refs/remotes/origin/main + // 4. git show-ref --verify --quiet refs/remotes/origin/main .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 6. git rev-list --count origin/main..HEAD (ahead) + // 5. git rev-list --count origin/main..HEAD (ahead) .mockResolvedValueOnce({ exitCode: 0, stdout: '0\n', stderr: '', } as MockRunResult) - // 7. git rev-list --count HEAD..origin/main (behind) + // 6. git rev-list --count HEAD..origin/main (behind) .mockResolvedValueOnce({ exitCode: 0, stdout: '0\n', @@ -1020,56 +965,55 @@ describe('Git IPC Handlers - Branch Sync Status', () => { }); it('should fallback to origin when branch remote is upstream and branch is missing', async () => { + // Use cached branch data + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + }); + mockGitExecutor.run - // 1. git branch --show-current - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) - // 2. git config branch.feature.pushRemote (fails) + // 1. git config branch.feature.pushRemote (fails) .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '', } as MockRunResult) - // 3. git config branch.feature.remote (returns upstream) + // 2. git config branch.feature.remote (returns upstream) .mockResolvedValueOnce({ exitCode: 0, stdout: 'upstream\n', stderr: '', } as MockRunResult) - // 4. git fetch upstream feature + // 3. git fetch upstream feature .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 5. git show-ref --verify --quiet refs/remotes/upstream/feature (missing) + // 4. git show-ref --verify --quiet refs/remotes/upstream/feature (missing) .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '', } as MockRunResult) - // 6. git fetch origin feature + // 5. git fetch origin feature .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 7. git show-ref --verify --quiet refs/remotes/origin/feature + // 6. git show-ref --verify --quiet refs/remotes/origin/feature .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', } as MockRunResult) - // 8. git rev-list origin/feature..HEAD --count (local ahead) + // 7. git rev-list origin/feature..HEAD --count (local ahead) .mockResolvedValueOnce({ exitCode: 0, stdout: '2\n', stderr: '', } as MockRunResult) - // 9. git rev-list HEAD..origin/feature --count (remote ahead) + // 8. git rev-list HEAD..origin/feature --count (remote ahead) .mockResolvedValueOnce({ exitCode: 0, stdout: '1\n', @@ -1271,14 +1215,23 @@ describe('Git IPC Handlers - CI Status', () => { }); it('should return null when remote URL cannot be obtained', async () => { + // Cache miss - provide fetchAndCacheRepoInfo mocks mockGitExecutor.run + // 1. git branch --show-current (from fetchAndCacheRepoInfo) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'feature\n', + stderr: '', + } as MockRunResult) + // 2. git remote get-url origin (from fetchAndCacheRepoInfo, fails) .mockResolvedValueOnce({ - exitCode: 1, // upstream not found + exitCode: 1, stdout: '', stderr: 'fatal: No such remote', } as MockRunResult) + // 3. git remote get-url upstream (from fetchAndCacheRepoInfo, fails) .mockResolvedValueOnce({ - exitCode: 1, // origin not found + exitCode: 1, stdout: '', stderr: 'fatal: No such remote', } as MockRunResult); @@ -1289,16 +1242,25 @@ describe('Git IPC Handlers - CI Status', () => { }); it('should return null for non-GitHub remotes', async () => { + // Cache miss - provide fetchAndCacheRepoInfo mocks mockGitExecutor.run + // 1. git branch --show-current (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', + exitCode: 0, + stdout: 'feature\n', + stderr: '', } as MockRunResult) + // 2. git remote get-url origin (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ exitCode: 0, stdout: 'git@gitlab.com:owner/repo.git\n', stderr: '', + } as MockRunResult) + // 3. git remote get-url upstream (from fetchAndCacheRepoInfo) + .mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'fatal: No such remote', } as MockRunResult); const result = await mockIpcMain.invoke('sessions:get-ci-status', sessionId); @@ -1307,21 +1269,25 @@ describe('Git IPC Handlers - CI Status', () => { }); it('should return null when branch is empty', async () => { + // Cache miss with empty branch - provide fetchAndCacheRepoInfo mocks mockGitExecutor.run + // 1. git branch --show-current (from fetchAndCacheRepoInfo, empty for detached HEAD) .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', + exitCode: 0, + stdout: '', // Empty branch (detached HEAD) + stderr: '', } as MockRunResult) + // 2. git remote get-url origin (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ - exitCode: 0, // origin + exitCode: 0, stdout: 'git@github.com:owner/repo.git\n', stderr: '', } as MockRunResult) + // 3. git remote get-url upstream (from fetchAndCacheRepoInfo) .mockResolvedValueOnce({ - exitCode: 0, - stdout: '', // Empty branch (detached HEAD) - stderr: '', + exitCode: 1, + stdout: '', + stderr: 'fatal: No such remote', } as MockRunResult); const result = await mockIpcMain.invoke('sessions:get-ci-status', sessionId); @@ -1330,24 +1296,18 @@ describe('Git IPC Handlers - CI Status', () => { }); it('should return null when gh pr checks fails', async () => { + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature-branch', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run + // 1. gh pr checks (fails - no PR) .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature-branch\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 1, // gh pr checks fails (no PR) + exitCode: 1, stdout: '', stderr: 'no pull requests found', } as MockRunResult); @@ -1363,22 +1323,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'test', state: 'SUCCESS', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:12:00Z', link: 'https://github.com/test/link2' }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1407,22 +1361,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'test', state: 'FAILURE', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:08:00Z', link: 'https://github.com/test/link2' }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1442,22 +1390,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'build', state: 'PENDING', startedAt: null, completedAt: null, link: 'https://github.com/test/link1' }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1478,22 +1420,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'build', state: 'IN_PROGRESS', startedAt: '2026-01-14T05:00:00Z', completedAt: null, link: 'https://github.com/test/link1' }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1514,22 +1450,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'optional-check', state: 'SKIPPED', startedAt: null, completedAt: null, link: null }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1549,22 +1479,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'build', state: 'CANCELLED', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:01:00Z', link: null }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1579,22 +1503,16 @@ describe('Git IPC Handlers - CI Status', () => { }); it('should handle empty checks array', async () => { + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: '[]', @@ -1607,22 +1525,16 @@ describe('Git IPC Handlers - CI Status', () => { }); it('should handle malformed JSON gracefully', async () => { + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: 'not valid json', @@ -1641,22 +1553,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'lint', state: 'IN_PROGRESS', startedAt: '2026-01-14T05:00:00Z', completedAt: null, link: null }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'feature', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'git@github.com:owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'feature\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1674,22 +1580,16 @@ describe('Git IPC Handlers - CI Status', () => { { name: 'build', state: 'SUCCESS', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:10:00Z', link: null }, ]); + // Use cached data to avoid calling fetchAndCacheRepoInfo - need BOTH branch AND owner_repo + mockSessionManager.db.getSession.mockReturnValue({ + current_branch: 'main', + owner_repo: 'owner/repo', + is_fork: false, + origin_owner_repo: 'owner/repo', + }); + mockGitExecutor.run - .mockResolvedValueOnce({ - exitCode: 1, // no upstream - stdout: '', - stderr: 'fatal: No such remote', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, // origin - stdout: 'https://github.com/owner/repo.git\n', - stderr: '', - } as MockRunResult) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: 'main\n', - stderr: '', - } as MockRunResult) + // 1. gh pr checks .mockResolvedValueOnce({ exitCode: 0, stdout: checksJson, @@ -1701,8 +1601,8 @@ describe('Git IPC Handlers - CI Status', () => { expect(result.success).toBe(true); expect(result.data.rollupState).toBe('success'); - // Verify gh pr checks was called with correct --repo (call index changed due to upstream check) - const ghChecksCall = mockGitExecutor.run.mock.calls[3]; + // Verify gh pr checks was called with correct --repo + const ghChecksCall = mockGitExecutor.run.mock.calls[0]; expect(ghChecksCall[0].argv).toContain('--repo'); expect(ghChecksCall[0].argv).toContain('owner/repo'); }); diff --git a/packages/desktop/src/infrastructure/ipc/git.ts b/packages/desktop/src/infrastructure/ipc/git.ts index b110630..1aec8f8 100644 --- a/packages/desktop/src/infrastructure/ipc/git.ts +++ b/packages/desktop/src/infrastructure/ipc/git.ts @@ -239,34 +239,18 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } } - const getRemoteUrl = async (remoteName: string): Promise => { - try { - const { stdout } = await gitExecutor.run({ - cwd: session.worktreePath!, - argv: ['git', 'remote', 'get-url', remoteName], - op: 'read', - recordTimeline: false, - meta: { source: 'ipc.git', operation: 'get-remote-url', remote: remoteName }, - }); - const trimmed = stdout.trim(); - return trimmed ? trimmed : null; - } catch { - return null; - } - }; - - const originUrl = await getRemoteUrl('origin'); - const upstreamUrl = await getRemoteUrl('upstream'); - const originExists = Boolean(originUrl); - const upstreamExists = Boolean(upstreamUrl); + // Use cached repo info to determine remote preference + const dbSession = sessionManager.db.getSession(sessionId); + const isFork = dbSession?.is_fork || false; let preferredRemote: 'origin' | 'upstream' | null = null; - if (originUrl && upstreamUrl && isForkOfUpstream(originUrl, upstreamUrl)) { + const originExists = Boolean(dbSession?.origin_owner_repo); + const upstreamExists = isFork; // If it's a fork, upstream exists + + if (isFork) { preferredRemote = 'upstream'; - } else if (originUrl) { + } else if (originExists) { preferredRemote = 'origin'; - } else if (upstreamUrl) { - preferredRemote = 'upstream'; } const remoteCandidates: Array<'origin' | 'upstream'> = []; @@ -494,32 +478,22 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo originOwner = originOwnerRepo.split('/')[0]; } - // Try to find PR in multiple remotes (upstream first for fork workflow, then origin) - const remoteNames = isFork ? ['upstream', 'origin'] : ['origin', 'upstream']; + // For fork workflow: try upstream first (ownerRepo), then origin (originOwnerRepo) + // For non-fork: try origin first, then upstream + const repoAttempts: Array<{ repo: string; remoteName: string }> = []; - for (const remoteName of remoteNames) { - const remoteRes = await gitExecutor.run({ - sessionId, - cwd: session.worktreePath, - argv: ['git', 'remote', 'get-url', remoteName], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 3_000, - meta: { source: 'ipc.git', operation: 'get-remote-url' }, - }); - - if (remoteRes.exitCode !== 0 || !remoteRes.stdout?.trim()) continue; - - const url = remoteRes.stdout.trim(); - // Parse: git@github.com:owner/repo.git or https://github.com/owner/repo.git - const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/); - if (!match) continue; + if (isFork) { + if (ownerRepo) repoAttempts.push({ repo: ownerRepo, remoteName: 'upstream' }); + if (originOwnerRepo) repoAttempts.push({ repo: originOwnerRepo, remoteName: 'origin' }); + } else { + if (ownerRepo) repoAttempts.push({ repo: ownerRepo, remoteName: 'origin' }); + } + for (const { repo, remoteName } of repoAttempts) { // For upstream repo in fork workflow, use "owner:branch" format // For origin repo, use just "branch" const branchArg = remoteName === 'upstream' && originOwner ? `${originOwner}:${branch}` : branch; - const repoArgs = ['--repo', match[1], branchArg]; + const repoArgs = ['--repo', repo, branchArg]; const res = await gitExecutor.run({ sessionId, @@ -909,50 +883,43 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo const commitHash = typeof options?.commitHash === 'string' ? options.commitHash.trim() : ''; if (!commitHash) return { success: false, error: 'Commit hash is required' }; - const getRemoteUrl = async (remoteName: string): Promise => { - try { - const result = await gitExecutor.run({ - sessionId, - cwd: session.worktreePath, - argv: ['git', 'remote', 'get-url', remoteName], - op: 'read', - recordTimeline: false, - meta: { source: 'ipc.git', operation: 'get-remote-url', remote: remoteName }, - timeoutMs: 5_000, - }); - const trimmed = result.stdout.trim(); - return trimmed ? trimmed : null; - } catch { - return null; - } - }; + // Use cached repo info to get owner/repo + const dbSession = sessionManager.db.getSession(sessionId); + let ownerRepo = dbSession?.owner_repo; + let originOwnerRepo = dbSession?.origin_owner_repo; + let isFork = dbSession?.is_fork || false; - const originUrl = await getRemoteUrl('origin'); - const upstreamUrl = await getRemoteUrl('upstream'); + // If cache miss, fetch and cache + if (!ownerRepo) { + const repoInfo = await fetchAndCacheRepoInfo(sessionId, session.worktreePath, sessionManager, gitExecutor); + if (!repoInfo) { + return { success: false, error: 'No remote configured' }; + } + ownerRepo = repoInfo.ownerRepo; + originOwnerRepo = repoInfo.originOwnerRepo; + isFork = repoInfo.isFork; + } - if (!originUrl && !upstreamUrl) { + if (!ownerRepo) { return { success: false, error: 'No remote configured' }; } - const remoteCandidates: Array<'origin' | 'upstream'> = []; - if (originUrl && upstreamUrl && isForkOfUpstream(originUrl, upstreamUrl)) { - remoteCandidates.push('upstream', 'origin'); + // For fork workflow, prefer upstream, then origin + // For non-fork, use ownerRepo (which is already the correct one) + const repoCandidates: string[] = []; + if (isFork) { + if (ownerRepo) repoCandidates.push(ownerRepo); // upstream + if (originOwnerRepo) repoCandidates.push(originOwnerRepo); // origin } else { - if (originUrl) remoteCandidates.push('origin'); - if (upstreamUrl) remoteCandidates.push('upstream'); + if (ownerRepo) repoCandidates.push(ownerRepo); } - for (const remoteName of remoteCandidates) { - const remoteUrl = remoteName === 'origin' ? originUrl : upstreamUrl; - if (!remoteUrl) continue; - const gitHubBaseUrl = parseGitRemoteToGitHubUrl(remoteUrl); - if (gitHubBaseUrl) { - const url = `${gitHubBaseUrl}/commit/${commitHash}`; - return { success: true, data: { url } }; - } + for (const repo of repoCandidates) { + const url = `https://github.com/${repo}/commit/${commitHash}`; + return { success: true, data: { url } }; } - return { success: false, error: 'Remote is not a GitHub repository' }; + return { success: false, error: 'No remote configured' }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to get commit GitHub URL' }; } @@ -972,26 +939,21 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo const cwd = session.worktreePath; const baseBranch = session.baseBranch || 'main'; - // Determine which remote to use for the base branch - // In fork workflows, we want to compare with upstream/main, not origin/main - // Try upstream first, fallback to origin - let remoteName = 'origin'; - - const upstreamCheck = await gitExecutor.run({ - sessionId, - cwd, - argv: ['git', 'remote', 'get-url', 'upstream'], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 3_000, - meta: { source: 'ipc.git', operation: 'check-upstream' }, - }); + // Use cached repo info to determine if it's a fork workflow + const dbSession = sessionManager.db.getSession(sessionId); + let isFork = dbSession?.is_fork || false; - if (upstreamCheck.exitCode === 0 && upstreamCheck.stdout?.trim()) { - remoteName = 'upstream'; + // If cache miss, fetch and cache + if (dbSession && dbSession.is_fork === null) { + const repoInfo = await fetchAndCacheRepoInfo(sessionId, cwd, sessionManager, gitExecutor); + if (repoInfo) { + isFork = repoInfo.isFork; + } } + // In fork workflows, we want to compare with upstream/main, not origin/main + const remoteName = isFork ? 'upstream' : 'origin'; + // Fetch the remote to ensure we have latest refs await gitExecutor.run({ sessionId, @@ -1193,71 +1155,26 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo const cwd = session.worktreePath; - // Helper to extract owner/repo from a GitHub URL - const extractOwnerRepo = (url: string): string | null => { - const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/); - return match ? match[1] : null; - }; - - // Try to get upstream remote first (for fork workflow), fallback to origin - let ownerRepo: string | null = null; - let originOwnerRepo: string | null = null; - - // Try upstream first - const upstreamRes = await gitExecutor.run({ - sessionId, - cwd, - argv: ['git', 'remote', 'get-url', 'upstream'], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 3_000, - meta: { source: 'ipc.git', operation: 'ci-status-remote-upstream' }, - }); - - if (upstreamRes.exitCode === 0 && upstreamRes.stdout?.trim()) { - ownerRepo = extractOwnerRepo(upstreamRes.stdout.trim()); - } - - // Get origin (needed for branch prefix in fork workflow) - const originRes = await gitExecutor.run({ - sessionId, - cwd, - argv: ['git', 'remote', 'get-url', 'origin'], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 3_000, - meta: { source: 'ipc.git', operation: 'ci-status-remote-origin' }, - }); - - if (originRes.exitCode === 0 && originRes.stdout?.trim()) { - originOwnerRepo = extractOwnerRepo(originRes.stdout.trim()); - } - - // Fallback to origin if no upstream - if (!ownerRepo) { - ownerRepo = originOwnerRepo; - } + // Use cached repo info + const dbSession = sessionManager.db.getSession(sessionId); + let branch = dbSession?.current_branch; + let ownerRepo = dbSession?.owner_repo; + let originOwnerRepo = dbSession?.origin_owner_repo; + let isFork = dbSession?.is_fork || false; - if (!ownerRepo) { - return { success: true, data: null }; + // If cache miss, fetch and cache + if (!branch || !ownerRepo) { + const repoInfo = await fetchAndCacheRepoInfo(sessionId, cwd, sessionManager, gitExecutor); + if (!repoInfo) { + return { success: true, data: null }; + } + branch = repoInfo.currentBranch; + ownerRepo = repoInfo.ownerRepo; + originOwnerRepo = repoInfo.originOwnerRepo; + isFork = repoInfo.isFork; } - // Get current branch - const branchRes = await gitExecutor.run({ - sessionId, - cwd, - argv: ['git', 'branch', '--show-current'], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 3_000, - meta: { source: 'ipc.git', operation: 'ci-status-branch' }, - }); - - const branch = branchRes.stdout?.trim(); - if (!branch) { + if (!ownerRepo || !branch) { return { success: true, data: null }; } @@ -1384,82 +1301,63 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo return { success: false, error: 'Session worktree not found' }; } - // Get current branch - const branchRes = await gitExecutor.run({ - sessionId, - cwd: session.worktreePath, - argv: ['git', 'branch', '--show-current'], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 5_000, - meta: { source: 'ipc.git', operation: 'mark-pr-ready-branch' }, - }); + // Use cached repo info + const dbSession = sessionManager.db.getSession(sessionId); + let branch = dbSession?.current_branch; + let ownerRepo = dbSession?.owner_repo; + let originOwnerRepo = dbSession?.origin_owner_repo; + let isFork = dbSession?.is_fork || false; - if (branchRes.exitCode !== 0 || !branchRes.stdout?.trim()) { - console.error('[git.ts] Failed to get current branch'); - return { success: false, error: 'Failed to get current branch' }; + // If cache miss, fetch and cache + if (!branch || !ownerRepo) { + const repoInfo = await fetchAndCacheRepoInfo(sessionId, session.worktreePath, sessionManager, gitExecutor); + if (!repoInfo) { + console.error('[git.ts] Failed to get repo info'); + return { success: false, error: 'Failed to get repo info' }; + } + branch = repoInfo.currentBranch; + ownerRepo = repoInfo.ownerRepo; + originOwnerRepo = repoInfo.originOwnerRepo; + isFork = repoInfo.isFork; + } + + if (!branch || !ownerRepo) { + console.error('[git.ts] Branch or owner/repo not found'); + return { success: false, error: 'Branch or owner/repo not found' }; } - const branch = branchRes.stdout.trim(); console.log('[git.ts] Current branch:', branch); // Get origin owner for fork workflow let originOwner: string | null = null; - const originRes = await gitExecutor.run({ - sessionId, - cwd: session.worktreePath, - argv: ['git', 'remote', 'get-url', 'origin'], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 3_000, - meta: { source: 'ipc.git', operation: 'mark-pr-ready-origin' }, - }); - if (originRes.exitCode === 0 && originRes.stdout?.trim()) { - const originUrl = originRes.stdout.trim(); - const originMatch = originUrl.match(/github\.com[:/]([^/]+)\//); - if (originMatch) { - originOwner = originMatch[1]; - } + if (isFork && originOwnerRepo) { + originOwner = originOwnerRepo.split('/')[0]; } console.log('[git.ts] Origin owner:', originOwner); - // Try to mark PR ready in multiple remotes (same logic as get-remote-pull-request) - const remoteNames = ['upstream', 'origin']; - - for (const remoteName of remoteNames) { - const remoteRes = await gitExecutor.run({ - sessionId, - cwd: session.worktreePath, - argv: ['git', 'remote', 'get-url', remoteName], - op: 'read', - recordTimeline: false, - throwOnError: false, - timeoutMs: 3_000, - meta: { source: 'ipc.git', operation: 'mark-pr-ready-remote' }, - }); - - if (remoteRes.exitCode !== 0 || !remoteRes.stdout?.trim()) continue; - - const url = remoteRes.stdout.trim(); - // Parse: git@github.com:owner/repo.git or https://github.com/owner/repo.git - const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/); - if (!match) continue; + // For fork workflow: try upstream first (ownerRepo), then origin (originOwnerRepo) + // For non-fork: try origin only + const repoAttempts: Array<{ repo: string; remoteName: string }> = []; - const ownerRepo = match[1]; + if (isFork) { + if (ownerRepo) repoAttempts.push({ repo: ownerRepo, remoteName: 'upstream' }); + if (originOwnerRepo) repoAttempts.push({ repo: originOwnerRepo, remoteName: 'origin' }); + } else { + if (ownerRepo) repoAttempts.push({ repo: ownerRepo, remoteName: 'origin' }); + } + for (const { repo, remoteName } of repoAttempts) { // For upstream repo in fork workflow, use "owner:branch" format // For origin repo, use just "branch" const branchArg = remoteName === 'upstream' && originOwner ? `${originOwner}:${branch}` : branch; - console.log(`[git.ts] Trying ${remoteName} with repo=${ownerRepo}, branch=${branchArg}`); + console.log(`[git.ts] Trying ${remoteName} with repo=${repo}, branch=${branchArg}`); // Mark PR as ready for review using gh pr ready const readyRes = await gitExecutor.run({ sessionId, cwd: session.worktreePath, - argv: ['gh', 'pr', 'ready', '--repo', ownerRepo, branchArg], + argv: ['gh', 'pr', 'ready', '--repo', repo, branchArg], op: 'write', recordTimeline: true, throwOnError: false, diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 20efc84..0e30197 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -233,31 +233,29 @@ export const MainLayout: React.FC = React.memo(() => { const headBranch = branchName || 'main'; const baseBranch = session.baseBranch || 'main'; + // Build repo context from session cache + const ownerRepo = session.ownerRepo || 'UNKNOWN'; + const isFork = session.isFork || false; + const originOwnerRepo = session.originOwnerRepo; + const originOwner = originOwnerRepo ? originOwnerRepo.split('/')[0] : null; + + // Compute pr_ref and pr_head based on fork workflow + const prRef = isFork && originOwner ? `${originOwner}:${headBranch}` : headBranch; + const prHead = isFork && originOwner ? `${originOwner}:${headBranch}` : headBranch; + const pushPrompt = [ 'Push the current branch and update/create a GitHub PR based on committed changes (using `gh`).', '', `Base branch: ${baseBranch}`, - `Expected head branch: ${headBranch}`, + `Current branch: ${headBranch}`, + `Repository: ${ownerRepo}`, + `Fork workflow: ${isFork ? 'yes' : 'no'}`, + ...(isFork ? [`Origin repository: ${originOwnerRepo}`] : []), '', 'Do (show the exact commands you run):', - '1. Check status and get repo info:', + '1. Check status:', ' - git status', - ' - git branch --show-current # Get current branch', ' - git log -1 --oneline', - ' - git remote -v # List all remotes', - ' - Select the remote for --repo: use "upstream" if it exists, otherwise use "origin"', - ' - Parse / from the selected remote URL', - ' - Supported formats:', - ' - https://github.com//.git', - ' - git@github.com:/.git', - ' - Use the parsed / for all gh --repo values', - ' - Parse / from origin URL (same URL formats as above)', - ' - Compute pr_ref for gh pr view positional argument:', - ' - If repo remote is "upstream" AND != : pr_ref = :', - ' - Else: pr_ref = ', - ' - Compute pr_head for gh pr create --head:', - ' - If repo remote is "upstream" AND != : pr_head = :', - ' - Else: pr_head = ', '', '2. Check for PR template:', ' - Only look for: .github/PULL_REQUEST_TEMPLATE.md', @@ -268,13 +266,12 @@ export const MainLayout: React.FC = React.memo(() => { ' - git push origin ', ' - If push fails, show the exact error and ask me what to do next', '', - '4. Create or update PR (ALWAYS use --repo / with gh commands):', - ' - Check existing: gh pr view --repo / --json number,url,state', - ` - If no PR exists: gh pr create --repo / --draft --base ${baseBranch} --head --title "" --body "<body>"`, - ' - If PR exists: gh pr edit --repo <owner>/<repo> <pr_ref> --title "<title>" --body "<body>" (only if needed)', + '4. Create or update PR:', + ` - Check existing: gh pr view --repo ${ownerRepo} ${prRef} --json number,url,state`, + ` - If no PR exists: gh pr create --repo ${ownerRepo} --draft --base ${baseBranch} --head ${prHead} --title "<title>" --body "<body>"`, + ` - If PR exists: gh pr edit --repo ${ownerRepo} ${prRef} --title "<title>" --body "<body>" (only if needed)`, '', 'Guidelines:', - '- ALWAYS use --repo <owner>/<repo> with ALL gh commands (required for worktree compatibility)', '- ALWAYS use --draft flag when creating new PRs', '- Avoid commands that persist git config (e.g. `git push -u`, `git branch --set-upstream-to`, `git config ...`); in worktrees these may write outside the worktree directory and fail under restricted sandboxes', '- If you need SSH options for a single push, use `GIT_SSH_COMMAND=\"ssh -p 22\" git push origin <branch>` or `git -c core.sshCommand=\"ssh -p 22\" push origin <branch>` (do not persist config)', @@ -303,28 +300,26 @@ export const MainLayout: React.FC = React.memo(() => { const baseBranch = session.baseBranch || 'main'; + // Determine remote from session cache + const isFork = session.isFork || false; + const remoteName = isFork ? 'upstream' : 'origin'; + const updatePrompt = [ `Update the current branch with the latest changes from the upstream base branch.`, '', `Base branch: ${baseBranch}`, + `Remote: ${remoteName}`, + `Fork workflow: ${isFork ? 'yes' : 'no'}`, '', 'Do (show the exact commands you run):', '1. Check current state:', ' - git status # Ensure working tree is clean', - ' - git branch --show-current', '', - '2. Determine which remote to use:', - ' - git remote -v # List all remotes', - ` - If "upstream" remote exists: use upstream/${baseBranch}`, - ` - Otherwise: use origin/${baseBranch}`, + '2. Fetch and rebase:', + ` - git fetch ${remoteName} ${baseBranch}`, + ` - git rebase ${remoteName}/${baseBranch}`, '', - '3. Fetch latest changes:', - ` - git fetch <remote> ${baseBranch} # Use the remote determined in step 2`, - '', - '4. Rebase current branch:', - ` - git rebase <remote>/${baseBranch} # Use the remote determined in step 2`, - '', - '5. If conflicts occur:', + '3. If conflicts occur:', ' - List conflicted files: git status', ' - For each conflicted file:', ' a. Read the file content to see conflict markers (<<<<<<< ======= >>>>>>>)', @@ -335,7 +330,6 @@ export const MainLayout: React.FC = React.memo(() => { ' - Repeat until all conflicts are resolved', '', 'Guidelines:', - '- Prefer "upstream" remote if it exists (for fork workflow), otherwise use "origin"', '- If working tree is dirty: stop and tell me to commit/stash changes first', '- If rebase succeeds: report success', '- If conflicts occur: analyze and resolve them intelligently', @@ -364,10 +358,11 @@ export const MainLayout: React.FC = React.memo(() => { const syncPrompt = [ `Sync local branch with the latest changes from the remote PR branch (origin/${headBranch}).`, '', + `Current branch: ${headBranch}`, + '', 'Do (show the exact commands you run):', '1. Check current state:', ' - git status # Ensure working tree is clean', - ' - git branch --show-current', '', '2. Fetch and check divergence:', ` - git fetch origin ${headBranch}`, diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index df2049d..df133b5 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -27,4 +27,8 @@ export interface Session { executionMode?: 'plan' | 'execute'; gitStatus?: GitStatus; workspaceStage?: import('./workspace').WorkspaceStage; + currentBranch?: string; + ownerRepo?: string; + isFork?: boolean; + originOwnerRepo?: string; }