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
9 changes: 6 additions & 3 deletions backend/src/routes/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ export function createRepoRoutes(database: Database) {
return c.json({ error: 'Repo not found' }, 404)
}

const branches = await repoService.listBranches(repo)
const branches = await repoService.listBranches(database, repo)


return c.json(branches)
} catch (error: any) {
Expand All @@ -223,7 +224,8 @@ export function createRepoRoutes(database: Database) {
}

const repoPath = path.resolve(getReposPath(), repo.localPath)
const status = await gitOperations.getGitStatus(repoPath)
const status = await gitOperations.getGitStatus(repoPath, database)


return c.json(status)
} catch (error: any) {
Expand All @@ -248,7 +250,8 @@ export function createRepoRoutes(database: Database) {
}

const repoPath = path.resolve(getReposPath(), repo.localPath)
const diff = await gitOperations.getFileDiff(repoPath, filePath)
const diff = await gitOperations.getFileDiff(repoPath, filePath, database)


return c.json(diff)
} catch (error: any) {
Expand Down
21 changes: 6 additions & 15 deletions backend/src/services/git-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { logger } from '../utils/logger'
import { SettingsService } from './settings'
import type { Database } from 'bun:sqlite'
import path from 'path'
import { createGitHubGitEnv, createNoPromptGitEnv } from '../utils/git-auth'

async function hasCommits(repoPath: string): Promise<boolean> {
try {
Expand All @@ -18,25 +19,15 @@ function getGitEnvironment(database: Database): Record<string, string> {
const settingsService = new SettingsService(database)
const settings = settingsService.getSettings('default')
const gitToken = settings.preferences.gitToken

if (gitToken) {
return {
GITHUB_TOKEN: gitToken,
GIT_ASKPASS: 'echo $GITHUB_TOKEN',
GIT_TERMINAL_PROMPT: '0'
}
}

return {
GIT_ASKPASS: 'echo',
GIT_TERMINAL_PROMPT: '0'
return createGitHubGitEnv(gitToken)
}

return createNoPromptGitEnv()
} catch (error) {
logger.warn('Failed to get git token from settings:', error)
return {
GIT_ASKPASS: 'echo',
GIT_TERMINAL_PROMPT: '0'
}
return createNoPromptGitEnv()
}
}

Expand Down
10 changes: 5 additions & 5 deletions backend/src/services/opencode-single-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn, execSync } from 'child_process'
import path from 'path'
import { logger } from '../utils/logger'
import { createGitHubGitEnv, createNoPromptGitEnv } from '../utils/git-auth'
import { SettingsService } from './settings'
import { getWorkspacePath, getOpenCodeConfigFilePath, ENV } from '@opencode-manager/shared'
import type { Database } from 'bun:sqlite'
Expand Down Expand Up @@ -101,8 +102,9 @@ class OpenCodeServerManager {
logger.info(`OpenCode server working directory: ${OPENCODE_SERVER_DIRECTORY}`)
logger.info(`OpenCode XDG_CONFIG_HOME: ${path.join(OPENCODE_SERVER_DIRECTORY, '.config')}`)
logger.info(`OpenCode will use ?directory= parameter for session isolation`)



const gitEnv = gitToken ? createGitHubGitEnv(gitToken) : createNoPromptGitEnv()

this.serverProcess = spawn(
'opencode',
['serve', '--port', OPENCODE_SERVER_PORT.toString(), '--hostname', '127.0.0.1'],
Expand All @@ -112,12 +114,10 @@ class OpenCodeServerManager {
stdio: isDevelopment ? 'inherit' : 'ignore',
env: {
...process.env,
...gitEnv,
XDG_DATA_HOME: path.join(OPENCODE_SERVER_DIRECTORY, '.opencode/state'),
XDG_CONFIG_HOME: path.join(OPENCODE_SERVER_DIRECTORY, '.config'),
OPENCODE_CONFIG: OPENCODE_CONFIG_PATH,
GITHUB_TOKEN: gitToken,
GIT_ASKPASS: gitToken ? 'echo $GITHUB_TOKEN' : 'echo',
GIT_TERMINAL_PROMPT: '0'
}
}
)
Expand Down
69 changes: 39 additions & 30 deletions backend/src/services/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Database } from 'bun:sqlite'
import type { Repo, CreateRepoInput } from '../types/repo'
import { logger } from '../utils/logger'
import { SettingsService } from './settings'
import { createGitEnvForRepoUrl, createNoPromptGitEnv } from '../utils/git-auth'
import { getReposPath } from '@opencode-manager/shared'
import path from 'path'

Expand Down Expand Up @@ -37,6 +38,22 @@ async function safeGetCurrentBranch(repoPath: string): Promise<string | null> {
}
}

function getGitEnv(database: Database, repoUrl?: string | null): Record<string, string> {
try {
const settingsService = new SettingsService(database)
const settings = settingsService.getSettings('default')
const gitToken = settings.preferences.gitToken

if (!repoUrl) {
return createNoPromptGitEnv()
}

return createGitEnvForRepoUrl(repoUrl, gitToken)
} catch {
return createNoPromptGitEnv()
}
}

export async function initLocalRepo(
database: Database,
localPath: string,
Expand Down Expand Up @@ -77,17 +94,7 @@ export async function initLocalRepo(
logger.info(`Created directory for local repo: ${fullPath}`)

logger.info(`Initializing git repository: ${fullPath}`)
const settingsService = new SettingsService(database)
const settings = settingsService.getSettings('default')
const gitToken = settings.preferences.gitToken
const env: Record<string, string> = gitToken ?
{
GITHUB_TOKEN: gitToken,
GIT_ASKPASS: 'echo $GITHUB_TOKEN',
GIT_TERMINAL_PROMPT: '0'
} :
{ GIT_ASKPASS: 'echo', GIT_TERMINAL_PROMPT: '0' }


await executeCommand(['git', 'init'], { cwd: fullPath })

if (branch && branch !== 'main') {
Expand Down Expand Up @@ -167,19 +174,16 @@ export async function cloneRepo(
const repo = db.createRepo(database, createRepoInput)

try {
const settingsService = new SettingsService(database)
const settings = settingsService.getSettings('default')
const gitToken = settings.preferences.gitToken

const cloneUrl = repoUrl

const env = getGitEnv(database, repoUrl)

if (shouldUseWorktree) {
logger.info(`Creating worktree for branch: ${branch}`)

const baseRepoPath = path.resolve(getReposPath(), baseRepoDirName)
const worktreePath = path.resolve(getReposPath(), worktreeDirName)

await executeCommand(['git', '-C', baseRepoPath, 'fetch', '--all'])
await executeCommand(['git', '-C', baseRepoPath, 'fetch', '--all'], { env })


await createWorktreeSafely(baseRepoPath, worktreePath, branch)

Expand Down Expand Up @@ -213,7 +217,7 @@ export async function cloneRepo(

try {
const cloneCmd = ['git', 'clone', '-b', branch, repoUrl, worktreeDirName]
await executeCommand(cloneCmd, getReposPath())
await executeCommand(cloneCmd, { cwd: getReposPath(), env })
} catch (error: any) {
if (error.message.includes('destination path') && error.message.includes('already exists')) {
logger.error(`Clone failed: directory still exists after cleanup attempt`)
Expand All @@ -222,7 +226,7 @@ export async function cloneRepo(

logger.info(`Branch '${branch}' not found during clone, cloning default branch and creating branch locally`)
const cloneCmd = ['git', 'clone', repoUrl, worktreeDirName]
await executeCommand(cloneCmd, getReposPath())
await executeCommand(cloneCmd, { cwd: getReposPath(), env })
let localBranchExists = 'missing'
try {
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'rev-parse', '--verify', `refs/heads/${branch}`])
Expand All @@ -246,7 +250,8 @@ export async function cloneRepo(

if (branch) {
logger.info(`Switching to branch: ${branch}`)
await executeCommand(['git', '-C', path.resolve(getReposPath(), baseRepoDirName), 'fetch', '--all'])
await executeCommand(['git', '-C', path.resolve(getReposPath(), baseRepoDirName), 'fetch', '--all'], { env })


let remoteBranchExists = false
try {
Expand Down Expand Up @@ -306,7 +311,7 @@ export async function cloneRepo(
? ['git', 'clone', '-b', branch, repoUrl, worktreeDirName]
: ['git', 'clone', repoUrl, worktreeDirName]

await executeCommand(cloneCmd, getReposPath())
await executeCommand(cloneCmd, { cwd: getReposPath(), env })
} catch (error: any) {
if (error.message.includes('destination path') && error.message.includes('already exists')) {
logger.error(`Clone failed: directory still exists after cleanup attempt`)
Expand All @@ -316,7 +321,7 @@ export async function cloneRepo(
if (branch && (error.message.includes('Remote branch') || error.message.includes('not found'))) {
logger.info(`Branch '${branch}' not found, cloning default branch and creating branch locally`)
const cloneCmd = ['git', 'clone', repoUrl, worktreeDirName]
await executeCommand(cloneCmd, getReposPath())
await executeCommand(cloneCmd, { cwd: getReposPath(), env })
let localBranchExists = 'missing'
try {
await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'rev-parse', '--verify', `refs/heads/${branch}`])
Expand Down Expand Up @@ -352,12 +357,13 @@ export async function getCurrentBranch(repo: Repo): Promise<string | null> {
return branch || repo.branch || repo.defaultBranch || null
}

export async function listBranches(repo: Repo): Promise<{ local: string[], remote: string[], current: string | null }> {
export async function listBranches(database: Database, repo: Repo): Promise<{ local: string[], remote: string[], current: string | null }> {
try {
const repoPath = path.resolve(getReposPath(), repo.localPath)

const env = getGitEnv(database, repo.repoUrl)

if (!repo.isLocal) {
await executeCommand(['git', '-C', repoPath, 'fetch', '--all'])
await executeCommand(['git', '-C', repoPath, 'fetch', '--all'], { env })
}

const localBranchesOutput = await executeCommand(['git', '-C', repoPath, 'branch', '--format=%(refname:short)'])
Expand Down Expand Up @@ -399,10 +405,11 @@ export async function switchBranch(database: Database, repoId: number, branch: s

try {
const repoPath = path.resolve(getReposPath(), repo.localPath)

const env = getGitEnv(database, repo.repoUrl)

logger.info(`Switching to branch: ${branch} in ${repo.localPath}`)
await executeCommand(['git', '-C', repoPath, 'fetch', '--all'])

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

let localBranchExists = false
try {
Expand Down Expand Up @@ -450,8 +457,10 @@ export async function pullRepo(database: Database, repoId: number): Promise<void
}

try {
const env = getGitEnv(database, repo.repoUrl)

logger.info(`Pulling repo: ${repo.repoUrl}`)
await executeCommand(['git', '-C', path.resolve(getReposPath(), repo.localPath), 'pull'])
await executeCommand(['git', '-C', path.resolve(getReposPath(), repo.localPath), 'pull'], { env })

db.updateLastPulled(database, repoId)
logger.info(`Repo pulled successfully: ${repo.repoUrl}`)
Expand Down
38 changes: 38 additions & 0 deletions backend/src/utils/git-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export function isGitHubHttpsUrl(repoUrl: string): boolean {
try {
const parsed = new URL(repoUrl)
return parsed.protocol === 'https:' && parsed.hostname === 'github.com'
} catch {
return false
}
}

export function createNoPromptGitEnv(): Record<string, string> {
return {
GIT_TERMINAL_PROMPT: '0'
}
}

export function createGitHubGitEnv(gitToken: string): Record<string, string> {
const basicAuth = Buffer.from(`x-access-token:${gitToken}`, 'utf8').toString('base64')

return {
...createNoPromptGitEnv(),
GITHUB_TOKEN: gitToken,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicAuth}`
}
}

export function createGitEnvForRepoUrl(repoUrl: string, gitToken?: string): Record<string, string> {
if (!gitToken) {
return createNoPromptGitEnv()
}

if (isGitHubHttpsUrl(repoUrl)) {
return createGitHubGitEnv(gitToken)
}

return createNoPromptGitEnv()
}
79 changes: 79 additions & 0 deletions backend/test/services/repo-auth-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getReposPath } from '@opencode-manager/shared'
import { createGitHubGitEnv } from '../../src/utils/git-auth'

const executeCommand = vi.fn()
const ensureDirectoryExists = vi.fn()

const getRepoByUrlAndBranch = vi.fn()
const createRepo = vi.fn()
const updateRepoStatus = vi.fn()
const deleteRepo = vi.fn()

vi.mock('../../src/utils/process', () => ({
executeCommand,
}))

vi.mock('../../src/services/file-operations', () => ({
ensureDirectoryExists,
}))

vi.mock('../../src/db/queries', () => ({
getRepoByUrlAndBranch,
createRepo,
updateRepoStatus,
deleteRepo,
}))

vi.mock('../../src/services/settings', () => ({
SettingsService: vi.fn().mockImplementation(() => ({
getSettings: () => ({
preferences: {
gitToken: 'ghp_test_token',
},
updatedAt: Date.now(),
}),
})),
}))

describe('repoService.cloneRepo auth env', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('passes github extraheader env to git clone', async () => {
const { cloneRepo } = await import('../../src/services/repo')

const database = {} as any
const repoUrl = 'https://github.com/acme/forge.git'

getRepoByUrlAndBranch.mockReturnValue(null)
createRepo.mockReturnValue({
id: 1,
repoUrl,
localPath: 'forge',
defaultBranch: 'main',
cloneStatus: 'cloning',
clonedAt: Date.now(),
})

executeCommand
.mockResolvedValueOnce('missing')
.mockResolvedValueOnce('missing')
.mockResolvedValueOnce('')

await cloneRepo(database, repoUrl)

const expectedEnv = createGitHubGitEnv('ghp_test_token')

expect(executeCommand).toHaveBeenNthCalledWith(
3,
['git', 'clone', repoUrl, 'forge'],
{ cwd: getReposPath(), env: expectedEnv }
)

expect(ensureDirectoryExists).toHaveBeenCalledWith(getReposPath())
expect(updateRepoStatus).toHaveBeenCalledWith(database, 1, 'ready')
expect(deleteRepo).not.toHaveBeenCalled()
})
})