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
10 changes: 10 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export default defineConfig({
rollupOptions: {
external: ['node-pty']
}
},
server: {
watch: {
ignored: ['**/.konductor/**']
}
}
},
preload: {
Expand All @@ -22,6 +27,11 @@ export default defineConfig({
}
},
plugins: [react()],
server: {
watch: {
ignored: ['**/.konductor/**']
}
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version)
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/fileWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function createFileWatcher(

// Watch for FS changes to trigger git status refresh
watcher = watch(cwd, {
ignored: ['**/.git/**', '**/node_modules/**'],
ignored: ['**/.git/**', '**/node_modules/**', '**/.konductor/**'],
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
Expand Down
25 changes: 24 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import {
getBranchDetails,
deleteBranch,
deleteRemoteBranch,
fetchPrune
fetchPrune,
getBranchFiles,
getBranchDiff
} from './worktree'
import { getGitHubRepo, listPullRequests, listIssues } from './github'

Expand Down Expand Up @@ -207,6 +209,27 @@ app.whenReady().then(() => {
return shell.openExternal(url)
})

ipcMain.handle(
'get-branch-files',
(_event, cwd: string, branch: string, worktreePath: string) => {
return getBranchFiles(cwd, branch, worktreePath)
}
)

ipcMain.handle(
'get-branch-diff',
(
_event,
cwd: string,
branch: string,
filePath: string,
source: 'committed' | 'uncommitted',
worktreePath: string
) => {
return getBranchDiff(cwd, branch, filePath, source, worktreePath)
}
)

ipcMain.handle('select-directory', async () => {
if (!mainWindow) return null
const result = await dialog.showOpenDialog(mainWindow, {
Expand Down
216 changes: 206 additions & 10 deletions src/main/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execFile } from 'child_process'
import { join } from 'path'
import { mkdir, rm } from 'fs/promises'
import { mkdir } from 'fs/promises'

function git(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -89,12 +89,24 @@ export async function createWorktree(
}

export async function removeWorktree(repoRoot: string, worktreePath: string): Promise<void> {
await rm(worktreePath, { recursive: true, force: true })

await new Promise<void>((resolve) => {
execFile('git', ['worktree', 'prune'], { cwd: repoRoot }, () => {
// Use git worktree remove first — it handles cleanup of .git references
await new Promise<void>((resolve, reject) => {
execFile('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot }, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
}).catch(async () => {
// Fallback: force-remove the directory (handles suid binaries like chrome-sandbox
// that Node's fs.rm can't delete) then prune the worktree list
await new Promise<void>((resolve) => {
execFile('rm', ['-rf', worktreePath], () => resolve())
})
await new Promise<void>((resolve) => {
execFile('git', ['worktree', 'prune'], { cwd: repoRoot }, () => resolve())
})
})
}

Expand All @@ -120,6 +132,14 @@ export function listBranches(cwd: string): Promise<string[]> {
})
}

export type PrState = 'open' | 'merged' | 'closed' | 'none'

export interface PrInfo {
state: PrState
number: number
url: string
}

export interface BranchDetail {
name: string
isHead: boolean
Expand All @@ -129,6 +149,77 @@ export interface BranchDetail {
lastCommitRelative: string
lastCommitSubject: string
worktreePath: string
aheadCount: number
dirty: boolean
pr: PrInfo
}

function getAheadCount(cwd: string, branch: string, mainBranch: string): Promise<number> {
return new Promise((resolve) => {
execFile('git', ['rev-list', '--count', `${mainBranch}..${branch}`], { cwd }, (err, stdout) => {
if (err) {
resolve(-1)
return
}
resolve(parseInt(stdout.trim(), 10) || 0)
})
})
}

const NO_PR: PrInfo = { state: 'none', number: 0, url: '' }

function getPrStatus(cwd: string, branch: string): Promise<PrInfo> {
return new Promise((resolve) => {
execFile(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'all',
'--json',
'state,number,url',
'--limit',
'1'
],
{ cwd },
(err, stdout) => {
if (err) {
resolve(NO_PR)
return
}
try {
const prs = JSON.parse(stdout.trim())
if (prs.length === 0) {
resolve(NO_PR)
return
}
const pr = prs[0]
resolve({
state: (pr.state as string).toLowerCase() as PrState,
number: pr.number,
url: pr.url
})
} catch {
resolve(NO_PR)
}
}
)
})
}

function isWorktreeDirty(worktreePath: string): Promise<boolean> {
return new Promise((resolve) => {
execFile('git', ['status', '--porcelain'], { cwd: worktreePath }, (err, stdout) => {
if (err) {
resolve(false)
return
}
resolve(stdout.trim().length > 0)
})
})
}

export function getBranchDetails(cwd: string): Promise<BranchDetail[]> {
Expand Down Expand Up @@ -161,31 +252,136 @@ export function getBranchDetails(cwd: string): Promise<BranchDetail[]> {
}

const wtByBranch = new Map(worktrees.map((w) => [w.branch, w.path]))
const mainBranch = 'origin/' + (worktrees.find((w) => w.isMain)?.branch ?? 'main')

const branches: BranchDetail[] = stdout
const parsed = stdout
.trim()
.split('\n')
.filter((line) => line.length > 0)
.map((line) => {
const obj = JSON.parse(line)
.map((line) => JSON.parse(line))

const branches: BranchDetail[] = await Promise.all(
parsed.map(async (obj) => {
const name: string = obj.name
const worktreePath = wtByBranch.get(name) || ''
const isMain = name === mainBranch

const [aheadCount, dirty, pr] = await Promise.all([
isMain ? Promise.resolve(0) : getAheadCount(cwd, name, mainBranch),
worktreePath ? isWorktreeDirty(worktreePath) : Promise.resolve(false),
isMain ? Promise.resolve(NO_PR) : getPrStatus(cwd, name)
])

return {
name: obj.name,
name,
isHead: obj.head === '*',
upstream: obj.upstream || '',
gone: obj.track.includes('gone'),
lastCommitDate: obj.date || '',
lastCommitRelative: obj.relative || '',
lastCommitSubject: obj.subject || '',
worktreePath: wtByBranch.get(obj.name) || ''
worktreePath,
aheadCount,
dirty,
pr
}
})
)

resolve(branches)
}
)
})
}

export interface BranchFile {
path: string
status: 'A' | 'M' | 'D' | 'R' | 'U'
source: 'committed' | 'uncommitted'
}

/** List files changed on a branch (committed vs origin/main + uncommitted in worktree) */
export async function getBranchFiles(
cwd: string,
branch: string,
worktreePath: string
): Promise<BranchFile[]> {
const files: BranchFile[] = []

// Committed changes: branch vs origin/main
const committed = await new Promise<string>((resolve) => {
execFile('git', ['diff', '--name-status', 'origin/main...' + branch], { cwd }, (err, stdout) =>
resolve(err ? '' : stdout)
)
})

for (const line of committed.trim().split('\n')) {
if (!line) continue
const [statusRaw, ...pathParts] = line.split('\t')
const status = statusRaw.charAt(0) as BranchFile['status']
const path = pathParts[pathParts.length - 1] // handles renames (R\told\tnew)
if (path) files.push({ path, status, source: 'committed' })
}

// Uncommitted changes in the worktree
if (worktreePath) {
const uncommitted = await new Promise<string>((resolve) => {
execFile(
'git',
['status', '--porcelain', '--no-renames'],
{ cwd: worktreePath },
(err, stdout) => resolve(err ? '' : stdout)
)
})

for (const line of uncommitted.trim().split('\n')) {
if (!line) continue
const xy = line.substring(0, 2)
const path = line.substring(3)
let status: BranchFile['status'] = 'M'
if (xy.includes('?')) status = 'A'
else if (xy.includes('D')) status = 'D'
else if (xy.includes('U')) status = 'U'
files.push({ path, status, source: 'uncommitted' })
}
}

return files
}

/** Get diff for a single file — either committed (branch vs origin/main) or uncommitted (worktree vs HEAD) */
export function getBranchDiff(
cwd: string,
branch: string,
filePath: string,
source: 'committed' | 'uncommitted',
worktreePath: string
): Promise<string> {
return new Promise((resolve) => {
if (source === 'committed') {
execFile('git', ['diff', 'origin/main...' + branch, '--', filePath], { cwd }, (err, stdout) =>
resolve(err && !stdout ? '' : stdout || '')
)
} else {
// Uncommitted: run in the worktree directory
const dir = worktreePath || cwd
execFile('git', ['diff', 'HEAD', '--', filePath], { cwd: dir }, (_err, stdout) => {
if (stdout && stdout.trim()) {
resolve(stdout)
} else {
// No diff from HEAD — file is likely untracked, try --no-index
execFile(
'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd: dir },
(_err2, stdout2) => resolve(stdout2 || '')
)
}
})
}
})
}

export function deleteBranch(cwd: string, branch: string, force: boolean): Promise<void> {
return new Promise((resolve, reject) => {
execFile('git', ['branch', force ? '-D' : '-d', branch], { cwd }, (err) => {
Expand Down
21 changes: 19 additions & 2 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ChangedFile } from '../main/fileWatcher'
import type { ActivityState } from '../main/activityWatcher'
import type { SessionInfo } from '../main/sessionManager'
import type { PersistedState } from '../main/store'
import type { WorktreeInfo, BranchDetail } from '../main/worktree'
import type { WorktreeInfo, BranchDetail, BranchFile } from '../main/worktree'
import type { GitHubRepo, GitHubPR, GitHubIssue } from '../main/github'

export type UpdateStatus = { status: 'available' | 'ready'; version: string }
Expand Down Expand Up @@ -49,6 +49,14 @@ export interface KonductorAPI {
listPullRequests: (cwd: string, state: string) => Promise<GitHubPR[]>
listIssues: (cwd: string, state: string) => Promise<GitHubIssue[]>
openExternal: (url: string) => Promise<void>
getBranchFiles: (cwd: string, branch: string, worktreePath: string) => Promise<BranchFile[]>
getBranchDiff: (
cwd: string,
branch: string,
filePath: string,
source: 'committed' | 'uncommitted',
worktreePath: string
) => Promise<string>
}

const api: KonductorAPI = {
Expand Down Expand Up @@ -149,7 +157,16 @@ const api: KonductorAPI = {
listPullRequests: (cwd: string, state: string) =>
ipcRenderer.invoke('list-pull-requests', cwd, state),
listIssues: (cwd: string, state: string) => ipcRenderer.invoke('list-issues', cwd, state),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url)
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
getBranchFiles: (cwd: string, branch: string, worktreePath: string) =>
ipcRenderer.invoke('get-branch-files', cwd, branch, worktreePath),
getBranchDiff: (
cwd: string,
branch: string,
filePath: string,
source: 'committed' | 'uncommitted',
worktreePath: string
) => ipcRenderer.invoke('get-branch-diff', cwd, branch, filePath, source, worktreePath)
}

if (process.contextIsolated) {
Expand Down
Loading
Loading