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
147 changes: 113 additions & 34 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,63 @@ import { publicProcedure, router } from "../..";
import { getDefaultBranch, getGitRoot } from "../workspaces/utils/git";
import { assignRandomColor } from "./utils/colors";

// Return types for openNew procedure
type OpenNewCanceled = { canceled: true };
type OpenNewSuccess = { canceled: false; project: Project };
type OpenNewNeedsGitInit = {
canceled: false;
needsGitInit: true;
selectedPath: string;
};
type OpenNewError = { canceled: false; error: string };
export type OpenNewResult =
| OpenNewCanceled
| OpenNewSuccess
| OpenNewNeedsGitInit
| OpenNewError;

/**
* Creates or updates a project record in the database.
* If a project with the same mainRepoPath exists, updates lastOpenedAt.
* Otherwise, creates a new project.
*/
async function upsertProject(
mainRepoPath: string,
defaultBranch: string,
): Promise<Project> {
const name = basename(mainRepoPath);

let project = db.data.projects.find((p) => p.mainRepoPath === mainRepoPath);

if (project) {
await db.update((data) => {
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
p.defaultBranch = defaultBranch;
}
});
} else {
project = {
id: nanoid(),
mainRepoPath,
name,
color: assignRandomColor(),
tabOrder: null,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
defaultBranch,
};

await db.update((data) => {
// biome-ignore lint/style/noNonNullAssertion: project is assigned above, TypeScript can't see it inside callback
data.projects.push(project!);
});
}

return project;
}
Comment on lines +36 to +71
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

Potential stale reference when returning updated project.

When updating an existing project, the function modifies the database but returns the original project reference from line 42, which won't reflect the updated lastOpenedAt and defaultBranch values.

Consider re-fetching the project after update or constructing the return value with updated fields:

 if (project) {
   await db.update((data) => {
     const p = data.projects.find((p) => p.id === project?.id);
     if (p) {
       p.lastOpenedAt = Date.now();
       p.defaultBranch = defaultBranch;
     }
   });
+  // Re-fetch to get updated values
+  project = db.data.projects.find((p) => p.mainRepoPath === mainRepoPath);
 }
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/projects/projects.ts around lines 36 to 71,
when updating an existing project the function currently returns the stale local
`project` reference that doesn't include the updated `lastOpenedAt` and
`defaultBranch`; re-fetch the project from `db.data.projects` after the `await
db.update(...)` completes (or build a new object merging the original `project`
with the updated fields) and return that up-to-date object so the caller
receives the updated values.


// Safe filename regex: letters, numbers, dots, underscores, hyphens, spaces, and common unicode
// Allows most valid Git repo names while avoiding path traversal characters
const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/;
Expand Down Expand Up @@ -94,7 +151,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),

openNew: publicProcedure.mutation(async () => {
openNew: publicProcedure.mutation(async (): Promise<OpenNewResult> => {
const window = getWindow();
if (!window) {
return { canceled: false, error: "No window available" };
Expand All @@ -114,48 +171,70 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
try {
mainRepoPath = await getGitRoot(selectedPath);
} catch (_error) {
throw new Error("Selected folder is not in a git repository");
}

const name = basename(mainRepoPath);

let project = db.data.projects.find(
(p) => p.mainRepoPath === mainRepoPath,
);

if (project) {
await db.update((data) => {
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
}
});
} else {
const defaultBranch = await getDefaultBranch(mainRepoPath);

project = {
id: nanoid(),
mainRepoPath,
name,
color: assignRandomColor(),
tabOrder: null,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
defaultBranch,
// Return a special response so the UI can offer to initialize git
return {
canceled: false,
needsGitInit: true,
selectedPath,
};

await db.update((data) => {
// biome-ignore lint/style/noNonNullAssertion: project is assigned above, TypeScript can't see it inside callback
data.projects.push(project!);
});
}

const defaultBranch = await getDefaultBranch(mainRepoPath);
const project = await upsertProject(mainRepoPath, defaultBranch);

return {
canceled: false,
project,
};
}),

initGitAndOpen: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {
const git = simpleGit(input.path);

// Initialize git repository with 'main' as default branch
// Try with --initial-branch=main (Git 2.28+), fall back to plain init
try {
await git.init(["--initial-branch=main"]);
} catch (err) {
// Likely an older Git version that doesn't support --initial-branch
console.warn(
"Git init with --initial-branch failed, using fallback:",
err,
);
await git.init();
}

// Create initial commit so we have a valid branch ref
try {
await git.raw(["commit", "--allow-empty", "-m", "Initial commit"]);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
// Check for common git config issues
if (
errorMessage.includes("empty ident") ||
errorMessage.includes("user.email") ||
errorMessage.includes("user.name")
) {
throw new Error(
"Git user not configured. Please run:\n" +
' git config --global user.name "Your Name"\n' +
' git config --global user.email "you@example.com"',
);
}
throw new Error(`Failed to create initial commit: ${errorMessage}`);
}

// Get the current branch name (will be 'main' or 'master' depending on git version/config)
const branchSummary = await git.branch();
const defaultBranch = branchSummary.current || "main";

const project = await upsertProject(input.path, defaultBranch);

return { project };
}),

cloneRepo: publicProcedure
.input(
z.object({
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,19 @@ export async function worktreeExists(
}
}

/**
* Checks if the repository has an 'origin' remote configured
*/
export async function hasOriginRemote(mainRepoPath: string): Promise<boolean> {
try {
const git = simpleGit(mainRepoPath);
const remotes = await git.getRemotes();
return remotes.some((r) => r.name === "origin");
} catch {
return false;
}
}

/**
* Detects the default branch of a repository by checking:
* 1. Remote HEAD reference (origin/HEAD -> origin/main or origin/master)
Expand Down
24 changes: 18 additions & 6 deletions apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
fetchDefaultBranch,
generateBranchName,
getDefaultBranch,
hasOriginRemote,
removeWorktree,
worktreeExists,
} from "./utils/git";
Expand Down Expand Up @@ -56,18 +57,29 @@ export const createWorkspacesRouter = () => {
});
}

// Fetch default branch to ensure we're branching from latest (best-effort)
try {
await fetchDefaultBranch(project.mainRepoPath, defaultBranch);
} catch {
// Silently continue - branch still exists locally, just might be stale
// Check if this repo has a remote origin
const hasRemote = await hasOriginRemote(project.mainRepoPath);

// Determine the start point for the worktree
let startPoint: string;
if (hasRemote) {
// Fetch default branch to ensure we're branching from latest (best-effort)
try {
await fetchDefaultBranch(project.mainRepoPath, defaultBranch);
} catch {
// Silently continue - branch still exists locally, just might be stale
}
startPoint = `origin/${defaultBranch}`;
} else {
// For local-only repos, use the local default branch
startPoint = defaultBranch;
}

await createWorktree(
project.mainRepoPath,
branch,
worktreePath,
`origin/${defaultBranch}`,
startPoint,
);

const worktree = {
Expand Down
Loading