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].
653 lines
24 KiB
JavaScript
653 lines
24 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import simpleGit from 'simple-git';
|
|
|
|
import {
|
|
checkoutCommit,
|
|
cherryPick,
|
|
getStatus,
|
|
removeWorktree,
|
|
resolvePrimaryWorktreeRoot,
|
|
resolveWorktreeTopLevel,
|
|
resetToCommit,
|
|
resolveBaseRefForLog,
|
|
revertCommit,
|
|
stageFiles,
|
|
unstageFiles,
|
|
applyHunk,
|
|
getDiff,
|
|
} from './service.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared test infrastructure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const tempDirs = [];
|
|
|
|
/** Create a temp dir and register it for afterEach cleanup. */
|
|
const createTempDir = () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
};
|
|
|
|
const runGit = (cwd, args) =>
|
|
execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const canRunGit = () => {
|
|
try {
|
|
execFileSync('git', ['--version'], { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Create a temp repo using simple-git (for tests that need its assertion API).
|
|
* The dir is registered in tempDirs so afterEach handles cleanup automatically.
|
|
*/
|
|
async function createTempRepo() {
|
|
const tmpDir = createTempDir();
|
|
const git = simpleGit(tmpDir);
|
|
await git.init();
|
|
await git.addConfig('user.name', 'Test User', false, 'local');
|
|
await git.addConfig('user.email', 'test@example.com', false, 'local');
|
|
await git.raw(['symbolic-ref', 'HEAD', 'refs/heads/main']);
|
|
return { tmpDir, git };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveBaseRefForLog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resolveBaseRefForLog', () => {
|
|
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
|
|
const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main';
|
|
expect(await resolveBaseRefForLog('main', checkRef)).toBe('main');
|
|
});
|
|
|
|
it('falls back to origin/<from> when local ref cannot be resolved but origin can', async () => {
|
|
const checkRef = async (ref) => ref === 'refs/remotes/origin/main';
|
|
expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main');
|
|
});
|
|
|
|
it('returns the original ref when neither local nor origin ref can be resolved', async () => {
|
|
const checkRef = async () => false;
|
|
expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
|
|
});
|
|
|
|
it('returns undefined when from is undefined', async () => {
|
|
const checkRef = async () => true;
|
|
expect(await resolveBaseRefForLog(undefined, checkRef)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when from is an empty string', async () => {
|
|
const checkRef = async () => true;
|
|
expect(await resolveBaseRefForLog('', checkRef)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when from is a whitespace-only string', async () => {
|
|
const checkRef = async () => true;
|
|
expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// git index path validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('git index path validation', () => {
|
|
it('rejects stage paths outside the repository before invoking git', async () => {
|
|
await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow(
|
|
'Path is outside repository: ../secret.txt'
|
|
);
|
|
});
|
|
|
|
it('rejects unstage paths outside the repository before invoking git', async () => {
|
|
await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow(
|
|
'Path is outside repository: ../secret.txt'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('getStatus', () => {
|
|
it('handles repositories without upstream tracking', async () => {
|
|
if (!canRunGit()) return;
|
|
|
|
const repo = createTempDir();
|
|
runGit(repo, ['init', '-b', 'main']);
|
|
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
runGit(repo, ['add', 'README.md']);
|
|
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
|
|
await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// worktree root resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('worktree root resolution', () => {
|
|
it('resolves the git toplevel for a repository subdirectory', async () => {
|
|
if (!canRunGit()) return;
|
|
|
|
const repo = createTempDir();
|
|
const subdirectory = path.join(repo, 'packages', 'app');
|
|
runGit(repo, ['init', '-b', 'main']);
|
|
fs.mkdirSync(subdirectory, { recursive: true });
|
|
|
|
await expect(resolveWorktreeTopLevel(subdirectory)).resolves.toEqual({ root: fs.realpathSync(repo) });
|
|
});
|
|
|
|
it('resolves the primary worktree root from a linked worktree', async () => {
|
|
if (!canRunGit()) return;
|
|
|
|
const repo = createTempDir();
|
|
const worktree = createTempDir();
|
|
runGit(repo, ['init', '-b', 'main']);
|
|
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
runGit(repo, ['add', 'README.md']);
|
|
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
fs.rmSync(worktree, { recursive: true, force: true });
|
|
runGit(repo, ['worktree', 'add', '-b', 'feature/test', worktree, 'HEAD']);
|
|
|
|
await expect(resolvePrimaryWorktreeRoot(worktree)).resolves.toEqual({ root: fs.realpathSync(repo) });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// removeWorktree
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('removeWorktree', () => {
|
|
it('forgets unmanaged orphan worktree entries without deleting files', async () => {
|
|
if (!canRunGit()) return;
|
|
|
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
|
const dataHome = createTempDir();
|
|
process.env.XDG_DATA_HOME = dataHome;
|
|
|
|
try {
|
|
const repo = createTempDir();
|
|
const sentinel = createTempDir();
|
|
const canary = path.join(sentinel, 'canary.txt');
|
|
|
|
runGit(repo, ['init', '-b', 'main']);
|
|
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
runGit(repo, ['add', 'README.md']);
|
|
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
fs.writeFileSync(canary, 'sentinel');
|
|
|
|
await expect(removeWorktree(repo, {
|
|
directory: sentinel,
|
|
deleteLocalBranch: false,
|
|
})).resolves.toBe(true);
|
|
expect(fs.existsSync(canary)).toBe(true);
|
|
} finally {
|
|
if (previousXdgDataHome === undefined) {
|
|
delete process.env.XDG_DATA_HOME;
|
|
} else {
|
|
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// checkoutCommit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('checkoutCommit', () => {
|
|
it('checks out a valid commit and puts the repo in detached HEAD state', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'first', 'utf8');
|
|
await git.add('file.txt');
|
|
const firstCommit = await git.commit('First commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'second', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Second commit');
|
|
|
|
const result = await checkoutCommit(tmpDir, firstCommit.commit);
|
|
expect(result).toEqual({ success: true });
|
|
|
|
const status = await git.status();
|
|
expect(status.detached).toBe(true);
|
|
});
|
|
|
|
it('throws an error for an invalid/nonexistent hash', async () => {
|
|
const { tmpDir } = await createTempRepo();
|
|
await expect(checkoutCommit(tmpDir, 'invalidhash123')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// cherryPick
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('cherryPick', () => {
|
|
it('cherry-picks a commit that applies cleanly', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Initial commit');
|
|
|
|
await git.checkoutBranch('feature', 'HEAD');
|
|
await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const featureCommit = await git.commit('Add line3');
|
|
|
|
await git.checkout('main');
|
|
const result = await cherryPick(tmpDir, featureCommit.commit);
|
|
expect(result).toEqual({ success: true, conflict: false });
|
|
|
|
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
expect(content).toBe('line1\nline2\nline3\n');
|
|
});
|
|
|
|
it('returns conflict info when cherry-picking a conflicting commit', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Initial commit');
|
|
|
|
await git.checkoutBranch('feature', 'HEAD');
|
|
await fs.promises.writeFile(filePath, 'line1\nfeature-line2\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const featureCommit = await git.commit('Change line2 in feature');
|
|
|
|
await git.checkout('main');
|
|
await fs.promises.writeFile(filePath, 'line1\nmain-line2\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Change line2 in main');
|
|
|
|
const result = await cherryPick(tmpDir, featureCommit.commit);
|
|
expect(result.success).toBe(false);
|
|
expect(result.conflict).toBe(true);
|
|
expect(Array.isArray(result.conflictFiles)).toBe(true);
|
|
expect(result.conflictFiles.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('throws for an invalid/nonexistent hash', async () => {
|
|
const { tmpDir } = await createTempRepo();
|
|
await expect(cherryPick(tmpDir, 'deadbeef00000000')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// revertCommit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('revertCommit', () => {
|
|
it('reverts a commit and stages the revert changes', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Initial commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const changeCommit = await git.commit('Add line3');
|
|
|
|
const result = await revertCommit(tmpDir, changeCommit.commit);
|
|
expect(result).toEqual({ success: true, conflict: false });
|
|
|
|
const status = await git.status();
|
|
expect(status.staged.length).toBeGreaterThan(0);
|
|
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
expect(content).toBe('line1\nline2\n');
|
|
});
|
|
|
|
it('returns conflict info when reverting causes a conflict', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Initial commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'line1\nchanged-a\nline3\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const commitA = await git.commit('Change line2 to changed-a');
|
|
|
|
await fs.promises.writeFile(filePath, 'line1\nchanged-b\nline3\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Change line2 to changed-b');
|
|
|
|
const result = await revertCommit(tmpDir, commitA.commit);
|
|
expect(result.success).toBe(false);
|
|
expect(result.conflict).toBe(true);
|
|
expect(Array.isArray(result.conflictFiles)).toBe(true);
|
|
expect(result.conflictFiles.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('throws for an invalid/nonexistent hash', async () => {
|
|
const { tmpDir } = await createTempRepo();
|
|
await expect(revertCommit(tmpDir, 'deadbeef00000000')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resetToCommit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resetToCommit', () => {
|
|
it('soft reset moves HEAD without touching the working tree', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const firstCommit = await git.commit('First commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Second commit');
|
|
|
|
const result = await resetToCommit(tmpDir, firstCommit.commit, 'soft');
|
|
expect(result).toEqual({ success: true });
|
|
|
|
const log = await git.log();
|
|
expect(log.latest.hash).toBe(firstCommit.commit);
|
|
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
expect(content).toBe('second\n');
|
|
|
|
const status = await git.status();
|
|
expect(status.staged.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('mixed reset moves HEAD and unstages changes', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const firstCommit = await git.commit('First commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Second commit');
|
|
|
|
const result = await resetToCommit(tmpDir, firstCommit.commit, 'mixed');
|
|
expect(result).toEqual({ success: true });
|
|
|
|
const log = await git.log();
|
|
expect(log.latest.hash).toBe(firstCommit.commit);
|
|
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
expect(content).toBe('second\n');
|
|
|
|
const status = await git.status();
|
|
expect(status.staged.length).toBe(0);
|
|
expect(status.modified.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('hard reset with clean working tree succeeds', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const firstCommit = await git.commit('First commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Second commit');
|
|
|
|
const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard');
|
|
expect(result).toEqual({ success: true });
|
|
|
|
const log = await git.log();
|
|
expect(log.latest.hash).toBe(firstCommit.commit);
|
|
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
expect(content).toBe('first\n');
|
|
|
|
const status = await git.status();
|
|
expect(status.isClean()).toBe(true);
|
|
});
|
|
|
|
it('hard reset with dirty working tree without force throws', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const firstCommit = await git.commit('First commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Second commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'dirty\n', 'utf8');
|
|
|
|
await expect(resetToCommit(tmpDir, firstCommit.commit, 'hard')).rejects.toThrow(
|
|
'Cannot hard reset: uncommitted changes in working tree'
|
|
);
|
|
});
|
|
|
|
it('hard reset with dirty working tree with force succeeds', async () => {
|
|
const { tmpDir, git } = await createTempRepo();
|
|
const filePath = path.join(tmpDir, 'file.txt');
|
|
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
|
await git.add('file.txt');
|
|
const firstCommit = await git.commit('First commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
|
await git.add('file.txt');
|
|
await git.commit('Second commit');
|
|
|
|
await fs.promises.writeFile(filePath, 'dirty\n', 'utf8');
|
|
|
|
const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard', true);
|
|
expect(result).toEqual({ success: true });
|
|
|
|
const log = await git.log();
|
|
expect(log.latest.hash).toBe(firstCommit.commit);
|
|
const content = await fs.promises.readFile(filePath, 'utf8');
|
|
expect(content).toBe('first\n');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// hash validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('hash validation', () => {
|
|
it('checkoutCommit rejects non-hex hash', async () => {
|
|
await expect(checkoutCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('checkoutCommit rejects ref name', async () => {
|
|
await expect(checkoutCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('checkoutCommit accepts valid 40-char hex format', async () => {
|
|
await expect(
|
|
checkoutCommit('/tmp', '1234567890abcdef1234567890abcdef12345678')
|
|
).rejects.not.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('cherryPick rejects non-hex hash', async () => {
|
|
await expect(cherryPick('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('cherryPick rejects ref name', async () => {
|
|
await expect(cherryPick('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('cherryPick accepts valid 40-char hex format', async () => {
|
|
await expect(
|
|
cherryPick('/tmp', '1234567890abcdef1234567890abcdef12345678')
|
|
).rejects.not.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('revertCommit rejects non-hex hash', async () => {
|
|
await expect(revertCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('revertCommit rejects ref name', async () => {
|
|
await expect(revertCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('revertCommit accepts valid 40-char hex format', async () => {
|
|
await expect(
|
|
revertCommit('/tmp', '1234567890abcdef1234567890abcdef12345678')
|
|
).rejects.not.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('resetToCommit rejects non-hex hash', async () => {
|
|
await expect(resetToCommit('/tmp', '--hard', 'soft')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('resetToCommit rejects ref name', async () => {
|
|
await expect(resetToCommit('/tmp', 'HEAD', 'soft')).rejects.toThrow('Invalid commit hash');
|
|
});
|
|
|
|
it('resetToCommit accepts valid 40-char hex format', async () => {
|
|
await expect(
|
|
resetToCommit('/tmp', '1234567890abcdef1234567890abcdef12345678', 'soft')
|
|
).rejects.not.toThrow('Invalid commit hash');
|
|
});
|
|
});
|