Skip to content

Commit f0d97f8

Browse files
Fix issue #42: Support absolute paths when adding local repos (#52)
Add ability to import existing git repositories from absolute paths by copying them to the workspace. Backend changes: - Add helper functions: isValidGitRepo(), checkRepoNameAvailable(), copyRepoToWorkspace() - Add checkoutBranchSafely() helper for consistent branch handling across codebase - Modify initLocalRepo() to detect absolute vs relative paths - For absolute paths: validate, check if git repo, copy to workspace using git clone --local - For relative paths: maintain existing behavior (create new empty git repo) - Refactor switchBranch() to use shared checkoutBranchSafely() helper - Fail with clear error message if repo name already exists in workspace Frontend changes: - Update UI placeholder and help text to explain absolute path support - Branch help text adapts based on input type (absolute vs relative)
1 parent 3ab55fc commit f0d97f8

3 files changed

Lines changed: 383 additions & 69 deletions

File tree

backend/src/services/repo.ts

Lines changed: 152 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,35 @@ async function hasCommits(repoPath: string): Promise<boolean> {
7676
}
7777
}
7878

79+
async function isValidGitRepo(repoPath: string): Promise<boolean> {
80+
try {
81+
await executeCommand(['git', '-C', repoPath, 'rev-parse', '--git-dir'], { silent: true })
82+
return true
83+
} catch {
84+
return false
85+
}
86+
}
87+
88+
async function checkRepoNameAvailable(name: string): Promise<boolean> {
89+
const reposPath = getReposPath()
90+
const targetPath = path.join(reposPath, name)
91+
try {
92+
await executeCommand(['test', '-e', targetPath], { silent: true })
93+
return false
94+
} catch {
95+
return true
96+
}
97+
}
98+
99+
async function copyRepoToWorkspace(sourcePath: string, targetName: string): Promise<void> {
100+
const reposPath = getReposPath()
101+
const targetPath = path.join(reposPath, targetName)
102+
103+
logger.info(`Copying repo from ${sourcePath} to ${targetPath}`)
104+
await executeCommand(['git', 'clone', '--local', sourcePath, targetName], { cwd: reposPath })
105+
logger.info(`Successfully copied repo to ${targetPath}`)
106+
}
107+
79108

80109

81110
async function safeGetCurrentBranch(repoPath: string): Promise<string | null> {
@@ -96,6 +125,40 @@ async function safeGetCurrentBranch(repoPath: string): Promise<string | null> {
96125
}
97126
}
98127

128+
async function checkoutBranchSafely(repoPath: string, branch: string): Promise<void> {
129+
const sanitizedBranch = branch
130+
.replace(/^refs\/heads\//, '')
131+
.replace(/^refs\/remotes\//, '')
132+
.replace(/^origin\//, '')
133+
134+
let localBranchExists = false
135+
try {
136+
await executeCommand(['git', '-C', repoPath, 'rev-parse', '--verify', `refs/heads/${sanitizedBranch}`], { silent: true })
137+
localBranchExists = true
138+
} catch {
139+
localBranchExists = false
140+
}
141+
142+
let remoteBranchExists = false
143+
try {
144+
await executeCommand(['git', '-C', repoPath, 'rev-parse', '--verify', `refs/remotes/origin/${sanitizedBranch}`], { silent: true })
145+
remoteBranchExists = true
146+
} catch {
147+
remoteBranchExists = false
148+
}
149+
150+
if (localBranchExists) {
151+
logger.info(`Checking out existing local branch: ${sanitizedBranch}`)
152+
await executeCommand(['git', '-C', repoPath, 'checkout', sanitizedBranch])
153+
} else if (remoteBranchExists) {
154+
logger.info(`Checking out remote branch: ${sanitizedBranch}`)
155+
await executeCommand(['git', '-C', repoPath, 'checkout', '-b', sanitizedBranch, `origin/${sanitizedBranch}`])
156+
} else {
157+
logger.info(`Creating new branch: ${sanitizedBranch}`)
158+
await executeCommand(['git', '-C', repoPath, 'checkout', '-b', sanitizedBranch])
159+
}
160+
}
161+
99162
function getGitEnv(database: Database): Record<string, string> {
100163
try {
101164
const settingsService = new SettingsService(database)
@@ -113,17 +176,62 @@ export async function initLocalRepo(
113176
localPath: string,
114177
branch?: string
115178
): Promise<Repo> {
116-
const normalizedPath = localPath.trim().replace(/\/+$/, '')
117-
const fullPath = path.resolve(getReposPath(), normalizedPath)
179+
const normalizedInputPath = localPath.trim().replace(/\/+$/, '')
180+
181+
let targetPath: string
182+
let repoLocalPath: string
183+
let sourceWasGitRepo = false
118184

119-
const existing = db.getRepoByLocalPath(database, normalizedPath)
185+
if (path.isAbsolute(normalizedInputPath)) {
186+
logger.info(`Absolute path detected: ${normalizedInputPath}`)
187+
188+
try {
189+
const exists = await executeCommand(['test', '-d', normalizedInputPath], { silent: true })
190+
.then(() => true)
191+
.catch(() => false)
192+
193+
if (!exists) {
194+
throw new Error(`No such file or directory: '${normalizedInputPath}'`)
195+
}
196+
197+
const isGit = await isValidGitRepo(normalizedInputPath)
198+
199+
if (isGit) {
200+
sourceWasGitRepo = true
201+
const baseName = path.basename(normalizedInputPath)
202+
203+
const isAvailable = await checkRepoNameAvailable(baseName)
204+
if (!isAvailable) {
205+
throw new Error(`A repository named '${baseName}' already exists in the workspace. Please remove it first or use a different source directory.`)
206+
}
207+
208+
repoLocalPath = baseName
209+
210+
logger.info(`Copying existing git repo from ${normalizedInputPath} to workspace as ${baseName}`)
211+
await copyRepoToWorkspace(normalizedInputPath, baseName)
212+
targetPath = path.join(getReposPath(), baseName)
213+
} else {
214+
throw new Error(`Directory exists but is not a valid Git repository. Please provide either a Git repository path or a simple directory name to create a new empty repository.`)
215+
}
216+
} catch (error: any) {
217+
if (error.message.includes('No such file or directory')) {
218+
throw error
219+
}
220+
throw new Error(`Failed to process absolute path '${normalizedInputPath}': ${error.message}`)
221+
}
222+
} else {
223+
repoLocalPath = normalizedInputPath
224+
targetPath = path.join(getReposPath(), repoLocalPath)
225+
}
226+
227+
const existing = db.getRepoByLocalPath(database, repoLocalPath)
120228
if (existing) {
121-
logger.info(`Local repo already exists in database: ${normalizedPath}`)
229+
logger.info(`Local repo already exists in database: ${repoLocalPath}`)
122230
return existing
123231
}
124232

125233
const createRepoInput: CreateRepoInput = {
126-
localPath: normalizedPath,
234+
localPath: repoLocalPath,
127235
branch: branch || undefined,
128236
defaultBranch: branch || 'main',
129237
cloneStatus: 'cloning',
@@ -136,26 +244,36 @@ export async function initLocalRepo(
136244

137245
try {
138246
repo = db.createRepo(database, createRepoInput)
139-
logger.info(`Created database record for local repo: ${normalizedPath} (id: ${repo.id})`)
247+
logger.info(`Created database record for local repo: ${repoLocalPath} (id: ${repo.id})`)
140248
} catch (error: any) {
141-
logger.error(`Failed to create database record for local repo: ${normalizedPath}`, error)
142-
throw new Error(`Failed to register local repository '${normalizedPath}': ${error.message}`)
249+
logger.error(`Failed to create database record for local repo: ${repoLocalPath}`, error)
250+
throw new Error(`Failed to register local repository '${repoLocalPath}': ${error.message}`)
143251
}
144252

145253
try {
146-
await ensureDirectoryExists(fullPath)
147-
directoryCreated = true
148-
logger.info(`Created directory for local repo: ${fullPath}`)
149-
150-
logger.info(`Initializing git repository: ${fullPath}`)
151-
152-
await executeCommand(['git', 'init'], { cwd: fullPath })
153-
154-
if (branch && branch !== 'main') {
155-
await executeCommand(['git', '-C', fullPath, 'checkout', '-b', branch])
254+
if (!sourceWasGitRepo) {
255+
await ensureDirectoryExists(targetPath)
256+
directoryCreated = true
257+
logger.info(`Created directory for local repo: ${targetPath}`)
258+
259+
logger.info(`Initializing git repository: ${targetPath}`)
260+
await executeCommand(['git', 'init'], { cwd: targetPath })
261+
262+
if (branch && branch !== 'main') {
263+
await executeCommand(['git', '-C', targetPath, 'checkout', '-b', branch])
264+
}
265+
} else {
266+
if (branch) {
267+
logger.info(`Switching to branch ${branch} for copied repo`)
268+
const currentBranch = await safeGetCurrentBranch(targetPath)
269+
270+
if (currentBranch !== branch) {
271+
await checkoutBranchSafely(targetPath, branch)
272+
}
273+
}
156274
}
157275

158-
const isGitRepo = await executeCommand(['git', '-C', fullPath, 'rev-parse', '--git-dir'])
276+
const isGitRepo = await executeCommand(['git', '-C', targetPath, 'rev-parse', '--git-dir'])
159277
.then(() => true)
160278
.catch(() => false)
161279

@@ -164,10 +282,10 @@ export async function initLocalRepo(
164282
}
165283

166284
db.updateRepoStatus(database, repo.id, 'ready')
167-
logger.info(`Local git repo ready: ${normalizedPath}`)
285+
logger.info(`Local git repo ready: ${repoLocalPath}`)
168286
return { ...repo, cloneStatus: 'ready' }
169287
} catch (error: any) {
170-
logger.error(`Failed to initialize local repo, rolling back: ${normalizedPath}`, error)
288+
logger.error(`Failed to initialize local repo, rolling back: ${repoLocalPath}`, error)
171289

172290
try {
173291
db.deleteRepo(database, repo.id)
@@ -176,16 +294,23 @@ export async function initLocalRepo(
176294
logger.error(`Failed to rollback database record for repo id ${repo.id}:`, dbError)
177295
}
178296

179-
if (directoryCreated) {
297+
if (directoryCreated && !sourceWasGitRepo) {
180298
try {
181-
await executeCommand(['rm', '-rf', normalizedPath], getReposPath())
182-
logger.info(`Rolled back directory: ${normalizedPath}`)
299+
await executeCommand(['rm', '-rf', repoLocalPath], getReposPath())
300+
logger.info(`Rolled back directory: ${repoLocalPath}`)
183301
} catch (fsError: any) {
184-
logger.error(`Failed to rollback directory ${normalizedPath}:`, fsError)
302+
logger.error(`Failed to rollback directory ${repoLocalPath}:`, fsError)
303+
}
304+
} else if (sourceWasGitRepo) {
305+
try {
306+
await executeCommand(['rm', '-rf', repoLocalPath], getReposPath())
307+
logger.info(`Cleaned up copied directory: ${repoLocalPath}`)
308+
} catch (fsError: any) {
309+
logger.error(`Failed to clean up copied directory ${repoLocalPath}:`, fsError)
185310
}
186311
}
187312

188-
throw new Error(`Failed to initialize local repository '${normalizedPath}': ${error.message}`)
313+
throw new Error(`Failed to initialize local repository '${repoLocalPath}': ${error.message}`)
189314
}
190315
}
191316

@@ -471,32 +596,7 @@ export async function switchBranch(database: Database, repoId: number, branch: s
471596

472597
await executeGitWithFallback(['git', '-C', repoPath, 'fetch', '--all'], { env })
473598

474-
let localBranchExists = false
475-
try {
476-
await executeCommand(['git', '-C', repoPath, 'rev-parse', '--verify', `refs/heads/${sanitizedBranch}`])
477-
localBranchExists = true
478-
} catch {
479-
localBranchExists = false
480-
}
481-
482-
let remoteBranchExists = false
483-
try {
484-
await executeCommand(['git', '-C', repoPath, 'rev-parse', '--verify', `refs/remotes/origin/${sanitizedBranch}`])
485-
remoteBranchExists = true
486-
} catch {
487-
remoteBranchExists = false
488-
}
489-
490-
if (localBranchExists) {
491-
logger.info(`Checking out existing local branch: ${sanitizedBranch}`)
492-
await executeCommand(['git', '-C', repoPath, 'checkout', sanitizedBranch])
493-
} else if (remoteBranchExists) {
494-
logger.info(`Checking out remote branch: ${sanitizedBranch}`)
495-
await executeCommand(['git', '-C', repoPath, 'checkout', '-b', sanitizedBranch, `origin/${sanitizedBranch}`])
496-
} else {
497-
logger.info(`Creating new branch: ${sanitizedBranch}`)
498-
await executeCommand(['git', '-C', repoPath, 'checkout', '-b', sanitizedBranch])
499-
}
599+
await checkoutBranchSafely(repoPath, sanitizedBranch)
500600

501601
logger.info(`Successfully switched to branch: ${sanitizedBranch}`)
502602
} catch (error: any) {

0 commit comments

Comments
 (0)