Skip to content

Commit 3e6f72b

Browse files
authored
Merge pull request #1881 from generalaction/jona/gen-1045-default-task-branch-is-unintuitive
fix: default branch resolution for project base refs
2 parents 0595b4c + 3dc1dd3 commit 3e6f72b

9 files changed

Lines changed: 411 additions & 78 deletions

File tree

src/main/core/git/impl/git-service.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,20 +1455,12 @@ export class GitService implements GitProvider, IDisposable {
14551455
}
14561456

14571457
let remoteName: string | undefined;
1458-
let remote: string | undefined;
14591458
try {
14601459
const { stdout } = await this.ctx.exec('git', ['remote']);
14611460
const remotes = stdout.trim().split('\n').filter(Boolean);
14621461
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
14631462
} catch {}
14641463

1465-
if (remoteName) {
1466-
try {
1467-
const { stdout } = await this.ctx.exec('git', ['remote', 'get-url', remoteName]);
1468-
remote = stdout.trim() || undefined;
1469-
} catch {}
1470-
}
1471-
14721464
let branch: string | undefined;
14731465
try {
14741466
const { stdout } = await this.ctx.exec('git', ['branch', '--show-current']);
@@ -1492,8 +1484,6 @@ export class GitService implements GitProvider, IDisposable {
14921484

14931485
return {
14941486
isGitRepo: true,
1495-
remote,
1496-
branch,
14971487
baseRef: computeBaseRef(undefined, remoteName, branch),
14981488
rootPath,
14991489
};

src/main/core/git/repository-service.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
RemoteBranchesPayload,
1313
RenameBranchError,
1414
} from '@shared/git';
15-
import { computeDefaultBranch, selectPreferredRemote } from '@shared/git-utils';
15+
import { selectPreferredRemote } from '@shared/git-utils';
1616
import type { ProjectRemoteState } from '@shared/projects';
1717
import type { Result } from '@shared/result';
1818
import type { ProjectSettingsProvider } from '@main/core/projects/settings/schema';
@@ -32,14 +32,6 @@ export class GitRepositoryService {
3232
return selectPreferredRemote(configured, remotes).name;
3333
}
3434

35-
async getDefaultBranchName(): Promise<string> {
36-
const configured = await this.settings.getDefaultBranch();
37-
const remote = await this.getConfiguredRemote();
38-
const branches = await this.git.getBranches();
39-
const gitDefault = await this.git.getDefaultBranch(remote);
40-
return computeDefaultBranch(configured, branches, remote, gitDefault);
41-
}
42-
4335
async getRepositoryInfo(): Promise<{ isUnborn: boolean; currentBranch: string | null }> {
4436
const headState = await this.git.getHeadState();
4537
const currentBranch = headState.isUnborn

src/main/core/projects/operations/createProject.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { createLocalProject, createSshProject, getSshProjectPathStatus } from '.
66

77
const mocks = vi.hoisted(() => ({
88
detectInfoMock: vi.fn(),
9+
getBranchesMock: vi.fn(),
10+
getDefaultBranchMock: vi.fn(),
911
initRepositoryMock: vi.fn(),
1012
openProjectMock: vi.fn(),
1113
getProjectMock: vi.fn(),
@@ -20,6 +22,8 @@ vi.mock('@main/core/git/impl/git-service', () => ({
2022
GitService: vi.fn(function MockGitService() {
2123
return {
2224
detectInfo: mocks.detectInfoMock,
25+
getBranches: mocks.getBranchesMock,
26+
getDefaultBranch: mocks.getDefaultBranchMock,
2327
initRepository: mocks.initRepositoryMock,
2428
};
2529
}),
@@ -59,6 +63,8 @@ beforeEach(() => {
5963
mocks.valuesMock.mockReturnValue({ returning: mocks.returningMock });
6064
mocks.openProjectMock.mockResolvedValue(undefined);
6165
mocks.getProjectMock.mockReturnValue(undefined);
66+
mocks.getBranchesMock.mockResolvedValue([]);
67+
mocks.getDefaultBranchMock.mockResolvedValue('main');
6268
mocks.initRepositoryMock.mockResolvedValue(undefined);
6369
mocks.sshConnectMock.mockResolvedValue({ id: 'ssh-proxy' });
6470
mocks.sshStatMock.mockResolvedValue({ path: '', type: 'dir' });
@@ -172,6 +178,84 @@ describe('createLocalProject', () => {
172178
expect(mocks.initRepositoryMock).not.toHaveBeenCalled();
173179
expect(mocks.detectInfoMock).toHaveBeenCalledTimes(1);
174180
});
181+
182+
it('stores the git remote default branch as baseRef instead of the current feature branch', async () => {
183+
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-'));
184+
tempDirs.push(projectPath);
185+
const row = {
186+
id: 'project-id',
187+
name: 'Project',
188+
path: projectPath,
189+
baseRef: 'origin/main',
190+
createdAt: '2026-04-16T00:00:00.000Z',
191+
updatedAt: '2026-04-16T00:00:00.000Z',
192+
};
193+
194+
mocks.detectInfoMock.mockResolvedValue({
195+
isGitRepo: true,
196+
baseRef: 'origin/feature/current',
197+
rootPath: projectPath,
198+
});
199+
mocks.getDefaultBranchMock.mockResolvedValue('main');
200+
mocks.getBranchesMock.mockResolvedValue([
201+
{
202+
type: 'remote',
203+
branch: 'main',
204+
remote: { name: 'origin', url: 'git@github.com:example/repo.git' },
205+
},
206+
]);
207+
mocks.returningMock.mockResolvedValue([row]);
208+
209+
const created = await createLocalProject({
210+
id: 'project-id',
211+
name: 'Project',
212+
path: projectPath,
213+
});
214+
215+
expect(mocks.valuesMock).toHaveBeenCalledWith(
216+
expect.objectContaining({ baseRef: 'origin/main' })
217+
);
218+
expect(created.baseRef).toBe('origin/main');
219+
});
220+
221+
it('keeps the detected baseRef when the git default branch is not present on the remote', async () => {
222+
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-'));
223+
tempDirs.push(projectPath);
224+
const row = {
225+
id: 'project-id',
226+
name: 'Project',
227+
path: projectPath,
228+
baseRef: 'origin/feature/current',
229+
createdAt: '2026-04-16T00:00:00.000Z',
230+
updatedAt: '2026-04-16T00:00:00.000Z',
231+
};
232+
233+
mocks.detectInfoMock.mockResolvedValue({
234+
isGitRepo: true,
235+
baseRef: 'origin/feature/current',
236+
rootPath: projectPath,
237+
});
238+
mocks.getDefaultBranchMock.mockResolvedValue('main');
239+
mocks.getBranchesMock.mockResolvedValue([
240+
{
241+
type: 'remote',
242+
branch: 'develop',
243+
remote: { name: 'origin', url: 'git@github.com:example/repo.git' },
244+
},
245+
]);
246+
mocks.returningMock.mockResolvedValue([row]);
247+
248+
const created = await createLocalProject({
249+
id: 'project-id',
250+
name: 'Project',
251+
path: projectPath,
252+
});
253+
254+
expect(mocks.valuesMock).toHaveBeenCalledWith(
255+
expect.objectContaining({ baseRef: 'origin/feature/current' })
256+
);
257+
expect(created.baseRef).toBe('origin/feature/current');
258+
});
175259
});
176260

177261
describe('createSshProject', () => {
@@ -262,6 +346,40 @@ describe('createSshProject', () => {
262346
expect(mocks.detectInfoMock).not.toHaveBeenCalled();
263347
expect(mocks.initRepositoryMock).not.toHaveBeenCalled();
264348
});
349+
350+
it('stores the git remote default branch as the SSH project baseRef', async () => {
351+
const rowWithDefault = {
352+
...row,
353+
baseRef: 'origin/main',
354+
};
355+
356+
mocks.detectInfoMock.mockResolvedValue({
357+
isGitRepo: true,
358+
baseRef: 'origin/feature/current',
359+
rootPath: row.path,
360+
});
361+
mocks.getDefaultBranchMock.mockResolvedValue('main');
362+
mocks.getBranchesMock.mockResolvedValue([
363+
{
364+
type: 'remote',
365+
branch: 'main',
366+
remote: { name: 'origin', url: 'git@github.com:example/repo.git' },
367+
},
368+
]);
369+
mocks.returningMock.mockResolvedValue([rowWithDefault]);
370+
371+
const created = await createSshProject({
372+
id: 'project-id',
373+
name: 'Project',
374+
path: projectPath,
375+
connectionId: 'connection-id',
376+
});
377+
378+
expect(mocks.valuesMock).toHaveBeenCalledWith(
379+
expect.objectContaining({ baseRef: 'origin/main' })
380+
);
381+
expect(created.baseRef).toBe('origin/main');
382+
});
265383
});
266384

267385
describe('getSshProjectPathStatus', () => {

src/main/core/projects/operations/createProject.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { randomUUID } from 'node:crypto';
22
import { sql } from 'drizzle-orm';
3+
import { remoteNameFromQualifiedRef, resolveBaseRefFromRemoteDefault } from '@shared/git-utils';
34
import { type LocalProject, type ProjectPathStatus, type SshProject } from '@shared/projects';
45
import { GitHubAuthExecutionContext } from '@main/core/execution-context/github-auth-execution-context';
56
import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context';
@@ -13,8 +14,29 @@ import { projectManager } from '@main/core/projects/project-manager';
1314
import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager';
1415
import { db } from '@main/db/client';
1516
import { projects } from '@main/db/schema';
17+
import { log } from '@main/lib/logger';
1618
import { checkIsValidDirectory } from '../path-utils';
1719

20+
async function resolveProjectBaseRef(git: GitService, detectedBaseRef: string): Promise<string> {
21+
const remoteName = remoteNameFromQualifiedRef(detectedBaseRef);
22+
if (!remoteName) return detectedBaseRef;
23+
24+
try {
25+
const [gitDefaultBranch, branches] = await Promise.all([
26+
git.getDefaultBranch(remoteName),
27+
git.getBranches(),
28+
]);
29+
return resolveBaseRefFromRemoteDefault({ detectedBaseRef, gitDefaultBranch, branches });
30+
} catch (error) {
31+
log.debug('Failed to resolve project base ref, using detected base ref', {
32+
detectedBaseRef,
33+
error,
34+
});
35+
}
36+
37+
return detectedBaseRef;
38+
}
39+
1840
async function ensureGitRepository(
1941
git: GitService,
2042
initGitRepository?: boolean
@@ -53,6 +75,7 @@ export async function createLocalProject(params: CreateLocalProjectParams): Prom
5375
const authCtx = new GitHubAuthExecutionContext(baseCtx, () => githubConnectionService.getToken());
5476
const git = new GitService(baseCtx, authCtx, fs);
5577
const gitInfo = await ensureGitRepository(git, params.initGitRepository);
78+
const baseRef = await resolveProjectBaseRef(git, gitInfo.baseRef);
5679

5780
const [row] = await db
5881
.insert(projects)
@@ -61,7 +84,7 @@ export async function createLocalProject(params: CreateLocalProjectParams): Prom
6184
name: params.name,
6285
path: gitInfo.rootPath,
6386
workspaceProvider: 'local',
64-
baseRef: gitInfo.baseRef,
87+
baseRef,
6588
updatedAt: sql`CURRENT_TIMESTAMP`,
6689
})
6790
.returning();
@@ -71,7 +94,7 @@ export async function createLocalProject(params: CreateLocalProjectParams): Prom
7194
id: row.id,
7295
name: row.name,
7396
path: row.path,
74-
baseRef: row.baseRef ?? gitInfo.baseRef,
97+
baseRef: row.baseRef ?? baseRef,
7598
createdAt: row.createdAt,
7699
updatedAt: row.updatedAt,
77100
};
@@ -119,6 +142,7 @@ export async function createSshProject(params: CreateSshProjectParams): Promise<
119142
const git = new GitService(baseSshCtx, authSshCtx, sshFs);
120143

121144
const gitInfo = await ensureGitRepository(git, params.initGitRepository);
145+
const baseRef = await resolveProjectBaseRef(git, gitInfo.baseRef);
122146

123147
const [row] = await db
124148
.insert(projects)
@@ -128,7 +152,7 @@ export async function createSshProject(params: CreateSshProjectParams): Promise<
128152
path: gitInfo.rootPath,
129153
workspaceProvider: 'ssh',
130154
sshConnectionId: params.connectionId,
131-
baseRef: gitInfo.baseRef,
155+
baseRef,
132156
updatedAt: sql`CURRENT_TIMESTAMP`,
133157
})
134158
.returning();
@@ -139,7 +163,7 @@ export async function createSshProject(params: CreateSshProjectParams): Promise<
139163
name: row.name,
140164
path: row.path,
141165
connectionId: params.connectionId,
142-
baseRef: row.baseRef ?? gitInfo.baseRef,
166+
baseRef: row.baseRef ?? baseRef,
143167
createdAt: row.createdAt,
144168
updatedAt: row.updatedAt,
145169
};

src/renderer/features/projects/stores/repository-store.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { computed, makeObservable, reaction } from 'mobx';
22
import { gitRefChangedChannel, type GitRefChange } from '@shared/events/gitEvents';
33
import type {
4+
Branch,
45
LocalBranch,
56
LocalBranchesPayload,
67
Remote,
78
RemoteBranch,
89
RemoteBranchesPayload,
910
} from '@shared/git';
10-
import { bareRefName, computeDefaultBranch, selectPreferredRemote } from '@shared/git-utils';
11+
import { resolveDefaultBranch, selectPreferredRemote } from '@shared/git-utils';
1112
import { parseGitHubRepository } from '@shared/github-repository';
1213
import { events, rpc } from '@renderer/lib/ipc';
1314
import { Resource } from '@renderer/lib/stores/resource';
@@ -71,14 +72,14 @@ export class RepositoryStore {
7172
() => this.remoteData.invalidate()
7273
);
7374

74-
makeObservable(this, {
75+
makeObservable<this, 'defaultBranchPreference'>(this, {
7576
isUnborn: computed,
7677
currentBranch: computed,
7778
branches: computed,
7879
localBranches: computed,
7980
remoteBranches: computed,
8081
configuredRemote: computed,
81-
defaultBranchName: computed,
82+
defaultBranchPreference: computed,
8283
defaultBranch: computed,
8384
remotes: computed,
8485
loading: computed,
@@ -140,38 +141,35 @@ export class RepositoryStore {
140141
return parseGitHubRepository(url)?.repositoryUrl ?? null;
141142
}
142143

143-
get defaultBranchName(): string {
144-
const d = this.remoteData.data;
145-
if (!d) return 'main';
144+
private get defaultBranchPreference(): Branch | undefined {
146145
const raw = this.settingsStore.settings?.defaultBranch;
147-
const configured =
148-
raw === undefined ? bareRefName(this.baseRef) : typeof raw === 'string' ? raw : raw.name;
149-
return computeDefaultBranch(
150-
configured,
151-
this.branches,
152-
this.configuredRemote.name,
153-
d.gitDefaultBranch
154-
);
146+
if (raw === undefined) return undefined;
147+
if (typeof raw === 'string') return { type: 'local', branch: raw };
148+
return { type: 'remote', branch: raw.name, remote: this.configuredRemote };
155149
}
156150

157151
get defaultBranch(): LocalBranch | RemoteBranch | undefined {
158-
const name = this.defaultBranchName;
159-
const raw = this.settingsStore.settings?.defaultBranch;
160-
const isRemotePref = typeof raw === 'object' && raw.remote === true;
161-
162-
if (isRemotePref) {
163-
const remote = this.remoteBranches.find(
164-
(b) => b.branch === name && b.remote.name === this.configuredRemote.name
165-
);
166-
if (remote) return remote;
167-
}
168-
const local = this.localBranches.find((b) => b.branch === name);
169-
if (local) return local;
170-
return this.remoteBranches.find((b) => b.branch === name);
152+
const d = this.remoteData.data;
153+
if (!d) return undefined;
154+
return resolveDefaultBranch({
155+
preference: this.defaultBranchPreference,
156+
branches: this.branches,
157+
configuredRemoteName: this.configuredRemote.name,
158+
gitDefaultBranch: d.gitDefaultBranch,
159+
baseRef: this.baseRef,
160+
});
171161
}
172162

173163
isDefault(branch: LocalBranch | RemoteBranch): boolean {
174-
return branch.branch === this.defaultBranchName;
164+
const defaultBranch = this.defaultBranch;
165+
if (!defaultBranch) return false;
166+
if (branch.type !== defaultBranch.type) return false;
167+
if (branch.type === 'remote' && defaultBranch.type === 'remote') {
168+
return (
169+
branch.branch === defaultBranch.branch && branch.remote.name === defaultBranch.remote.name
170+
);
171+
}
172+
return branch.branch === defaultBranch.branch;
175173
}
176174

177175
isBranchOnRemote(branchName: string): boolean {

0 commit comments

Comments
 (0)