feat: add git workflow backend operations

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-31 16:43:13 +01:00
co-authored by Copilot
parent 44d0ab07db
commit 906433f408
16 changed files with 2434 additions and 9 deletions
+332
View File
@@ -1,3 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'bun:test';
import { GitService } from '@main/git/gitService';
@@ -175,4 +179,332 @@ describe('GitService', () => {
await expect(service.captureWorkingTreeSnapshot('C:\\workspace\\not-a-repo', '2026-03-23T19:00:00.000Z')).resolves.toBeUndefined();
});
test('captures baseline data for tracked diffs and untracked files', async () => {
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-service-'));
try {
await writeFile(join(tempDirectory, 'notes.txt'), 'fresh notes\n', 'utf8');
const service = createService({
'diff --binary --no-ext-diff --no-renames HEAD -- src\\app.ts': 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n',
});
await expect(service.captureWorkingTreeBaseline(tempDirectory, {
scannedAt: '2026-03-23T19:00:00.000Z',
repoRoot: tempDirectory,
branch: 'main',
changedFileCount: 2,
changes: {
staged: 0,
unstaged: 1,
untracked: 1,
conflicted: 0,
},
files: [
{
path: 'src\\app.ts',
unstagedStatus: 'modified',
},
{
path: 'notes.txt',
unstagedStatus: 'untracked',
},
],
})).resolves.toEqual([
{
path: 'src\\app.ts',
previousPath: undefined,
combinedDiff: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n',
},
{
path: 'notes.txt',
previousPath: undefined,
untrackedContentBase64: Buffer.from('fresh notes\n', 'utf8').toString('base64'),
},
]);
} finally {
await rm(tempDirectory, { force: true, recursive: true });
}
});
test('describes git details with branches and recent commits', async () => {
const service = createService({
'rev-parse --show-toplevel': 'C:\\workspace\\repo\n',
'status --porcelain=1 --untracked-files=all': ' M src\\app.ts\n',
'branch --show-current': 'main\n',
'rev-parse --abbrev-ref --symbolic-full-name @{upstream}': 'origin/main\n',
'rev-list --left-right --count @{upstream}...HEAD': '0\t2\n',
'log -1 --format=%H%n%h%n%s%n%cI': '0123456789abcdef\n0123456\nAdd git detail plumbing\n2026-03-23T18:00:00+01:00\n',
'for-each-ref --format=%(refname:short)%00%(HEAD)%00%(upstream:short) refs/heads': 'main\0*\0origin/main\nfeature/refactor\0 \0origin/feature/refactor\n',
'log -n15 --format=%H%x00%h%x00%an%x00%s%x00%cI%x00%D%x1e': '0123456789abcdef\x00123456\x00Alice\x00Add git detail plumbing\x002026-03-23T18:00:00+01:00\x00HEAD -> main, origin/main\x1e',
});
await expect(service.describeProjectGitDetails('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z', 15)).resolves.toMatchObject({
scannedAt: '2026-03-23T19:00:00.000Z',
context: {
status: 'ready',
branch: 'main',
upstream: 'origin/main',
ahead: 2,
behind: 0,
},
workingTree: {
changedFileCount: 1,
},
branches: [
{ name: 'main', isCurrent: true, upstream: 'origin/main' },
{ name: 'feature/refactor', isCurrent: false, upstream: 'origin/feature/refactor' },
],
recentCommits: [
{
hash: '0123456789abcdef',
shortHash: '123456',
authorName: 'Alice',
subject: 'Add git detail plumbing',
committedAt: '2026-03-23T18:00:00+01:00',
refNames: 'HEAD -> main, origin/main',
},
],
});
});
test('dispatches commit workflow and branch commands to git', async () => {
const executedCommands: string[] = [];
const service = new GitService(async (_projectPath, args) => {
executedCommands.push(args.join(' '));
if (args.join(' ') === 'commit -m feat: update auth') {
return '';
}
if (args.join(' ') === 'log -1 --format=%H%n%h%n%s%n%cI') {
return 'fedcba9876543210\nfedcba9\nfeat: update auth\n2026-03-23T18:00:00+01:00\n';
}
return '';
});
await service.stageFiles('C:\\workspace\\repo', [{ path: 'src\\auth.ts' }]);
await service.unstageFiles('C:\\workspace\\repo', [{ path: 'src\\auth.ts' }]);
await expect(service.commit('C:\\workspace\\repo', 'feat: update auth')).resolves.toEqual({
hash: 'fedcba9876543210',
shortHash: 'fedcba9',
subject: 'feat: update auth',
committedAt: '2026-03-23T18:00:00+01:00',
});
await service.push('C:\\workspace\\repo');
await service.fetch('C:\\workspace\\repo');
await service.pull('C:\\workspace\\repo', true);
await service.createBranch('C:\\workspace\\repo', 'feature/git-panel', undefined, true);
await service.switchBranch('C:\\workspace\\repo', 'main');
await service.deleteBranch('C:\\workspace\\repo', 'feature/git-panel', true);
expect(executedCommands).toEqual([
'add -- src\\auth.ts',
'restore --staged -- src\\auth.ts',
'commit -m feat: update auth',
'log -1 --format=%H%n%h%n%s%n%cI',
'push',
'fetch --all --prune',
'pull --rebase',
'switch -c feature/git-panel',
'switch main',
'branch -D feature/git-panel',
]);
});
test('discards run-created untracked files from the working tree', async () => {
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-discard-'));
try {
await writeFile(join(tempDirectory, 'notes.txt'), 'fresh notes\n', 'utf8');
const executedCommands: string[] = [];
const service = new GitService(async (_projectPath, args) => {
executedCommands.push(args.join(' '));
return '';
});
await service.discardRunChanges(tempDirectory, {
summary: {
generatedAt: '2026-03-31T00:00:00.000Z',
branchAtStart: 'main',
branchAtEnd: 'main',
fileCount: 1,
additions: 0,
deletions: 0,
counts: {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 1,
cleaned: 0,
},
files: [
{
path: 'notes.txt',
kind: 'untracked',
origin: 'run-created',
additions: 0,
deletions: 0,
canRevert: true,
},
],
},
});
expect(executedCommands).toEqual([
'rm --cached --force --ignore-unmatch -- notes.txt',
]);
await expect(Bun.file(join(tempDirectory, 'notes.txt')).exists()).resolves.toBe(false);
} finally {
await rm(tempDirectory, { force: true, recursive: true });
}
});
test('restores cleaned pre-existing untracked files from the captured baseline', async () => {
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-restore-'));
try {
const executedCommands: string[] = [];
const service = new GitService(async (_projectPath, args) => {
executedCommands.push(args.join(' '));
return '';
});
await service.discardRunChanges(tempDirectory, {
summary: {
generatedAt: '2026-03-31T00:00:00.000Z',
branchAtStart: 'main',
branchAtEnd: 'main',
fileCount: 1,
additions: 0,
deletions: 0,
counts: {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 0,
cleaned: 1,
},
files: [
{
path: 'legacy.txt',
kind: 'cleaned',
origin: 'pre-existing',
additions: 0,
deletions: 0,
canRevert: true,
},
],
},
preRunBaselineFiles: [
{
path: 'legacy.txt',
untrackedContentBase64: Buffer.from('legacy\n', 'utf8').toString('base64'),
},
],
});
expect(executedCommands).toEqual([
'restore --source=HEAD --staged --worktree -- legacy.txt',
]);
await expect(Bun.file(join(tempDirectory, 'legacy.txt')).text()).resolves.toBe('legacy\n');
} finally {
await rm(tempDirectory, { force: true, recursive: true });
}
});
test('restores cleaned pre-existing empty untracked files from the captured baseline', async () => {
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-restore-empty-'));
try {
const service = new GitService(async () => '');
await service.discardRunChanges(tempDirectory, {
summary: {
generatedAt: '2026-03-31T00:00:00.000Z',
branchAtStart: 'main',
branchAtEnd: 'main',
fileCount: 1,
additions: 0,
deletions: 0,
counts: {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 0,
cleaned: 1,
},
files: [
{
path: 'empty.txt',
kind: 'cleaned',
origin: 'pre-existing',
additions: 0,
deletions: 0,
canRevert: true,
},
],
},
preRunBaselineFiles: [
{
path: 'empty.txt',
untrackedContentBase64: '',
},
],
});
await expect(Bun.file(join(tempDirectory, 'empty.txt')).text()).resolves.toBe('');
} finally {
await rm(tempDirectory, { force: true, recursive: true });
}
});
test('rejects restoring pre-existing files when no restorable baseline was captured', async () => {
const service = new GitService(async () => '');
await expect(service.discardRunChanges('C:\\workspace\\repo', {
summary: {
generatedAt: '2026-03-31T00:00:00.000Z',
branchAtStart: 'main',
branchAtEnd: 'main',
fileCount: 1,
additions: 0,
deletions: 0,
counts: {
added: 0,
modified: 1,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 0,
cleaned: 0,
},
files: [
{
path: 'src\\auth.ts',
kind: 'modified',
origin: 'pre-existing',
additions: 0,
deletions: 0,
canRevert: false,
},
],
},
preRunBaselineFiles: [
{
path: 'src\\auth.ts',
},
],
})).rejects.toThrow('no restorable baseline was captured');
});
});