feat: add revert/discard support for untracked files in source control (#140)

- Enhance discardChanges to handle untracked files by deleting them instead of git checkout
- Add path traversal protection to prevent discarding files outside the worktree
- Extract source control action logic into getSourceControlActions helper
- Show revert button for untracked files with confirmation dialog
- Add tests for both backend and frontend logic

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinjing
2026-03-27 12:35:19 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 00332fbd3e
commit cff5b034e4
5 changed files with 166 additions and 28 deletions
+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileAsyncMock, readFileMock, rmMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn()
}))
vi.mock('util', async () => {
const actual = await vi.importActual('util')
return {
...actual,
promisify: vi.fn(() => execFileAsyncMock)
}
})
vi.mock('fs/promises', () => ({
readFile: readFileMock,
rm: rmMock
}))
import { discardChanges } from './status'
describe('discardChanges', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
readFileMock.mockReset()
rmMock.mockReset()
})
it('restores tracked files from HEAD', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: 'src/file.ts\n' })
execFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await discardChanges('/repo', 'src/file.ts')
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
1,
'git',
['ls-files', '--error-unmatch', '--', 'src/file.ts'],
{
cwd: '/repo',
encoding: 'utf-8'
}
)
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
2,
'git',
['restore', '--worktree', '--source=HEAD', '--', 'src/file.ts'],
{
cwd: '/repo',
encoding: 'utf-8'
}
)
expect(rmMock).not.toHaveBeenCalled()
})
it('removes untracked files from disk', async () => {
execFileAsyncMock.mockRejectedValueOnce(new Error('not tracked'))
await discardChanges('/repo', 'src/new-file.ts')
expect(execFileAsyncMock).toHaveBeenCalledTimes(1)
expect(rmMock).toHaveBeenCalledWith('/repo/src/new-file.ts', {
force: true,
recursive: true
})
})
it('rejects paths that traverse outside the worktree', async () => {
await expect(discardChanges('/repo', '../../etc/passwd')).rejects.toThrow(
'resolves outside the worktree'
)
expect(execFileAsyncMock).not.toHaveBeenCalled()
expect(rmMock).not.toHaveBeenCalled()
})
})
+25 -6
View File
@@ -1,7 +1,7 @@
import { execFile } from 'child_process'
import { readFile } from 'fs/promises'
import { readFile, rm } from 'fs/promises'
import { promisify } from 'util'
import { join } from 'path'
import { join, resolve } from 'path'
import type { GitStatusEntry, GitFileStatus, GitDiffResult } from '../../shared/types'
const execFileAsync = promisify(execFile)
@@ -163,8 +163,27 @@ export async function unstageFile(worktreePath: string, filePath: string): Promi
* Discard working tree changes for a file.
*/
export async function discardChanges(worktreePath: string, filePath: string): Promise<void> {
await execFileAsync('git', ['checkout', '--', filePath], {
cwd: worktreePath,
encoding: 'utf-8'
})
const resolvedWorktree = resolve(worktreePath)
const resolvedTarget = resolve(worktreePath, filePath)
if (!resolvedTarget.startsWith(`${resolvedWorktree}/`)) {
throw new Error(`Path "${filePath}" resolves outside the worktree`)
}
let tracked = false
try {
await execFileAsync('git', ['ls-files', '--error-unmatch', '--', filePath], {
cwd: worktreePath,
encoding: 'utf-8'
})
tracked = true
} catch {
// File is not tracked by git
}
await (tracked
? execFileAsync('git', ['restore', '--worktree', '--source=HEAD', '--', filePath], {
cwd: worktreePath,
encoding: 'utf-8'
})
: rm(resolvedTarget, { force: true, recursive: true }))
}
@@ -15,6 +15,7 @@ import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { cn } from '@/lib/utils'
import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types'
import { getSourceControlActions } from './source-control-actions'
const STATUS_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
modified: FileEdit,
@@ -252,6 +253,7 @@ export default function SourceControl(): React.JSX.Element {
const dirPath = entry.path.includes('/')
? entry.path.slice(0, entry.path.lastIndexOf('/'))
: ''
const actions = getSourceControlActions(area)
return (
<div
@@ -277,28 +279,36 @@ export default function SourceControl(): React.JSX.Element {
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{area === 'unstaged' || area === 'untracked' ? (
<>
{area === 'unstaged' && (
<ActionButton
icon={Undo2}
title="Discard changes"
onClick={(e) => {
e.stopPropagation()
void handleDiscard(entry.path)
}}
/>
)}
<ActionButton
icon={Plus}
title="Stage"
onClick={(e) => {
e.stopPropagation()
void handleStage(entry.path)
}}
/>
</>
) : (
{actions.includes('discard') && (
<ActionButton
icon={Undo2}
title={area === 'untracked' ? 'Revert untracked file' : 'Discard changes'}
onClick={(e) => {
e.stopPropagation()
if (area === 'untracked') {
if (
!window.confirm(
`Delete untracked file "${entry.path}"? This cannot be undone.`
)
) {
return
}
}
void handleDiscard(entry.path)
}}
/>
)}
{actions.includes('stage') && (
<ActionButton
icon={Plus}
title="Stage"
onClick={(e) => {
e.stopPropagation()
void handleStage(entry.path)
}}
/>
)}
{actions.includes('unstage') && (
<ActionButton
icon={Minus}
title="Unstage"
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getSourceControlActions } from './source-control-actions'
describe('getSourceControlActions', () => {
it('shows discard and stage actions for untracked files', () => {
expect(getSourceControlActions('untracked')).toEqual(['discard', 'stage'])
})
it('shows discard and stage actions for unstaged files', () => {
expect(getSourceControlActions('unstaged')).toEqual(['discard', 'stage'])
})
it('shows unstage action for staged files', () => {
expect(getSourceControlActions('staged')).toEqual(['unstage'])
})
})
@@ -0,0 +1,15 @@
import type { GitStagingArea } from '../../../../shared/types'
export type SourceControlAction = 'discard' | 'stage' | 'unstage'
export function getSourceControlActions(area: GitStagingArea): SourceControlAction[] {
switch (area) {
case 'staged':
return ['unstage']
case 'unstaged':
case 'untracked':
return ['discard', 'stage']
default:
return []
}
}