Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/desktop/docs/BRANCH_WORKSPACE_IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Branch Workspace Improvements

Potential improvements identified during code review. Create Linear tickets as needed.

## Medium Priority

### 1. Cache `hasOrigin` at project level
**Location**: `workspaces.ts:460`, `projects.ts`

Currently `hasOriginRemote()` is called on every `getActive` poll until base branch detection completes. Cache this at the project level in the database to avoid repeated git calls.

### 2. Race condition in worktree creation
**Location**: `workspaces.ts:78-86`

The branch existence check and the actual worktree creation are not atomic. If a branch is deleted between the check and use, it fails with a generic git error. Consider:
- Wrapping in a retry with exponential backoff
- Catching the specific git error and providing a clear message

### 3. `getDefaultBranch` for local repos uses current branch
**Location**: `git.ts:287-306`

For repos without a remote, `getDefaultBranch` returns the current branch. If the user is on a feature branch, that becomes the "default" for new worktrees, which may not be intended. Consider always preferring `main`/`master` if they exist locally.

### 4. Disable Create button when no branches available
**Location**: `NewWorkspaceModal.tsx`

If `getBranches` returns an empty array (e.g., new repo with no commits), the Create button is still enabled. Should be disabled with a helpful message.
Comment on lines +5 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix style issues in Medium Priority section.

Lines 27 and 44 contain incomplete sentences missing subjects. Additionally, clarify whether any of these medium-priority items are blocking the current PR or are intended as post-release improvements.

🔎 Proposed style fixes
- If `getBranches` returns an empty array (e.g., new repo with no commits), the Create button is still enabled. Should be disabled with a helpful message.
+ If `getBranches` returns an empty array (e.g., new repo with no commits), the Create button is still enabled and should be disabled with a helpful message.

- `localBranches` and `remoteBranches` arrays are always built but only used in the fallback path. Could be computed lazily for minor perf improvement.
+ `localBranches` and `remoteBranches` arrays are always built but only used in the fallback path and could be computed lazily for minor perf improvement.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 LanguageTool

[style] ~27-~27: To form a complete sentence, be sure to include a subject.
Context: ...s), the Create button is still enabled. Should be disabled with a helpful message. ##...

(MISSING_IT_THERE)

🤖 Prompt for AI Agents
In apps/desktop/docs/BRANCH_WORKSPACE_IMPROVEMENTS.md around lines 5-27 (Medium
Priority section) there are style issues: complete the incomplete sentences at
lines 27 and 44 by adding clear subjects and verbs so each bullet reads as a
full sentence, remove or populate the empty "Proposed style fixes" details
block, and add one explicit sentence in the Medium Priority intro clarifying
whether these items are blocking the current PR or are intended as post-release
improvements.


## Low Priority

### 5. Rename `fetch` parameter to `gitFetch`
**Location**: `workspaces.ts` - `getBranches` procedure

The `fetch` parameter controls whether to run `git fetch`, but could be confused with React Query's fetch behavior. Rename to `gitFetch` or `refreshRemote` for clarity.

### 6. Extract magic numbers to constants
**Location**: `WorkspaceHeader.tsx`

Values like `max-w-[480px]`, `h-[22px]`, `max-w-[180px]` should be design tokens or named constants.

### 7. Lazy compute branch arrays
**Location**: `projects.ts:185-196`

`localBranches` and `remoteBranches` arrays are always built but only used in the fallback path. Could be computed lazily for minor perf improvement.

### 8. Add loading skeleton to base branch picker
**Location**: `NewWorkspaceModal.tsx`

Currently shows a disabled button while branches load. A skeleton would feel more polished.

### 9. Optimistic UI for branch switching
**Location**: `BranchSwitcher.tsx`

The `switchBranchWorkspace` mutation could use optimistic updates to feel snappier.

## Testing

### 10. Add unit tests for git utilities
**Location**: `git.ts`

Functions like `getDefaultBranch`, `branchExistsOnRemote`, `detectBaseBranch`, and `checkBranchCheckoutSafety` have complex logic that would benefit from unit tests with mocked `simple-git`.

Priority candidates:
- `getDefaultBranch` - many code paths
- `detectBaseBranch` - merge-base logic
- `checkBranchCheckoutSafety` - safety checks before checkout
105 changes: 105 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,111 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),

getBranches: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(
async ({
input,
}): Promise<{
branches: Array<{ name: string; lastCommitDate: number }>;
defaultBranch: string;
}> => {
const project = db.data.projects.find(
(p) => p.id === input.projectId,
);
if (!project) {
throw new Error(`Project ${input.projectId} not found`);
}

const git = simpleGit(project.mainRepoPath);

// Check if origin remote exists
let hasOrigin = false;
try {
const remotes = await git.getRemotes();
hasOrigin = remotes.some((r) => r.name === "origin");
} catch {
// If we can't get remotes, assume no origin
}

// Get all branches (local and remote)
const branchSummary = await git.branch(["-a"]);

const localBranches: string[] = [];
const remoteBranches: string[] = [];

for (const name of Object.keys(branchSummary.branches)) {
if (name.startsWith("remotes/origin/")) {
if (name === "remotes/origin/HEAD") continue;
const remoteName = name.replace("remotes/origin/", "");
remoteBranches.push(remoteName);
} else {
localBranches.push(name);
}
}

// Get branch dates for sorting
let branches: Array<{ name: string; lastCommitDate: number }> = [];

// Determine which ref pattern to use based on whether origin exists
const refPattern = hasOrigin ? "refs/remotes/origin/" : "refs/heads/";

try {
const branchInfo = await git.raw([
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname:short) %(committerdate:unix)",
refPattern,
]);

const seen = new Set<string>();
for (const line of branchInfo.trim().split("\n")) {
if (!line) continue;
const lastSpaceIdx = line.lastIndexOf(" ");
let branch = line.substring(0, lastSpaceIdx);
const timestamp = Number.parseInt(
line.substring(lastSpaceIdx + 1),
10,
);

// Normalize remote branch names
if (branch.startsWith("origin/")) {
branch = branch.replace("origin/", "");
}

// Skip duplicates and HEAD
if (seen.has(branch)) continue;
if (branch === "HEAD") continue;
seen.add(branch);

branches.push({
name: branch,
lastCommitDate: timestamp * 1000,
});
}
} catch {
// Fallback: just list branches without dates
const branchList = hasOrigin ? remoteBranches : localBranches;
branches = branchList.map((name) => ({ name, lastCommitDate: 0 }));
}

// Determine default branch
let defaultBranch = project.defaultBranch;
if (!defaultBranch) {
defaultBranch = await getDefaultBranch(project.mainRepoPath);
}

// Sort: default branch first, then by date
branches.sort((a, b) => {
if (a.name === defaultBranch) return -1;
if (b.name === defaultBranch) return 1;
return b.lastCommitDate - a.lastCommitDate;
});

return { branches, defaultBranch };
},
),

openNew: publicProcedure.mutation(async (): Promise<OpenNewResult> => {
const window = getWindow();
if (!window) {
Expand Down
118 changes: 100 additions & 18 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,33 +248,63 @@ export async function hasOriginRemote(mainRepoPath: string): Promise<boolean> {
export async function getDefaultBranch(mainRepoPath: string): Promise<string> {
const git = simpleGit(mainRepoPath);

try {
const headRef = await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]);
const match = headRef.trim().match(/refs\/remotes\/origin\/(.+)/);
if (match) return match[1];
} catch {}
// First check if we have an origin remote
const hasRemote = await hasOriginRemote(mainRepoPath);

try {
const branches = await git.branch(["-r"]);
const remoteBranches = branches.all.map((b) => b.replace("origin/", ""));
if (hasRemote) {
// Try to get the default branch from origin/HEAD
try {
const headRef = await git.raw([
"symbolic-ref",
"refs/remotes/origin/HEAD",
]);
const match = headRef.trim().match(/refs\/remotes\/origin\/(.+)/);
if (match) return match[1];
} catch {}

for (const candidate of ["main", "master", "develop", "trunk"]) {
if (remoteBranches.includes(candidate)) {
return candidate;
// Check remote branches for common default branch names
try {
const branches = await git.branch(["-r"]);
const remoteBranches = branches.all.map((b) => b.replace("origin/", ""));

for (const candidate of ["main", "master", "develop", "trunk"]) {
if (remoteBranches.includes(candidate)) {
return candidate;
}
}
}
} catch {}
} catch {}

try {
const hasRemote = await hasOriginRemote(mainRepoPath);
if (hasRemote) {
// Try ls-remote as last resort for remote repos
try {
const result = await git.raw(["ls-remote", "--symref", "origin", "HEAD"]);
const symrefMatch = result.match(/ref:\s+refs\/heads\/(.+?)\tHEAD/);
if (symrefMatch) {
return symrefMatch[1];
}
}
} catch {}
} catch {}
} else {
// No remote - use the current local branch or check for common branch names
try {
const currentBranch = await getCurrentBranch(mainRepoPath);
if (currentBranch) {
return currentBranch;
}
} catch {}

// Fallback: check for common default branch names locally
try {
const localBranches = await git.branchLocal();
for (const candidate of ["main", "master", "develop", "trunk"]) {
if (localBranches.all.includes(candidate)) {
return candidate;
}
}
// If we have any local branches, use the first one
if (localBranches.all.length > 0) {
return localBranches.all[0];
}
} catch {}
}

return "main";
}
Expand Down Expand Up @@ -359,6 +389,58 @@ export async function branchExistsOnRemote(
}
}

/**
* Detect which branch a worktree was likely based off of.
* Uses merge-base to find the closest common ancestor with candidate base branches.
*/
export async function detectBaseBranch(
worktreePath: string,
currentBranch: string,
defaultBranch: string,
): Promise<string | null> {
const git = simpleGit(worktreePath);

// Candidate base branches to check, in priority order
const candidates = [
defaultBranch,
"main",
"master",
"develop",
"development",
].filter((b, i, arr) => arr.indexOf(b) === i); // dedupe

let bestCandidate: string | null = null;
let bestAheadCount = Number.POSITIVE_INFINITY;

for (const candidate of candidates) {
// Skip if this is the current branch
if (candidate === currentBranch) continue;

try {
// Check if the remote branch exists
const remoteBranch = `origin/${candidate}`;
await git.raw(["rev-parse", "--verify", remoteBranch]);

// Count how many commits the current branch is ahead of the merge-base
// The branch with the fewest commits "ahead" is likely the base
const mergeBase = await git.raw(["merge-base", "HEAD", remoteBranch]);
const aheadCount = await git.raw([
"rev-list",
"--count",
`${mergeBase.trim()}..HEAD`,
]);

const count = Number.parseInt(aheadCount.trim(), 10);
if (count < bestAheadCount) {
bestAheadCount = count;
bestCandidate = candidate;
}
} catch {}
}

return bestCandidate;
}

/**
* Lists all local and remote branches in a repository
* @param repoPath - Path to the repository
Expand Down
Loading