Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c4a7fa3
feat: enhance source control with discard and commit detail views
theepicsaxguy Feb 7, 2026
5b718a1
Merge branch 'main' into feature/enhanced-source-control
theepicsaxguy Feb 10, 2026
5994a38
Fix critical bugs from code review
theepicsaxguy Feb 10, 2026
c5f8afc
Fix critical bugs from code review
theepicsaxguy Feb 10, 2026
9f9a06d
Merge branch 'main' into feature/enhanced-source-control
theepicsaxguy Feb 14, 2026
1aa1bbd
fix: resolve code review issues
theepicsaxguy Feb 14, 2026
10b9019
feat: enhance source control with commit view improvements and diff f…
theepicsaxguy Feb 15, 2026
27f3aa8
Merge branch 'main' of github.com:theepicsaxguy/opencode-web into fea…
theepicsaxguy Feb 16, 2026
c56b57e
Refactor API error handling and enhance source control functionality
theepicsaxguy Feb 16, 2026
eb4bf77
Fix code review issues: OAuth error handling, unpushed detection, com…
theepicsaxguy Feb 16, 2026
9ce0531
Refactor imports and type assertions in GitService, SSH tests, and ha…
theepicsaxguy Feb 16, 2026
95e1f08
Refactor GitService methods for improved error handling and code clar…
theepicsaxguy Feb 16, 2026
faf1ee8
Refactor Git and SSH services: streamline imports, enhance error hand…
theepicsaxguy Feb 16, 2026
a4df8ff
Refactor source control components: remove unused useGitAction hook a…
theepicsaxguy Feb 16, 2026
37501d0
Merge branch 'main' of github.com:theepicsaxguy/opencode-web into fea…
theepicsaxguy Feb 17, 2026
1eb38ab
Enhance error handling in Git routes and improve UI components for be…
theepicsaxguy Feb 17, 2026
e77283a
Remove unnecessary blank line in GitService class
theepicsaxguy Feb 17, 2026
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
92 changes: 92 additions & 0 deletions backend/src/routes/repo-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,98 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS
}
})

app.post('/:id/git/discard', async (c) => {
try {
const id = parseInt(c.req.param('id'))
const repo = db.getRepoById(database, id)

if (!repo) {
return c.json({ error: 'Repo not found' }, 404)
}

const body = await c.req.json()
const { paths, staged } = body

if (!paths || !Array.isArray(paths)) {
return c.json({ error: 'paths is required and must be an array' }, 400)
}

await git.discardChanges(id, paths, staged ?? false, database)

const status = await git.getStatus(id, database)
return c.json(status)
} catch (error: unknown) {
logger.error('Failed to discard changes:', error)
const gitError = parseGitError(error)
return c.json(
{ error: gitError.summary, detail: gitError.detail, code: gitError.code },
gitError.statusCode as ContentfulStatusCode
)
}
})

app.get('/:id/git/commit/:hash', async (c) => {
try {
const id = parseInt(c.req.param('id'))
const hash = c.req.param('hash')
const repo = db.getRepoById(database, id)

if (!repo) {
return c.json({ error: 'Repo not found' }, 404)
}

if (!hash) {
return c.json({ error: 'hash is required' }, 400)
}

const commitDetails = await git.getCommitDetails(id, hash, database)

if (!commitDetails) {
return c.json({ error: 'Commit not found' }, 404)
}

return c.json(commitDetails)
} catch (error: unknown) {
logger.error('Failed to get commit details:', error)
const gitError = parseGitError(error)
return c.json(
{ error: gitError.summary, detail: gitError.detail, code: gitError.code },
gitError.statusCode as ContentfulStatusCode
)
}
})

app.get('/:id/git/commit/:hash/diff', async (c) => {
try {
const id = parseInt(c.req.param('id'))
const hash = c.req.param('hash')
const filePath = c.req.query('path')
const repo = db.getRepoById(database, id)

if (!repo) {
return c.json({ error: 'Repo not found' }, 404)
}

if (!hash) {
return c.json({ error: 'hash is required' }, 400)
}

if (!filePath) {
return c.json({ error: 'path query parameter is required' }, 400)
}

const diff = await git.getCommitDiff(id, hash, filePath, database)
return c.json(diff)
} catch (error: unknown) {
logger.error('Failed to get commit diff:', error)
const gitError = parseGitError(error)
return c.json(
{ error: gitError.summary, detail: gitError.detail, code: gitError.code },
gitError.statusCode as ContentfulStatusCode
)
}
})

app.get('/:id/git/log', async (c) => {
try {
const id = parseInt(c.req.param('id'))
Expand Down
243 changes: 240 additions & 3 deletions backend/src/services/git/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { resolveGitIdentity, createGitIdentityEnv, isSSHUrl } from '../../utils/
import { isNoUpstreamError, parseBranchNameFromError } from '../../utils/git-errors'
import { SettingsService } from '../settings'
import type { Database } from 'bun:sqlite'
import type { GitBranch, GitCommit, FileDiffResponse, GitDiffOptions, GitStatusResponse, GitFileStatus, GitFileStatusType } from '../../types/git'
import type { GitBranch, GitCommit, FileDiffResponse, GitDiffOptions, GitStatusResponse, GitFileStatus, GitFileStatusType, CommitDetails, CommitFile } from '../../types/git'
import type { GitCredential } from '@opencode-manager/shared'
import path from 'path'

Expand Down Expand Up @@ -269,6 +269,229 @@ export class GitService {
}
}

async discardChanges(repoId: number, paths: string[], staged: boolean, database: Database): Promise<string> {
try {
const repo = getRepoById(database, repoId)
if (!repo) {
throw new Error(`Repository not found`)
}

const repoPath = repo.fullPath
const env = this.gitAuthService.getGitEnvironment()

if (paths.length === 0) {
return ''
}

if (staged) {
const args = ['git', '-C', repoPath, 'restore', '--staged', '--worktree', '--source', 'HEAD', '--', ...paths]
return await executeCommand(args, { env })
}

const statusOutput = await executeCommand(
['git', '-C', repoPath, 'status', '--porcelain', '-u', '--', ...paths],
{ env }
)

const untrackedPaths: string[] = []
const trackedPaths: string[] = []

for (const line of statusOutput.split('\n')) {
if (!line.trim()) continue
const statusCode = line.substring(0, 2)
const filePath = line.substring(3).trim()

if (statusCode === '??') {
untrackedPaths.push(filePath)
} else {
trackedPaths.push(filePath)
}
}

const results: string[] = []

if (trackedPaths.length > 0) {
const args = ['git', '-C', repoPath, 'checkout', '--', ...trackedPaths]
results.push(await executeCommand(args, { env }))
}

if (untrackedPaths.length > 0) {
try {
const args = ['git', '-C', repoPath, 'clean', '-fd', '--', ...untrackedPaths]
results.push(await executeCommand(args, { env }))
} catch (error: unknown) {
logger.error(`Failed to clean untracked files for repo ${repoId}:`, error)
throw error
}
}

return results.join('\n')
} catch (error: unknown) {
logger.error(`Failed to discard changes for repo ${repoId}:`, error)
throw error
}
}

private parseNumstatOutput(output: string): Map<string, { additions: number; deletions: number }> {
const map = new Map<string, { additions: number; deletions: number }>()
const lines = output.trim().split('\n')

for (const line of lines) {
if (!line.trim()) continue

const parts = line.split('\t')
if (parts.length >= 3) {
const additions = parts[0]
const deletions = parts[1]
const filePath = parts.slice(2).join('\t')

if (
additions?.match(/^\d+$/) &&
deletions?.match(/^\d+$/) &&
filePath
) {
map.set(filePath, {
additions: parseInt(additions, 10),
deletions: parseInt(deletions, 10)
})
}
}
}

return map
}

private parseCommitFiles(
output: string,
numstatMap: Map<string, { additions: number; deletions: number }>
): CommitFile[] {
const files: CommitFile[] = []
const lines = output.trim().split('\n')

for (const line of lines) {
if (!line.trim()) continue

const parts = line.split('\t')
if (parts.length >= 2 && parts[0] && parts[0].match(/^[AMDRC]/)) {
const statusCode = parts[0]
const fromPath = parts[1] || ''
const toPath = parts[2] || parts[1] || ''
const isRename = statusCode === 'R'
const isCopy = statusCode === 'C'

let status: GitFileStatusType = 'modified'
switch (statusCode.charAt(0)) {
case 'A':
status = 'added'
break
case 'D':
status = 'deleted'
break
case 'R':
status = 'renamed'
break
case 'C':
status = 'copied'
break
case 'M':
status = 'modified'
break
}

const numstatData = numstatMap.get(toPath)
const additions = numstatData?.additions ?? 0
const deletions = numstatData?.deletions ?? 0

files.push({
path: toPath,
status,
oldPath: isRename || isCopy ? fromPath : undefined,
additions,
deletions
})
}
}

return files
}

async getCommitDetails(repoId: number, hash: string, database: Database): Promise<CommitDetails | null> {
try {
const repo = getRepoById(database, repoId)
if (!repo) {
throw new Error(`Repository not found: ${repoId}`)
}

const repoPath = path.resolve(repo.fullPath)
const env = this.gitAuthService.getGitEnvironment(true)

const commitOutput = await executeCommand(
['git', '-C', repoPath, 'log', '-1', '--format=%H|%an|%ae|%at|%s', hash],
{ env }
)

if (!commitOutput.trim()) {
return null
}

const parts = commitOutput.trim().split('|')
const [commitHash, authorName, authorEmail, timestamp, ...messageParts] = parts
const message = messageParts.join('|')

if (!commitHash) {
return null
}

const filesOutput = await executeCommand(
['git', '-C', repoPath, 'show', '--name-status', '--format=', hash],
{ env }
)

const numstatOutput = await executeCommand(
['git', '-C', repoPath, 'show', '--numstat', '--format=', hash],
{ env }
)

const numstatMap = this.parseNumstatOutput(numstatOutput)
const files = this.parseCommitFiles(filesOutput, numstatMap)

return {
hash: commitHash,
authorName: authorName || '',
authorEmail: authorEmail || '',
date: timestamp || '',
message: message || '',
unpushed: await this.isCommitUnpushed(repoPath, commitHash, env),
files
}
} catch (error: unknown) {
logger.error(`Failed to get commit details for repo ${repoId}:`, error)
throw new Error(`Failed to get commit details: ${getErrorMessage(error)}`)
}
}

async getCommitDiff(repoId: number, hash: string, filePath: string, database: Database): Promise<FileDiffResponse> {
try {
const repo = getRepoById(database, repoId)
if (!repo) {
throw new Error(`Repository not found: ${repoId}`)
}

const repoPath = path.resolve(repo.fullPath)
const env = this.gitAuthService.getGitEnvironment(true)

const diff = await executeCommand(
['git', '-C', repoPath, 'show', '--format=', hash, '--', filePath],
{ env }
)

return this.parseDiffOutput(diff, 'modified', filePath)
} catch (error: unknown) {
logger.error(`Failed to get commit diff for repo ${repoId}:`, error)
throw new Error(`Failed to get commit diff: ${getErrorMessage(error)}`)
}
}

private async setupSSHIfNeeded(repoUrl: string | undefined, database: Database): Promise<void> {
await this.gitAuthService.setupSSHForRepoUrl(repoUrl, database)
}
Expand Down Expand Up @@ -669,6 +892,7 @@ export class GitService {
let additions = 0
let deletions = 0
let isBinary = false
const MAX_DIFF_SIZE = 500 * 1024

if (typeof diff === 'string') {
if (diff.includes('Binary files') || diff.includes('GIT binary patch')) {
Expand All @@ -682,13 +906,21 @@ export class GitService {
}
}

let diffOutput = typeof diff === 'string' ? diff : ''
let truncated = false
if (diffOutput.length > MAX_DIFF_SIZE) {
diffOutput = diffOutput.substring(0, MAX_DIFF_SIZE) + '\n\n... (diff truncated due to size)'
truncated = true
}

return {
path: filePath || '',
status: status as GitFileStatusType,
diff: typeof diff === 'string' ? diff : '',
diff: diffOutput,
additions,
deletions,
isBinary
isBinary,
truncated
}
}

Expand All @@ -701,6 +933,11 @@ export class GitService {
}
}

private async isCommitUnpushed(repoPath: string, commitHash: string, env: Record<string, string>): Promise<boolean> {
const unpushedHashes = await this.getUnpushedCommitHashes(repoPath, env)
return unpushedHashes.has(commitHash)
}

private async getUnpushedCommitHashes(repoPath: string, env: Record<string, string>): Promise<Set<string>> {
try {
const output = await executeCommand(
Expand Down
Loading