Skip to content
Open
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
36 changes: 35 additions & 1 deletion app/src/main/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {
getWorktreeStatus,
stageFiles,
unstageFiles,
stageHunk,
unstageHunk,
createCommit,
checkoutWorktree,
resetWorktreeBranch,
} from '@sproutgit/git/staging';
import { fetchWorktree, pullWorktree, pushWorktreeBranch, getWorktreePushStatus } from '@sproutgit/git/remote';
import { getDiffFiles, getDiffContent, getWorkingDiff } from '@sproutgit/git/diff';
import { getDiffFiles, getDiffContent, getWorkingDiff, getUnstagedFileDiff, getStagedDiff } from '@sproutgit/git/diff';
import { createStash, listStashes, applyStash, popStash, dropStash } from '@sproutgit/git/stash';
import { cherryPickCommit } from '@sproutgit/git/cherry-pick';
import { handle } from './handle.js';
Expand Down Expand Up @@ -119,6 +121,26 @@ export function registerGitHandlers(configDb: ConfigDb): void {
return unstageFiles(args.worktreePath, args.paths);
});

handle(IPC.GIT_STAGE_HUNK, async (_e, args: {
worktreePath: string;
filePath: string;
hunkIndex: number;
lineIndices?: number[] | null;
}) => {
assertWorkingTreePath(args.worktreePath);
return stageHunk(args.worktreePath, args.filePath, args.hunkIndex, args.lineIndices);
});

handle(IPC.GIT_UNSTAGE_HUNK, async (_e, args: {
worktreePath: string;
filePath: string;
hunkIndex: number;
lineIndices?: number[] | null;
}) => {
assertWorkingTreePath(args.worktreePath);
return unstageHunk(args.worktreePath, args.filePath, args.hunkIndex, args.lineIndices);
});

handle(IPC.GIT_COMMIT, async (_e, args: { worktreePath: string; message: string }) => {
assertWorkingTreePath(args.worktreePath);
return createCommit(args.worktreePath, args.message);
Expand Down Expand Up @@ -194,6 +216,18 @@ export function registerGitHandlers(configDb: ConfigDb): void {
return result.diff;
});

handle(IPC.GIT_UNSTAGED_FILE_DIFF, async (_e, args: { worktreePath: string; file: string }) => {
if (!args.worktreePath || !existsSync(args.worktreePath)) return '';
assertWorkingTreePath(args.worktreePath);
return getUnstagedFileDiff(args.worktreePath, args.file);
});

handle(IPC.GIT_STAGED_FILE_DIFF, async (_e, args: { worktreePath: string; file?: string }) => {
if (!args.worktreePath || !existsSync(args.worktreePath)) return '';
assertWorkingTreePath(args.worktreePath);
return getStagedDiff(args.worktreePath, args.file ?? null);
});

// ── stash ─────────────────────────────────────────────────────────────────
handle(IPC.GIT_STASH_CREATE, async (_e, args: { worktreePath: string; message?: string }) => {
assertWorkingTreePath(args.worktreePath);
Expand Down
12 changes: 12 additions & 0 deletions app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ const api = {
unstageFiles: (worktreePath: string, paths: string[]): Promise<void> =>
invoke(IPC.GIT_UNSTAGE, { worktreePath, paths }),

stageHunk: (worktreePath: string, filePath: string, hunkIndex: number, lineIndices?: number[] | null): Promise<void> =>
invoke(IPC.GIT_STAGE_HUNK, lineIndices ? { worktreePath, filePath, hunkIndex, lineIndices } : { worktreePath, filePath, hunkIndex }),

unstageHunk: (worktreePath: string, filePath: string, hunkIndex: number, lineIndices?: number[] | null): Promise<void> =>
invoke(IPC.GIT_UNSTAGE_HUNK, lineIndices ? { worktreePath, filePath, hunkIndex, lineIndices } : { worktreePath, filePath, hunkIndex }),

createCommit: (worktreePath: string, message: string): Promise<void> =>
invoke(IPC.GIT_COMMIT, { worktreePath, message }),

Expand Down Expand Up @@ -200,6 +206,12 @@ const api = {
getWorkingDiff: (worktreePath: string, file?: string): Promise<string> =>
invoke(IPC.GIT_WORKING_DIFF, file ? { worktreePath, file } : { worktreePath }),

getUnstagedFileDiff: (worktreePath: string, file: string): Promise<string> =>
invoke(IPC.GIT_UNSTAGED_FILE_DIFF, { worktreePath, file }),

getStagedFileDiff: (worktreePath: string, file?: string): Promise<string> =>
invoke(IPC.GIT_STAGED_FILE_DIFF, file ? { worktreePath, file } : { worktreePath }),

// ── Stash ────────────────────────────────────────────────────────────────
createStash: (worktreePath: string, message?: string): Promise<void> =>
invoke(IPC.GIT_STASH_CREATE, message ? { worktreePath, message } : { worktreePath }),
Expand Down
6 changes: 4 additions & 2 deletions app/src/renderer/routes/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -705,11 +705,13 @@ function WorkspaceInner() {
getStatus={(p) => api.getStatus(p)}
stageFiles={(p, paths) => api.stageFiles(p, paths)}
unstageFiles={(p, paths) => api.unstageFiles(p, paths)}
stageHunk={(p, filePath, hunkIndex, lineIndices) => api.stageHunk(p, filePath, hunkIndex, lineIndices)}
unstageHunk={(p, filePath, hunkIndex, lineIndices) => api.unstageHunk(p, filePath, hunkIndex, lineIndices)}
createCommit={(p, msg) => api.createCommit(p, msg)}
getDiff={(p, staged, file) =>
staged
? api.getDiffContent(p, "HEAD", file)
: api.getWorkingDiff(p, file)
? api.getStagedFileDiff(p, file)
: api.getUnstagedFileDiff(p, file ?? "")
}
generateCommitMessage={async (p) => {
const settings =
Expand Down
53 changes: 53 additions & 0 deletions e2e/specs/commit-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,57 @@ describe('commit workflow', () => {

await assertNoErrors();
});

it('stages and unstages a single hunk, leaving the file\'s other hunk untouched', async () => {
const assertNoErrors = monitorErrors();

const defaultBranch = execSync('git symbolic-ref --short HEAD', { cwd: testRepo }).toString().trim();

// Commit a baseline multi-line tracked file before opening the workspace,
// so the two edits below land far enough apart to produce two separate
// diff hunks under git's default 3-line context.
const baseline = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
writeFileSync(join(testRepo, 'notes.txt'), baseline.join('\n') + '\n');
execSync('git add notes.txt', { cwd: testRepo });
execSync('git commit -m "add notes.txt"', { cwd: testRepo });

await gotoHash(`/workspace?path=${encodeURIComponent(testRepo)}`);
await expect($('//*[contains(@class,"sg-tab") and contains(.,"Graph")]')).toBeDisplayed();
await expect($(`[data-testid="worktree-item"][data-branch="${defaultBranch}"]`)).toBeDisplayed();

const worktreePath = join(testRepo, '.sproutgit', 'worktrees', defaultBranch);
const modified = [...baseline];
modified[1] = 'LINE TWO';
modified[15] = 'LINE SIXTEEN';
writeFileSync(join(worktreePath, 'notes.txt'), modified.join('\n') + '\n');

await $('//*[contains(@class,"sg-tab") and contains(.,"Changes")]').click();
await expect($('//*[contains(@class,"sg-file-row") and contains(.,"notes.txt")]')).toBeDisplayed();
await $('//*[contains(@class,"sg-file-row") and contains(.,"notes.txt")]').click();

// Two separate hunks should render for the two far-apart edits.
await expect($$('[data-testid="diff-hunk"]')).toBeElementsArrayOfSize(2);

// Stage only the first hunk.
await $('[data-testid="stage-hunk-btn"][data-hunk-index="0"]').click();

// The diff view refreshes in place — only the second hunk should remain unstaged.
await expect($$('[data-testid="diff-hunk"]')).toBeElementsArrayOfSize(1);
await expect($('//*[contains(@class,"sg-diff-add") and contains(.,"LINE SIXTEEN")]')).toBeDisplayed();

// The file now has both staged and unstaged changes.
await expect($('[data-testid="staging-staged-file-row"][data-path="notes.txt"]')).toBeDisplayed();
await expect($('[data-testid="staging-unstaged-file-row"][data-path="notes.txt"]')).toBeDisplayed();

// The staged pane shows exactly the hunk that was staged.
await $('[data-testid="staging-staged-file-row"][data-path="notes.txt"]').click();
await expect($$('[data-testid="diff-hunk"]')).toBeElementsArrayOfSize(1);
await expect($('//*[contains(@class,"sg-diff-add") and contains(.,"LINE TWO")]')).toBeDisplayed();

// Unstage that hunk back — the file should return to fully unstaged.
await $('[data-testid="unstage-hunk-btn"][data-hunk-index="0"]').click();
await expect($('[data-testid="staging-staged-file-row"][data-path="notes.txt"]')).not.toBeDisplayed();

await assertNoErrors();
});
});
32 changes: 32 additions & 0 deletions packages/git/src/__tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,38 @@ describe('staging', () => {
});
});

describe('getWorktreeStatus', () => {
let repoPath: string;

beforeAll(() => {
repoPath = initTestRepo();
return () => rmSync(repoPath, { recursive: true, force: true });
});

it('reports the correct path when the only change is an unstaged modification to a tracked file', async () => {
// Regression test: `gitForPath()` configures simple-git with
// `trimmed: true`, which runs a whole-string `.trim()` on git's stdout.
// When the *only* status line is an unstaged-only change (" M file",
// i.e. the porcelain output's very first character is a space), that
// leading space used to be indistinguishable from incidental whitespace
// and got stripped along with it — corrupting the parsed path (losing
// its first character) and misreading the index/worktree columns.
writeFileSync(join(repoPath, 'README.md'), '# Test Repo\nmodified\n');

const result = await getWorktreeStatus(repoPath);
expect(result.files).toHaveLength(1);
expect(result.files[0]).toMatchObject({
path: 'README.md',
staged: false,
indexStatus: ' ',
workTreeStatus: 'M',
});

// Restore for any tests that run after this one in the same file.
execSync('git checkout -- README.md', { cwd: repoPath });
});
});

describe('getStagedDiff', () => {
let repoPath: string;

Expand Down
148 changes: 148 additions & 0 deletions packages/git/src/__tests__/staging-hunks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { execSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { getWorktreeStatus, stageHunk, unstageHunk } from '../staging.js';
import { getUnstagedFileDiff, getStagedDiff } from '../diff.js';

/** Creates a test repo with `initialContent` committed as file.txt. */
function initTestRepo(initialContent: string): string {
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'sg-git-hunk-test-')));
execSync('git init', { cwd: dir, stdio: 'ignore' });
execSync('git config user.email "test@sproutgit.test"', { cwd: dir, stdio: 'ignore' });
execSync('git config user.name "SproutGit Test"', { cwd: dir, stdio: 'ignore' });
writeFileSync(join(dir, 'file.txt'), initialContent);
execSync('git add file.txt', { cwd: dir, stdio: 'ignore' });
execSync('git commit -m "initial commit"', { cwd: dir, stdio: 'ignore' });
return dir;
}

function readIndexedFile(dir: string): string {
return execSync('git show :file.txt', { cwd: dir }).toString();
}

// 30 lines so two single-line edits far enough apart (line 2 and line 25) land
// in separate hunks under the default --unified=3 context.
const THIRTY_LINES = Array.from({ length: 30 }, (_, i) => String(i + 1));

describe('stageHunk / unstageHunk', () => {
let repoPath: string;

beforeEach(() => {
repoPath = initTestRepo(THIRTY_LINES.join('\n') + '\n');
return () => rmSync(repoPath, { recursive: true, force: true });
});

it('stages only the requested hunk, leaving the other one unstaged', async () => {
const modified = [...THIRTY_LINES];
modified[1] = 'TWO';
modified[24] = 'TWENTYFIVE';
writeFileSync(join(repoPath, 'file.txt'), modified.join('\n') + '\n');

const raw = await getUnstagedFileDiff(repoPath, 'file.txt');
expect(raw.match(/^@@/gm) ?? []).toHaveLength(2);

await stageHunk(repoPath, 'file.txt', 0);

const status = await getWorktreeStatus(repoPath);
const entry = status.files.find(f => f.path === 'file.txt');
expect(entry?.indexStatus).not.toBe(' '); // something staged
expect(entry?.workTreeStatus).not.toBe(' '); // still something unstaged

const stagedDiff = await getStagedDiff(repoPath, 'file.txt');
expect(stagedDiff).toContain('+TWO');
expect(stagedDiff).not.toContain('+TWENTYFIVE');

const remainingUnstaged = await getUnstagedFileDiff(repoPath, 'file.txt');
expect(remainingUnstaged).toContain('+TWENTYFIVE');
expect(remainingUnstaged).not.toContain('+TWO');
});

it('stages only the selected lines within a hunk', async () => {
// Two single-line edits close enough to land in one hunk, but far enough
// apart (surrounded by unchanged context) that git emits them as
// independent del/add pairs rather than a merged del-block/add-block —
// i.e. hunk.lines is [ctx..., del(two), add(TWO), ctx..., del(six), add(SIX), ctx...].
const original = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
const localRepo = initTestRepo(original.join('\n') + '\n');
try {
const modified = [...original];
modified[1] = 'TWO';
modified[5] = 'SIX';
writeFileSync(join(localRepo, 'file.txt'), modified.join('\n') + '\n');

const raw = await getUnstagedFileDiff(localRepo, 'file.txt');
expect(raw.match(/^@@/gm) ?? []).toHaveLength(1); // single hunk containing both edits
const allLines = raw.split('\n').filter(l => l !== '');
const bodyLines = allLines.slice(allLines.findIndex(l => l.startsWith('@@')) + 1);
expect(bodyLines).toEqual([
' one', '-two', '+TWO', ' three', ' four', ' five', '-six', '+SIX', ' seven', ' eight', ' nine',
]);
// Indices into hunk.lines: 0=one(ctx) 1=two(del) 2=TWO(add) 3=three(ctx) 4=four(ctx) 5=five(ctx) 6=six(del) 7=SIX(add) ...
await stageHunk(localRepo, 'file.txt', 0, [1, 2]);

const stagedDiff = await getStagedDiff(localRepo, 'file.txt');
expect(stagedDiff).toContain('+TWO');
expect(stagedDiff).not.toContain('+SIX');

const remaining = await getUnstagedFileDiff(localRepo, 'file.txt');
expect(remaining).toContain('+SIX');
expect(remaining).not.toContain('+TWO');

// The index should now read "TWO" in place of "two", with "six" untouched.
const indexed = readIndexedFile(localRepo);
expect(indexed).toContain('TWO');
expect(indexed).toContain('\nsix\n');
} finally {
rmSync(localRepo, { recursive: true, force: true });
}
});

it('unstages only the requested hunk, leaving the other one staged', async () => {
const modified = [...THIRTY_LINES];
modified[1] = 'TWO';
modified[24] = 'TWENTYFIVE';
writeFileSync(join(repoPath, 'file.txt'), modified.join('\n') + '\n');
execSync('git add file.txt', { cwd: repoPath, stdio: 'ignore' });

const stagedRaw = await getStagedDiff(repoPath, 'file.txt');
expect(stagedRaw.match(/^@@/gm) ?? []).toHaveLength(2);

await unstageHunk(repoPath, 'file.txt', 0);

const stagedAfter = await getStagedDiff(repoPath, 'file.txt');
expect(stagedAfter).not.toContain('+TWO');
expect(stagedAfter).toContain('+TWENTYFIVE');

const unstagedAfter = await getUnstagedFileDiff(repoPath, 'file.txt');
expect(unstagedAfter).toContain('+TWO');
expect(unstagedAfter).not.toContain('+TWENTYFIVE');
});

it('round-trips a file with no trailing newline', async () => {
const noNewlineRepo = initTestRepo('one\ntwo');
try {
writeFileSync(join(noNewlineRepo, 'file.txt'), 'one\nTWO');
await stageHunk(noNewlineRepo, 'file.txt', 0);
const content = readIndexedFile(noNewlineRepo);
expect(content).toBe('one\nTWO');
} finally {
rmSync(noNewlineRepo, { recursive: true, force: true });
}
});

it('leaves the working tree file content untouched after staging a hunk', async () => {
const modified = [...THIRTY_LINES];
modified[1] = 'TWO';
modified[24] = 'TWENTYFIVE';
const modifiedContent = modified.join('\n') + '\n';
writeFileSync(join(repoPath, 'file.txt'), modifiedContent);

await stageHunk(repoPath, 'file.txt', 0);

// Working tree content must be untouched by a --cached apply.
const workingContent = execSync('cat file.txt', { cwd: repoPath }).toString();
expect(workingContent).toBe(modifiedContent);
});
});
24 changes: 20 additions & 4 deletions packages/git/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,28 @@ export async function getDiffContent(
}

/**
* Returns the raw unified diff of currently staged changes (index vs HEAD).
* Used as the input fed to commit-message generators.
* Returns the raw unified diff of currently staged changes (index vs HEAD),
* optionally scoped to a single file. Used both as the input fed to
* commit-message generators (whole-repo) and as the source diff for
* unstaging a specific hunk (single file).
*/
export async function getStagedDiff(worktreePath: string): Promise<string> {
export async function getStagedDiff(worktreePath: string, filePath?: string | null): Promise<string> {
const git = gitForPath(worktreePath);
return git.raw(['diff', '--cached', '--unified=3']);
const args = ['diff', '--cached', '--unified=3'];
if (filePath) args.push('--', filePath);
return git.raw(args);
}

/**
* Returns the unified diff of the working tree against the index (i.e. only
* genuinely unstaged changes, excluding anything already staged) for a
* single file. This is the exact diff `git apply --cached` needs as input
* when staging a hunk — unlike `getWorkingDiff`, which diffs against HEAD
* and so mixes in already-staged hunks.
*/
export async function getUnstagedFileDiff(worktreePath: string, filePath: string): Promise<string> {
const git = gitForPath(worktreePath);
return git.raw(['diff', '--unified=3', '--', filePath]);
}

/**
Expand Down
Loading
Loading