Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
A professional workspace for AI coding agents with: - Real-time streaming chat with SSE - Session management (fork, clone, tree navigation) - MCP tools browser & metadata panel - Skills browser (global, project, package sources) - Voice input & STT provider configuration - Integrated terminal & file explorer - Model selector with capability metadata - Keyboard-first UI with command palette - 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi) - Tauri 2 desktop app - React 19 + TypeScript + Vite 8 + Tailwind CSS 4 - Pi Agent extension runtime Originally forked from OpenCodeUI, now an independent project. License: GPL-3.0-only
260 lines
7.1 KiB
TypeScript
260 lines
7.1 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
|
|
export interface GitCommandResult {
|
|
stdout: string
|
|
stderr: string
|
|
}
|
|
|
|
export type GitCommandRunner = (args: string[], options: { cwd: string }) => Promise<GitCommandResult>
|
|
|
|
export interface GitBranchInfo {
|
|
name: string
|
|
current: boolean
|
|
remote: boolean
|
|
upstream?: string
|
|
commit?: string
|
|
}
|
|
|
|
export interface GitBranchesResult {
|
|
current?: string
|
|
branches: GitBranchInfo[]
|
|
}
|
|
|
|
export interface GitStatusFile {
|
|
path: string
|
|
index: string
|
|
workingTree: string
|
|
staged: boolean
|
|
unstaged: boolean
|
|
untracked: boolean
|
|
}
|
|
|
|
export interface GitStatusResult {
|
|
branch?: string
|
|
upstream?: string
|
|
ahead: number
|
|
behind: number
|
|
dirty: boolean
|
|
files: GitStatusFile[]
|
|
}
|
|
|
|
export interface GitActionResult {
|
|
ok: true
|
|
stdout?: string
|
|
stderr?: string
|
|
}
|
|
|
|
export interface CheckoutGitBranchRequest {
|
|
branch: string
|
|
}
|
|
|
|
export interface CreateGitBranchRequest {
|
|
name: string
|
|
}
|
|
|
|
export interface GitPathsRequest {
|
|
paths: string[]
|
|
}
|
|
|
|
export interface CommitGitChangesRequest {
|
|
message: string
|
|
}
|
|
|
|
export const runGit: GitCommandRunner = (args, options) => {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('git', args, { cwd: options.cwd, shell: false })
|
|
let stdout = ''
|
|
let stderr = ''
|
|
|
|
child.stdout.setEncoding('utf8')
|
|
child.stderr.setEncoding('utf8')
|
|
child.stdout.on('data', chunk => {
|
|
stdout += chunk
|
|
})
|
|
child.stderr.on('data', chunk => {
|
|
stderr += chunk
|
|
})
|
|
child.on('error', reject)
|
|
child.on('close', code => {
|
|
if (code === 0) {
|
|
resolve({ stdout, stderr })
|
|
return
|
|
}
|
|
reject(new Error((stderr || stdout || `git ${args.join(' ')} failed with exit code ${code}`).trim()))
|
|
})
|
|
})
|
|
}
|
|
|
|
function assertSafeRefName(value: string, label: string): string {
|
|
const name = value.trim()
|
|
if (!name) throw new Error(`${label} is required`)
|
|
if (name.startsWith('-')) throw new Error(`Invalid ${label}`)
|
|
if (/\s/.test(name) || /[\0~^:?*[\]\\]/.test(name) || name.includes('..') || name.includes('@{')) {
|
|
throw new Error(`Invalid ${label}`)
|
|
}
|
|
return name
|
|
}
|
|
|
|
function assertSafePath(value: string): string {
|
|
const filePath = value.trim()
|
|
if (!filePath) throw new Error('Git path is required')
|
|
if (filePath.includes('\0')) throw new Error('Invalid Git path')
|
|
return filePath
|
|
}
|
|
|
|
function normalizeRemoteName(name: string): string {
|
|
return name.replace(/^remotes\//, '')
|
|
}
|
|
|
|
export async function listGitBranches(cwd: string, runner: GitCommandRunner = runGit): Promise<GitBranchesResult> {
|
|
const { stdout } = await runner(
|
|
['branch', '--format=%(refname:short)|%(upstream:short)|%(objectname:short)|%(HEAD)', '--all'],
|
|
{
|
|
cwd,
|
|
},
|
|
)
|
|
|
|
const branches = stdout
|
|
.split(/\r?\n/)
|
|
.map(line => line.trim())
|
|
.filter(Boolean)
|
|
.map(line => {
|
|
const [rawName, upstream, commit, head] = line.split('|')
|
|
const name = normalizeRemoteName(rawName)
|
|
return {
|
|
name,
|
|
current: head === '*',
|
|
remote: rawName.startsWith('remotes/') || name.startsWith('origin/'),
|
|
upstream: upstream || undefined,
|
|
commit: commit || undefined,
|
|
}
|
|
})
|
|
.filter(branch => branch.name !== 'origin/HEAD')
|
|
|
|
return { current: branches.find(branch => branch.current)?.name, branches }
|
|
}
|
|
|
|
function parseStatusPath(line: string): string {
|
|
if (line.includes('\t')) return line.split('\t').pop() || ''
|
|
const parts = line.split(' ')
|
|
return parts[parts.length - 1] || ''
|
|
}
|
|
|
|
export async function getGitStatus(cwd: string, runner: GitCommandRunner = runGit): Promise<GitStatusResult> {
|
|
const { stdout } = await runner(['status', '--porcelain=v2', '--branch'], { cwd })
|
|
const status: GitStatusResult = { ahead: 0, behind: 0, dirty: false, files: [] }
|
|
|
|
for (const line of stdout.split(/\r?\n/)) {
|
|
if (!line) continue
|
|
if (line.startsWith('# branch.head ')) {
|
|
const branch = line.slice('# branch.head '.length).trim()
|
|
if (branch && branch !== '(detached)') status.branch = branch
|
|
continue
|
|
}
|
|
if (line.startsWith('# branch.upstream ')) {
|
|
status.upstream = line.slice('# branch.upstream '.length).trim()
|
|
continue
|
|
}
|
|
if (line.startsWith('# branch.ab ')) {
|
|
const match = line.match(/\+(\d+)\s+-(\d+)/)
|
|
if (match) {
|
|
status.ahead = Number(match[1])
|
|
status.behind = Number(match[2])
|
|
}
|
|
continue
|
|
}
|
|
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
const xy = line.slice(2, 4)
|
|
const file = {
|
|
path: parseStatusPath(line),
|
|
index: xy[0],
|
|
workingTree: xy[1],
|
|
staged: xy[0] !== '.',
|
|
unstaged: xy[1] !== '.',
|
|
untracked: false,
|
|
}
|
|
status.files.push(file)
|
|
continue
|
|
}
|
|
if (line.startsWith('? ')) {
|
|
status.files.push({
|
|
path: line.slice(2).trim(),
|
|
index: '?',
|
|
workingTree: '?',
|
|
staged: false,
|
|
unstaged: false,
|
|
untracked: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
status.dirty = status.files.length > 0
|
|
return status
|
|
}
|
|
|
|
export async function checkoutGitBranch(
|
|
cwd: string,
|
|
request: CheckoutGitBranchRequest,
|
|
runner: GitCommandRunner = runGit,
|
|
): Promise<GitActionResult> {
|
|
const branch = assertSafeRefName(request.branch, 'Branch')
|
|
const result = await runner(['checkout', branch], { cwd })
|
|
return { ok: true, ...result }
|
|
}
|
|
|
|
export async function createGitBranch(
|
|
cwd: string,
|
|
request: CreateGitBranchRequest,
|
|
runner: GitCommandRunner = runGit,
|
|
): Promise<GitActionResult> {
|
|
const name = assertSafeRefName(request.name, 'Branch name')
|
|
const result = await runner(['checkout', '-b', name], { cwd })
|
|
return { ok: true, ...result }
|
|
}
|
|
|
|
function normalizePaths(request: GitPathsRequest): string[] {
|
|
if (!Array.isArray(request.paths) || request.paths.length === 0) throw new Error('At least one path is required')
|
|
return request.paths.map(assertSafePath)
|
|
}
|
|
|
|
export async function stageGitPaths(
|
|
cwd: string,
|
|
request: GitPathsRequest,
|
|
runner: GitCommandRunner = runGit,
|
|
): Promise<GitActionResult> {
|
|
const paths = normalizePaths(request)
|
|
const result = await runner(['add', '--', ...paths], { cwd })
|
|
return { ok: true, ...result }
|
|
}
|
|
|
|
export async function unstageGitPaths(
|
|
cwd: string,
|
|
request: GitPathsRequest,
|
|
runner: GitCommandRunner = runGit,
|
|
): Promise<GitActionResult> {
|
|
const paths = normalizePaths(request)
|
|
const result = await runner(['restore', '--staged', '--', ...paths], { cwd })
|
|
return { ok: true, ...result }
|
|
}
|
|
|
|
export async function commitGitChanges(
|
|
cwd: string,
|
|
request: CommitGitChangesRequest,
|
|
runner: GitCommandRunner = runGit,
|
|
): Promise<GitActionResult> {
|
|
const message = request.message.trim()
|
|
if (!message) throw new Error('Commit message is required')
|
|
const result = await runner(['commit', '-m', message], { cwd })
|
|
return { ok: true, ...result }
|
|
}
|
|
|
|
export async function pullGitBranch(cwd: string, runner: GitCommandRunner = runGit): Promise<GitActionResult> {
|
|
const result = await runner(['pull', '--ff-only'], { cwd })
|
|
return { ok: true, ...result }
|
|
}
|
|
|
|
export async function pushGitBranch(cwd: string, runner: GitCommandRunner = runGit): Promise<GitActionResult> {
|
|
const result = await runner(['push'], { cwd })
|
|
return { ok: true, ...result }
|
|
}
|