Skip to content
Open
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
35 changes: 35 additions & 0 deletions backend/src/services/file-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,38 @@ export async function listDirectory(dirPath: string): Promise<Array<{
throw new Error(`Failed to list directory ${dirPath}: ${error}`)
}
}

export async function directoryExists(dirPath: string): Promise<boolean> {
try {
const fullPath = path.isAbsolute(dirPath) ? dirPath : path.join(getReposPath(), dirPath)
const stats = await fs.stat(fullPath)
return stats.isDirectory()
} catch {
return false
}
}
Comment on lines +125 to +133
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

The directoryExists function's automatic path resolution behavior (prepending getReposPath() for relative paths) is inconsistent with how callers use it. All callers in repo.ts already construct absolute paths using path.join(getReposPath(), ...) before passing them to this function, making the internal path resolution redundant. This could lead to confusion or bugs if a caller passes a relative path expecting different behavior. Consider either: (1) removing the automatic path resolution and requiring absolute paths, or (2) documenting this behavior clearly and updating callers to pass relative paths directly.

Copilot uses AI. Check for mistakes.

export async function removeDirectory(dirPath: string): Promise<void> {
try {
const fullPath = path.isAbsolute(dirPath) ? dirPath : path.join(getReposPath(), dirPath)
await fs.rm(fullPath, { recursive: true, force: true })
} catch (error) {
throw new Error(`Failed to remove directory ${dirPath}: ${error}`)
}
}
Comment on lines +135 to +142
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

Same path resolution issue as directoryExists. All callers construct absolute paths before calling this function (e.g., removeDirectory(path.join(getReposPath(), dirName))), making the internal path resolution logic redundant and potentially confusing. Consider standardizing the approach across all file operation functions.

Copilot uses AI. Check for mistakes.

export async function listDirectoryNames(dirPath: string): Promise<string[]> {
try {
const fullPath = path.isAbsolute(dirPath) ? dirPath : path.join(getReposPath(), dirPath)
const entries = await fs.readdir(fullPath, { withFileTypes: true })
const directories: string[] = []
for (const entry of entries) {
if (entry.isDirectory()) {
directories.push(entry.name)
}
}
return directories
} catch {
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

The error handling silently returns an empty array on any error, which could mask actual problems (e.g., permission issues, invalid paths). This differs from removeDirectory which throws errors. Consider either throwing errors like the other functions, or at minimum logging the error before returning an empty array so failures aren't completely silent.

Suggested change
} catch {
} catch (error) {
logger.error(`Failed to list directory names for ${dirPath}:`, error)

Copilot uses AI. Check for mistakes.
return []
}
}
91 changes: 43 additions & 48 deletions backend/src/services/repo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { executeCommand } from '../utils/process'
import { ensureDirectoryExists } from './file-operations'
import { ensureDirectoryExists, directoryExists, removeDirectory, listDirectoryNames } from './file-operations'
import * as db from '../db/queries'
import type { Database } from 'bun:sqlite'
import type { Repo, CreateRepoInput } from '../types/repo'
Expand Down Expand Up @@ -88,12 +88,7 @@ async function isValidGitRepo(repoPath: string): Promise<boolean> {
async function checkRepoNameAvailable(name: string): Promise<boolean> {
const reposPath = getReposPath()
const targetPath = path.join(reposPath, name)
try {
await executeCommand(['test', '-e', targetPath], { silent: true })
return false
} catch {
return true
}
return !(await directoryExists(targetPath))
}

async function copyRepoToWorkspace(sourcePath: string, targetName: string): Promise<void> {
Expand Down Expand Up @@ -186,9 +181,7 @@ export async function initLocalRepo(
logger.info(`Absolute path detected: ${normalizedInputPath}`)

try {
const exists = await executeCommand(['test', '-d', normalizedInputPath], { silent: true })
.then(() => true)
.catch(() => false)
const exists = await directoryExists(normalizedInputPath)

if (!exists) {
throw new Error(`No such file or directory: '${normalizedInputPath}'`)
Expand Down Expand Up @@ -296,14 +289,14 @@ export async function initLocalRepo(

if (directoryCreated && !sourceWasGitRepo) {
try {
await executeCommand(['rm', '-rf', repoLocalPath], getReposPath())
await removeDirectory(path.join(getReposPath(), repoLocalPath))
logger.info(`Rolled back directory: ${repoLocalPath}`)
} catch (fsError: any) {
logger.error(`Failed to rollback directory ${repoLocalPath}:`, fsError)
}
} else if (sourceWasGitRepo) {
try {
await executeCommand(['rm', '-rf', repoLocalPath], getReposPath())
await removeDirectory(path.join(getReposPath(), repoLocalPath))
logger.info(`Cleaned up copied directory: ${repoLocalPath}`)
} catch (fsError: any) {
logger.error(`Failed to clean up copied directory ${repoLocalPath}:`, fsError)
Expand Down Expand Up @@ -333,9 +326,9 @@ export async function cloneRepo(
}

await ensureDirectoryExists(getReposPath())
const baseRepoExists = await executeCommand(['bash', '-c', `test -d ${baseRepoDirName} && echo exists || echo missing`], path.resolve(getReposPath()))
const baseRepoExists = await directoryExists(path.join(getReposPath(), baseRepoDirName))

const shouldUseWorktree = useWorktree && branch && baseRepoExists.trim() === 'exists'
const shouldUseWorktree = useWorktree && branch && baseRepoExists

const createRepoInput: CreateRepoInput = {
repoUrl: normalizedRepoUrl,
Expand Down Expand Up @@ -366,26 +359,24 @@ export async function cloneRepo(

await createWorktreeSafely(baseRepoPath, worktreePath, branch)

const worktreeVerified = await executeCommand(['test', '-d', worktreePath])
.then(() => true)
.catch(() => false)
const worktreeVerified = await directoryExists(worktreePath)

if (!worktreeVerified) {
throw new Error(`Worktree directory was not created at: ${worktreePath}`)
}

logger.info(`Worktree verified at: ${worktreePath}`)

} else if (branch && baseRepoExists.trim() === 'exists' && useWorktree) {
} else if (branch && baseRepoExists && useWorktree) {
logger.info(`Base repo exists but worktree creation failed, cloning branch separately`)

const worktreeExists = await executeCommand(['bash', '-c', `test -d ${worktreeDirName} && echo exists || echo missing`], path.resolve(getReposPath()))
if (worktreeExists.trim() === 'exists') {
const worktreeExists = await directoryExists(path.join(getReposPath(), worktreeDirName))
if (worktreeExists) {
logger.info(`Workspace directory exists, removing it: ${worktreeDirName}`)
try {
await executeCommand(['rm', '-rf', worktreeDirName], getReposPath())
const verifyRemoved = await executeCommand(['bash', '-c', `test -d ${worktreeDirName} && echo exists || echo removed`], getReposPath())
if (verifyRemoved.trim() === 'exists') {
await removeDirectory(path.join(getReposPath(), worktreeDirName))
const verifyRemoved = !(await directoryExists(path.join(getReposPath(), worktreeDirName)))
if (!verifyRemoved) {
throw new Error(`Failed to remove existing directory: ${worktreeDirName}`)
}
} catch (cleanupError: any) {
Expand All @@ -402,27 +393,32 @@ export async function cloneRepo(
throw new Error(`Workspace directory ${worktreeDirName} already exists. Please delete it manually or contact support.`)
}

logger.info(`Branch '${branch}' not found during clone, cloning default branch and creating branch locally`)
await executeGitWithFallback(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env })
let localBranchExists = 'missing'
try {
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'rev-parse', '--verify', `refs/heads/${branch}`])
localBranchExists = 'exists'
} catch {
localBranchExists = 'missing'
}
if (localBranchExists.trim() === 'missing') {
if (branch && (error.message.includes('Remote branch') || error.message.includes('not found'))) {
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

The condition checks if branch exists before entering the error handling block, but branch is already guaranteed to be truthy at this point in the code flow (line 370 condition is branch && baseRepoExists && useWorktree). The redundant check reduces code clarity.

Suggested change
if (branch && (error.message.includes('Remote branch') || error.message.includes('not found'))) {
if (error.message.includes('Remote branch') || error.message.includes('not found')) {

Copilot uses AI. Check for mistakes.
logger.info(`Branch '${branch}' not found, cloning default branch and creating branch locally`)
await executeGitWithFallback(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env })
let localBranchExists = 'missing'
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

The initial value of localBranchExists is unused, since it is always overwritten.

Suggested change
let localBranchExists = 'missing'
let localBranchExists: 'exists' | 'missing'

Copilot uses AI. Check for mistakes.
try {
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'rev-parse', '--verify', `refs/heads/${branch}`])
localBranchExists = 'exists'
} catch {
localBranchExists = 'missing'
}

if (localBranchExists === 'missing') {
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

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

The changes from string-based existence checks ('exists'/'missing') to boolean checks are correct, but the logic at lines 399-411 for handling local branch creation when a remote branch is not found lacks test coverage. This is a critical code path for branch creation fallback that should be tested to ensure the Windows compatibility fix works correctly in this scenario.

Copilot uses AI. Check for mistakes.
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', '-b', branch])
} else {
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', branch])
}
} else {
throw error
}
}
} else {
if (baseRepoExists.trim() === 'exists') {
if (baseRepoExists) {
logger.info(`Repository directory already exists, verifying it's a valid git repo: ${baseRepoDirName}`)
const isValidRepo = await executeCommand(['git', '-C', path.resolve(getReposPath(), baseRepoDirName), 'rev-parse', '--git-dir'], path.resolve(getReposPath())).then(() => 'valid').catch(() => 'invalid')

if (isValidRepo.trim() === 'valid') {
if (isValidRepo === 'valid') {
logger.info(`Valid repository found: ${normalizedRepoUrl}`)

if (branch) {
Expand Down Expand Up @@ -462,19 +458,19 @@ export async function cloneRepo(
return { ...repo, cloneStatus: 'ready' }
} else {
logger.warn(`Invalid repository directory found, removing and recloning: ${baseRepoDirName}`)
await executeCommand(['rm', '-rf', baseRepoDirName], getReposPath())
await removeDirectory(path.join(getReposPath(), baseRepoDirName))
}
}

logger.info(`Cloning repo: ${normalizedRepoUrl}${branch ? ` to branch ${branch}` : ''}`)

const worktreeExists = await executeCommand(['bash', '-c', `test -d ${worktreeDirName} && echo exists || echo missing`], getReposPath())
if (worktreeExists.trim() === 'exists') {
const worktreeExists = await directoryExists(path.join(getReposPath(), worktreeDirName))
if (worktreeExists) {
logger.info(`Workspace directory exists, removing it: ${worktreeDirName}`)
try {
await executeCommand(['rm', '-rf', worktreeDirName], getReposPath())
const verifyRemoved = await executeCommand(['bash', '-c', `test -d ${worktreeDirName} && echo exists || echo removed`], getReposPath())
if (verifyRemoved.trim() === 'exists') {
await removeDirectory(path.join(getReposPath(), worktreeDirName))
const verifyRemoved = !(await directoryExists(path.join(getReposPath(), worktreeDirName)))
if (!verifyRemoved) {
throw new Error(`Failed to remove existing directory: ${worktreeDirName}`)
}
} catch (cleanupError: any) {
Expand Down Expand Up @@ -506,7 +502,7 @@ export async function cloneRepo(
localBranchExists = 'missing'
}

if (localBranchExists.trim() === 'missing') {
if (localBranchExists === 'missing') {
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', '-b', branch])
} else {
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', branch])
Expand Down Expand Up @@ -704,10 +700,10 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi

// Remove the directory
logger.info(`Removing directory: ${dirName} from ${getReposPath()}`)
await executeCommand(['rm', '-rf', dirName], getReposPath())
await removeDirectory(path.join(getReposPath(), dirName))

const checkExists = await executeCommand(['bash', '-c', `test -d ${dirName} && echo exists || echo deleted`], getReposPath())
if (checkExists.trim() === 'exists') {
const checkExists = await directoryExists(path.join(getReposPath(), dirName))
if (checkExists) {
logger.error(`Directory still exists after deletion: ${dirName}`)
throw new Error(`Failed to delete workspace directory: ${dirName}`)
}
Expand Down Expand Up @@ -764,8 +760,7 @@ export async function cleanupOrphanedDirectories(database: Database): Promise<vo
const reposPath = getReposPath()
await ensureDirectoryExists(reposPath)

const dirResult = await executeCommand(['ls', '-1'], reposPath).catch(() => '')
const directories = dirResult.split('\n').filter(d => d.trim())
const directories = await listDirectoryNames(reposPath)

if (directories.length === 0) {
return
Expand All @@ -782,7 +777,7 @@ export async function cleanupOrphanedDirectories(database: Database): Promise<vo
for (const dir of orphanedDirs) {
try {
logger.info(`Removing orphaned directory: ${dir}`)
await executeCommand(['rm', '-rf', dir], reposPath)
await removeDirectory(path.join(reposPath, dir))
} catch (error) {
logger.warn(`Failed to remove orphaned directory ${dir}:`, error)
}
Expand Down
Loading