Skip to content
Closed
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
260 changes: 218 additions & 42 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, statSync } from "node:fs";
import { access } from "node:fs/promises";
import { access, mkdir, rm } from "node:fs/promises";
import { basename, join } from "node:path";
import {
BRANCH_PREFIX_MODES,
Expand Down Expand Up @@ -191,6 +191,51 @@ async function ensureMainWorkspace(project: Project): Promise<void> {
}
}

/**
* Initializes a git repository, creates an initial commit, and returns the default branch name.
* Handles --initial-branch=main fallback for older Git versions and git config error detection.
*/
async function initGitRepo(
repoPath: string,
options?: { stageAll?: boolean; commitMessage?: string },
): Promise<string> {
const git = simpleGit(repoPath);

try {
await git.init(["--initial-branch=main"]);
} catch {
await git.init();
}

const message = options?.commitMessage ?? "Initial commit";

try {
if (options?.stageAll) {
await git.add(".");
await git.commit(message);
} else {
await git.raw(["commit", "--allow-empty", "-m", message]);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
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}`);
}

const branchSummary = await git.branch();
return branchSummary.current || "main";
}

// 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 @@ -577,48 +622,8 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
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 defaultBranch = await initGitRepo(input.path);
const project = upsertProject(input.path, defaultBranch);

// Auto-create main workspace if it doesn't exist
await ensureMainWorkspace(project);

track("project_opened", {
Expand Down Expand Up @@ -785,6 +790,177 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
}
}),

createEmptyRepo: publicProcedure
.input(
z.object({
name: z
.string()
.min(1)
.refine((val) => SAFE_REPO_NAME_REGEX.test(val), {
message:
"Name can only contain letters, numbers, dots, underscores, hyphens, and spaces",
}),
}),
)
.mutation(async ({ input }) => {
try {
const window = getWindow();
if (!window) {
return {
canceled: false as const,
success: false as const,
error: "No window available",
};
}

const result = await dialog.showOpenDialog(window, {
properties: ["openDirectory", "createDirectory"],
title: "Select Location for New Repository",
});

if (result.canceled || result.filePaths.length === 0) {
return { canceled: true as const, success: false as const };
}

const parentDir = result.filePaths[0];
const repoPath = join(parentDir, input.name);

if (existsSync(repoPath)) {
return {
canceled: false as const,
success: false as const,
error: `A folder named "${input.name}" already exists at this location.`,
};
}

await mkdir(repoPath, { recursive: true });

const defaultBranch = await initGitRepo(repoPath);
const project = upsertProject(repoPath, defaultBranch);
await ensureMainWorkspace(project);

track("project_opened", {
project_id: project.id,
method: "create_empty",
});

return {
canceled: false as const,
success: true as const,
project,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
canceled: false as const,
success: false as const,
error: `Failed to create repository: ${errorMessage}`,
};
}
}),
Comment on lines +806 to +861

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

No cleanup of created directory on failure.

If initGitRepo (Line 838) or upsertProject (Line 839) throws after mkdir (Line 836) succeeds, the empty directory is left on disk. The user sees an error but the stale folder will cause an "already exists" error on retry.

Consider wrapping the post-mkdir block in a try/catch that removes the directory on failure:

Proposed fix sketch
 					await mkdir(repoPath, { recursive: true });
 
+					try {
 					const defaultBranch = await initGitRepo(repoPath);
 					const project = upsertProject(repoPath, defaultBranch);
 					await ensureMainWorkspace(project);
 
 					track("project_opened", {
 						project_id: project.id,
 						method: "create_empty",
 					});
 
 					return {
 						canceled: false as const,
 						success: true as const,
 						project,
 					};
+					} catch (innerError) {
+						// Clean up partially-created directory
+						await rm(repoPath, { recursive: true, force: true }).catch(() => {});
+						throw innerError;
+					}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/projects/projects.ts` around lines 806 -
861, The try block creates repoPath with mkdir then calls initGitRepo and
upsertProject; if either throws the empty directory is left behind. Wrap the
post-mkdir sequence (calls to initGitRepo, upsertProject, ensureMainWorkspace,
track) in its own try/catch and on any error remove the newly created directory
(await fs.rm(repoPath, { recursive: true, force: true }) or equivalent) before
rethrowing or returning the failure result; use the existing existsSync check to
ensure you only remove directories you just created and reference repoPath,
mkdir, initGitRepo, upsertProject, and ensureMainWorkspace to locate the code to
change.


createFromTemplate: publicProcedure
.input(
z.object({
templateUrl: z
.string()
.min(1)
.refine(
(val) => {
try {
const parsed = new URL(val);
return ALLOWED_URL_PROTOCOLS.has(parsed.protocol);
} catch {
return SSH_GIT_URL_REGEX.test(val);
}
},
{ message: "Must be a valid Git URL (HTTPS or SSH)" },
),
name: z
.string()
.trim()
.optional()
.transform((v) => (v && v.length > 0 ? v : undefined)),
}),
)
.mutation(async ({ input }) => {
try {
const window = getWindow();
if (!window) {
return {
canceled: false as const,
success: false as const,
error: "No window available",
};
}

const result = await dialog.showOpenDialog(window, {
properties: ["openDirectory", "createDirectory"],
title: "Select Location for New Project",
});

if (result.canceled || result.filePaths.length === 0) {
return { canceled: true as const, success: false as const };
}

const parentDir = result.filePaths[0];
const repoName = input.name || extractRepoName(input.templateUrl);

if (!repoName) {
return {
canceled: false as const,
success: false as const,
error: "Could not determine project name from template URL",
};
}

const repoPath = join(parentDir, repoName);

if (existsSync(repoPath)) {
return {
canceled: false as const,
success: false as const,
error: `A folder named "${repoName}" already exists at this location.`,
};
}

// Clone the template repo (shallow), then strip its history
const git = simpleGit();
await git.clone(input.templateUrl, repoPath, ["--depth", "1"]);
await rm(join(repoPath, ".git"), {
recursive: true,
force: true,
});

const defaultBranch = await initGitRepo(repoPath, {
stageAll: true,
commitMessage: "Initial commit from template",
});
const project = upsertProject(repoPath, defaultBranch);
await ensureMainWorkspace(project);

track("project_opened", {
project_id: project.id,
method: "create_from_template",
});

return {
canceled: false as const,
success: true as const,
project,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
canceled: false as const,
success: false as const,
error: `Failed to create project from template: ${errorMessage}`,
};
}
}),
Comment on lines +928 to +962

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

Same cleanup concern applies to createFromTemplate.

If initGitRepo or upsertProject fails after the clone + .git removal (Lines 930-934), the directory is left in an inconsistent state (cloned files without git history). The same cleanup pattern would help here—remove repoPath on failure so the user can retry cleanly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/projects/projects.ts` around lines 928 -
962, The catch block for the create-from-template flow must clean up the
partially created directory to avoid leaving cloned files without git metadata;
update the error handling in the createFromTemplate flow so that if any of
initGitRepo or upsertProject (or other steps after clone and removing .git)
throw, you attempt to remove repoPath (use rm(repoPath, { recursive: true,
force: true }) and await it, swallowing any rm errors) before returning the
failure object; reference the cloned repoPath and the functions initGitRepo and
upsertProject when implementing the cleanup.


update: publicProcedure
.input(
z.object({
Expand Down

This file was deleted.

Loading
Loading