Stage, unstage, and discard individual diff hunks
Add per-hunk staging, unstaging, and discarding to the Changes diff
view, so a single change region inside a file can be acted on in
isolation instead of forcing whole-file stage/revert. The change is
wired end-to-end across the web server, the shared UI runtime API
contract, and the VS Code extension, with Electron inheriting the web
path unchanged (it boots the server in-process).
Server
------
- New `applyHunk(directory, filePath, { patch, action })` in
packages/web/server/lib/git/service.js. It resolves the repository
context and validates the file path with the same helpers used by
stageFiles/unstageFiles (resolveGitFileContext +
validateRepositoryFilePaths), then writes the single-hunk patch to a
temporary file in the OS temp dir (never inside the repo, so it
cannot show up as an untracked file) and runs `git apply` with flags
chosen per action:
stage -> git apply --cached (working tree -> index)
unstage -> git apply --cached --reverse (index -> working tree)
discard -> git apply --reverse (revert in working tree)
A `git apply --check` runs first with the same flags, so a stale
hunk that no longer applies fails with a clear "Hunk no longer
applies - refresh and try again" message instead of leaving a
partial mutation. The patch's target path is parsed and must match
the requested file (with /dev/null tolerated for new/deleted files),
preventing a patch from silently targeting a different path. The
whole operation runs inside withGitIndexMutationQueue to avoid
racing with concurrent stage/unstage. The temp file is removed in a
finally block.
- New `POST /api/git/apply-hunk` route in routes.js, registered
alongside stage/unstage. Validates directory, path, non-empty patch,
and action before delegating.
- DOCUMENTATION.md updated with the new service entry.
Patch extraction
----------------
- packages/ui/src/lib/diff/patchFileDiff.ts gains
splitPatchIntoHunks(patch) and extractHunkPatch(patch, hunkIndex).
They keep the original file header (diff --git / index / --- / +++)
and emit exactly one @@ hunk per standalone patch, which is what
`git apply` expects. Each emitted patch is guaranteed to end with a
trailing newline (without it git apply reports "corrupt patch").
Runtime API contract
--------------------
- GitAPI (packages/ui/src/lib/api/types.ts) gains optional
stageGitHunk / unstageGitHunk / revertGitHunk, matching the
stageGitFiles? / unstageGitFiles? precedent so runtimes that do not
support it degrade gracefully.
- gitApi.ts delegates to the registered runtime git API, falling back
to gitApiHttp, exactly like the existing whole-file helpers.
- gitApiHttp.ts posts to /api/git/apply-hunk.
- Web runtime composes the three methods in packages/web/src/api/git.ts.
VS Code parity
--------------
- packages/vscode/src/gitService.ts adds applyGitHunk(), implemented
natively with the existing execGit helper + a temp patch file +
`git apply` (--cached / --cached --reverse / --reverse), mirroring
the server's --check-first safety and temp-file cleanup.
- bridge-git-runtime.ts handles the new api:git/apply-hunk bridge
message; webview/api/git.ts sends it. VS Code users get identical
stage/unstage/discard-hunk behavior.
UI
--
- New DiffHunkActions component renders a compact per-hunk strip
above each expanded file diff in the Changes view. Each hunk chip
shows its +additions / -deletions counts and offers:
working scope -> Stage + Discard
staged scope -> Unstage
Clicking extracts that hunk's standalone patch via
extractHunkPatch(patch, hunkIndex) and calls the runtime git API.
Because the chip index comes directly from fileDiff.hunks[] and the
patch is sliced in the same order, the hunk the user sees is always
the hunk that gets applied. While any action is in flight all buttons
disable to prevent conflicting concurrent mutations; the per-hunk
spinner reflects in-flight state.
- DiffView wires DiffHunkActions into InlineDiffViewer (text diffs
only; binary/image and full-file-content modes are excluded since
they have no patch). MultiFileDiffEntry passes directory/staged
through and handles onHunkApplied by bumping the diff reload nonce
(so the file's diff re-fetches and the affected hunk disappears)
and refreshing git status (so file counts and the staged/changed
scope update). Hunk actions are therefore available wherever the
default patch-context diff is shown.
i18n
----
- 10 new keys (diffView.hunk.*) added to all 9 locales (en, es, fr,
ko, pl, pt-BR, uk, zh-CN, zh-TW), including stage/unstage/discard
labels, tooltips with the hunk index, a stale-hunk error message,
and an unsupported-runtime fallback.
Tests
-----
- packages/ui/src/lib/diff/patchFileDiff.test.ts covers
splitHunks/extractHunkPatch: multi-hunk split, header preservation,
single-hunk and empty patches, out-of-range indices.
- service.test.js adds an applyHunk suite that builds real temp repos
with two separate hunks and verifies: staging one hunk leaves the
other unstaged, discarding reverts only the targeted hunk in the
working tree, unstaging removes only one hunk from the index, and a
retargeted patch (different file path) is rejected. Also covers
invalid-action / missing-hunk-header validation.
- packages/web/src/api/git.test.ts mock completed with the new methods
(and previously-missing exports that prevented the test from
loading) and asserts the three hunk methods are exposed.
- routes.test.js continues to pass under bun.
CHANGELOG updated under [Unreleased].
This commit is contained in:
@@ -33,6 +33,7 @@ The following functions are exported and used by the web server:
|
||||
- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
|
||||
- `stageFile(directory, filePath)`: Add one file path to the index.
|
||||
- `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content.
|
||||
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file.
|
||||
|
||||
### Branch Operations
|
||||
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
|
||||
|
||||
@@ -436,6 +436,33 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/apply-hunk', async (req, res) => {
|
||||
const { applyHunk } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const { path: filePath, patch, action } = req.body || {};
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return res.status(400).json({ error: 'path parameter is required' });
|
||||
}
|
||||
if (typeof patch !== 'string' || !patch.trim()) {
|
||||
return res.status(400).json({ error: 'patch is required' });
|
||||
}
|
||||
if (action !== 'stage' && action !== 'unstage' && action !== 'discard') {
|
||||
return res.status(400).json({ error: 'action must be stage, unstage, or discard' });
|
||||
}
|
||||
|
||||
await applyHunk(directory, filePath, { patch, action });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to apply git hunk:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to apply git hunk' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/pull', async (req, res) => {
|
||||
const { pull } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -2555,6 +2555,75 @@ export async function revertFile(directory, filePath, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
const HUNK_ACTION_FLAGS = {
|
||||
stage: ['--cached'],
|
||||
unstage: ['--cached', '--reverse'],
|
||||
discard: ['--reverse'],
|
||||
};
|
||||
|
||||
const extractPatchTargetPath = (patch) => {
|
||||
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+(?:[ab]\/)?([^\s\t]+)/gm)];
|
||||
const realTargets = matches
|
||||
.map((match) => match[1])
|
||||
.filter((value) => value && value !== '/dev/null');
|
||||
return realTargets[0] || null;
|
||||
};
|
||||
|
||||
const writeTempPatchFile = async (patch) => {
|
||||
const tmpDir = os.tmpdir();
|
||||
const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
||||
await fsp.writeFile(tmpPath, patch, 'utf8');
|
||||
return tmpPath;
|
||||
};
|
||||
|
||||
export async function applyHunk(directory, filePath, options = {}) {
|
||||
const action = options?.action;
|
||||
if (!action || !HUNK_ACTION_FLAGS[action]) {
|
||||
throw new Error('Invalid hunk action');
|
||||
}
|
||||
const patch = typeof options?.patch === 'string' ? options.patch : '';
|
||||
if (!patch.trim()) {
|
||||
throw new Error('patch is required to apply a hunk');
|
||||
}
|
||||
if (!/^@@\s/m.test(patch)) {
|
||||
throw new Error('patch does not contain a hunk header');
|
||||
}
|
||||
|
||||
return withGitIndexMutationQueue(directory, async () => {
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]);
|
||||
|
||||
const targetPath = extractPatchTargetPath(patch);
|
||||
if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
|
||||
throw new Error('patch target path does not match the requested file');
|
||||
}
|
||||
|
||||
const flags = HUNK_ACTION_FLAGS[action];
|
||||
let tmpPath = null;
|
||||
try {
|
||||
tmpPath = await writeTempPatchFile(patch);
|
||||
|
||||
try {
|
||||
await git.raw(['apply', ...flags, '--check', tmpPath]);
|
||||
} catch (checkError) {
|
||||
const text = parseGitErrorText(checkError);
|
||||
throw new Error(
|
||||
text
|
||||
? `Hunk no longer applies — refresh and try again.\n${text}`
|
||||
: 'Hunk no longer applies — refresh and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
await git.raw(['apply', ...flags, tmpPath]);
|
||||
} finally {
|
||||
if (tmpPath) {
|
||||
await fsp.rm(tmpPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function collectDiffs(directory, files = []) {
|
||||
const results = [];
|
||||
for (const filePath of files) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
revertCommit,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
applyHunk,
|
||||
getDiff,
|
||||
} from './service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -122,6 +124,124 @@ describe('git index path validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyHunk (per-hunk stage / unstage / discard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal unified-diff splitter: returns standalone per-hunk patches. */
|
||||
const splitHunks = (patch) => {
|
||||
const lines = patch.split(/\r?\n/);
|
||||
const headerEnd = lines.findIndex((line) => /^@@\s/.test(line));
|
||||
if (headerEnd === -1) return [];
|
||||
const header = lines.slice(0, headerEnd);
|
||||
const hunks = [];
|
||||
for (let i = headerEnd; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (/^@@\s/.test(line)) hunks.push([...header, line]);
|
||||
else if (hunks.length > 0) hunks[hunks.length - 1].push(line);
|
||||
}
|
||||
return hunks.map((hunk) => hunk.join('\n'))
|
||||
.filter((hunk) => hunk.trim().length > 0)
|
||||
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
|
||||
};
|
||||
|
||||
const writeFile = (repo, name, contents) =>
|
||||
fs.promises.writeFile(path.join(repo, name), contents, 'utf8');
|
||||
|
||||
// Build a 20-line file so changes on line 1 and line 20 stay in separate hunks
|
||||
// (default 3-line diff context would merge closer edits into one hunk).
|
||||
const makeFile = (first, last) =>
|
||||
[first, ...Array.from({ length: 18 }, (_, i) => `line${i + 2}`), last].join('\n') + '\n';
|
||||
const ORIGINAL_FILE = makeFile('line1', 'line20');
|
||||
const EDITED_FILE = makeFile('TOP', 'BOTTOM');
|
||||
|
||||
const readWorking = (repo) => fs.promises.readFile(path.join(repo, 'file.txt'), 'utf8').then((c) => c.replace(/\r\n/g, '\n'));
|
||||
const readStaged = async (git) => (await git.raw(['show', ':file.txt'])).replace(/\r\n/g, '\n');
|
||||
|
||||
describe('applyHunk', () => {
|
||||
it('rejects an invalid action or a patch without a hunk header', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: '@@ -1 +1 @@\n a\n', action: 'bogus' })).rejects.toThrow(
|
||||
'Invalid hunk action'
|
||||
);
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: 'no hunk here', action: 'stage' })).rejects.toThrow(
|
||||
'hunk header'
|
||||
);
|
||||
});
|
||||
|
||||
it('stages a single hunk while leaving the rest unstaged', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial');
|
||||
|
||||
await writeFile(tmpDir, 'file.txt', EDITED_FILE);
|
||||
const diff = await getDiff(tmpDir, { path: 'file.txt' });
|
||||
const hunks = splitHunks(diff);
|
||||
expect(hunks.length).toBe(2);
|
||||
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'stage' });
|
||||
|
||||
expect(await readStaged(git)).toBe(makeFile('TOP', 'line20'));
|
||||
expect(await readWorking(tmpDir)).toBe(EDITED_FILE);
|
||||
});
|
||||
|
||||
it('discards a single hunk from the working tree', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial');
|
||||
|
||||
await writeFile(tmpDir, 'file.txt', EDITED_FILE);
|
||||
const diff = await getDiff(tmpDir, { path: 'file.txt' });
|
||||
const hunks = splitHunks(diff);
|
||||
expect(hunks.length).toBe(2);
|
||||
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: hunks[1], action: 'discard' });
|
||||
|
||||
expect(await readWorking(tmpDir)).toBe(makeFile('TOP', 'line20'));
|
||||
});
|
||||
|
||||
it('unstages a single hunk from the index', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial');
|
||||
|
||||
await writeFile(tmpDir, 'file.txt', EDITED_FILE);
|
||||
await git.add('file.txt');
|
||||
|
||||
const stagedDiff = await getDiff(tmpDir, { path: 'file.txt', staged: true });
|
||||
const hunks = splitHunks(stagedDiff);
|
||||
expect(hunks.length).toBe(2);
|
||||
|
||||
await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'unstage' });
|
||||
|
||||
// Only the first hunk (line1 -> TOP) was reverted in the index;
|
||||
// the second hunk (BOTTOM) stays staged.
|
||||
expect(await readStaged(git)).toBe(makeFile('line1', 'BOTTOM'));
|
||||
});
|
||||
|
||||
it('rejects a patch whose target path does not match the requested file', async () => {
|
||||
if (!canRunGit()) return;
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE);
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial');
|
||||
await writeFile(tmpDir, 'file.txt', makeFile('CHANGED', 'line20'));
|
||||
|
||||
const diff = await getDiff(tmpDir, { path: 'file.txt' });
|
||||
const [hunk] = splitHunks(diff);
|
||||
const retargeted = hunk.replace(/file\.txt/g, 'other.txt');
|
||||
await expect(applyHunk(tmpDir, 'file.txt', { patch: retargeted, action: 'stage' })).rejects.toThrow(
|
||||
'patch target path does not match'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,9 @@ vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({
|
||||
stageGitFiles: vi.fn(),
|
||||
unstageGitFile: vi.fn(),
|
||||
unstageGitFiles: vi.fn(),
|
||||
stageGitHunk: vi.fn(),
|
||||
unstageGitHunk: vi.fn(),
|
||||
revertGitHunk: vi.fn(),
|
||||
isLinkedWorktree: vi.fn(),
|
||||
getGitBranches: vi.fn(),
|
||||
deleteGitBranch: vi.fn(),
|
||||
@@ -55,6 +58,16 @@ vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({
|
||||
stash: vi.fn(),
|
||||
stashPop: vi.fn(),
|
||||
getConflictDetails: vi.fn(),
|
||||
checkoutCommit: vi.fn(),
|
||||
cherryPick: vi.fn(),
|
||||
revertCommit: vi.fn(),
|
||||
resetToCommit: vi.fn(),
|
||||
getCommitFileDiff: vi.fn(),
|
||||
previewGitWorktree: vi.fn(),
|
||||
getGitWorktreeBootstrapStatus: vi.fn(),
|
||||
discoverGitCredentials: vi.fn(),
|
||||
getGlobalGitIdentity: vi.fn(),
|
||||
getRemoteUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('createWebGitAPI', () => {
|
||||
@@ -64,5 +77,8 @@ describe('createWebGitAPI', () => {
|
||||
|
||||
expect(typeof api.stageGitFiles).toBe('function');
|
||||
expect(typeof api.unstageGitFiles).toBe('function');
|
||||
expect(typeof api.stageGitHunk).toBe('function');
|
||||
expect(typeof api.unstageGitHunk).toBe('function');
|
||||
expect(typeof api.revertGitHunk).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
stageGitFiles: gitApiHttp.stageGitFiles,
|
||||
unstageGitFile: gitApiHttp.unstageGitFile,
|
||||
unstageGitFiles: gitApiHttp.unstageGitFiles,
|
||||
stageGitHunk: gitApiHttp.stageGitHunk,
|
||||
unstageGitHunk: gitApiHttp.unstageGitHunk,
|
||||
revertGitHunk: gitApiHttp.revertGitHunk,
|
||||
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
|
||||
getGitBranches: gitApiHttp.getGitBranches,
|
||||
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
||||
|
||||
Reference in New Issue
Block a user