mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 05:13:58 +02:00
feat: add git workflow backend operations
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,7 +2,11 @@ import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectGitWorkingTreeSnapshot, ProjectRecord } from '@shared/domain/project';
|
||||
import type {
|
||||
ProjectGitRunChangeSummary,
|
||||
ProjectGitWorkingTreeSnapshot,
|
||||
ProjectRecord,
|
||||
} from '@shared/domain/project';
|
||||
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
@@ -131,12 +135,50 @@ function createSnapshot(): ProjectGitWorkingTreeSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function createRunSummary(): ProjectGitRunChangeSummary {
|
||||
return {
|
||||
generatedAt: TIMESTAMP,
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 4,
|
||||
deletions: 1,
|
||||
counts: {
|
||||
added: 1,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 0,
|
||||
cleaned: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\generated.ts',
|
||||
kind: 'added',
|
||||
origin: 'run-created',
|
||||
additions: 4,
|
||||
deletions: 1,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'src\\generated.ts',
|
||||
diff: '@@ -0,0 +1,4 @@\n+export const generated = true;\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
workspace: WorkspaceState,
|
||||
pattern: PatternDefinition,
|
||||
options?: {
|
||||
snapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
runSummary?: ProjectGitRunChangeSummary;
|
||||
onCaptureSnapshot?: (projectPath: string, scannedAt: string) => void;
|
||||
onComputeRunSummary?: (projectPath: string) => void;
|
||||
onScheduleRefresh?: (projectId?: string) => void;
|
||||
runTurn?: (command: RunTurnCommand) => Promise<[]>;
|
||||
},
|
||||
@@ -170,6 +212,8 @@ function createService(
|
||||
projectPath: string,
|
||||
scannedAt: string,
|
||||
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
|
||||
captureWorkingTreeBaseline: () => Promise<[]>;
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
@@ -184,6 +228,8 @@ function createService(
|
||||
projectPath: string,
|
||||
scannedAt: string,
|
||||
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
|
||||
captureWorkingTreeBaseline: () => Promise<[]>;
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).gitService = {
|
||||
@@ -191,6 +237,11 @@ function createService(
|
||||
options?.onCaptureSnapshot?.(projectPath, scannedAt);
|
||||
return options?.snapshot;
|
||||
},
|
||||
captureWorkingTreeBaseline: async () => [],
|
||||
computeRunChangeSummary: async (projectPath) => {
|
||||
options?.onComputeRunSummary?.(projectPath);
|
||||
return options?.runSummary;
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
@@ -229,6 +280,23 @@ describe('AryxAppService git refresh integration', () => {
|
||||
expect(scheduledProjectIds).toEqual([project.id]);
|
||||
});
|
||||
|
||||
test('sendSessionMessage stores a post-run git summary on the completed run', async () => {
|
||||
const { workspace, pattern, session, project } = createFixture();
|
||||
const computedProjectPaths: string[] = [];
|
||||
const service = createService(workspace, pattern, {
|
||||
snapshot: createSnapshot(),
|
||||
runSummary: createRunSummary(),
|
||||
onComputeRunSummary: (projectPath) => {
|
||||
computedProjectPaths.push(projectPath);
|
||||
},
|
||||
});
|
||||
|
||||
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
|
||||
|
||||
expect(computedProjectPaths).toEqual([project.path]);
|
||||
expect(workspace.sessions[0]?.runs[0]?.postRunGitSummary).toEqual(createRunSummary());
|
||||
});
|
||||
|
||||
test('sendSessionMessage schedules a git refresh after a failed project turn', async () => {
|
||||
const { workspace, pattern, project, session } = createFixture();
|
||||
const scheduledProjectIds: Array<string | undefined> = [];
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { ProjectGitRunChangeSummary } from '@shared/domain/project';
|
||||
|
||||
import { buildProjectGitCommitMessageSuggestion } from '@main/git/gitCommitMessageSuggestion';
|
||||
|
||||
const baseSummary: ProjectGitRunChangeSummary = {
|
||||
generatedAt: '2026-03-31T00:00:00.000Z',
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 5,
|
||||
deletions: 2,
|
||||
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: 'run-created',
|
||||
additions: 5,
|
||||
deletions: 2,
|
||||
canRevert: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildProjectGitCommitMessageSuggestion', () => {
|
||||
test('infers a fix commit from the triggering user prompt', () => {
|
||||
const suggestion = buildProjectGitCommitMessageSuggestion({
|
||||
session: {
|
||||
title: 'Auth hardening',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: 'Fix auth hardening for git integration.',
|
||||
createdAt: '2026-03-31T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
run: {
|
||||
triggerMessageId: 'msg-user-1',
|
||||
},
|
||||
summary: baseSummary,
|
||||
});
|
||||
|
||||
expect(suggestion).toEqual({
|
||||
type: 'fix',
|
||||
subject: 'auth hardening for git integration',
|
||||
message: 'fix: auth hardening for git integration',
|
||||
});
|
||||
});
|
||||
|
||||
test('infers docs commits from documentation-only changes', () => {
|
||||
const suggestion = buildProjectGitCommitMessageSuggestion({
|
||||
session: {
|
||||
title: 'Docs touch-up',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: '',
|
||||
createdAt: '2026-03-31T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
run: {
|
||||
triggerMessageId: 'msg-user-1',
|
||||
},
|
||||
summary: {
|
||||
...baseSummary,
|
||||
files: [
|
||||
{
|
||||
path: 'README.md',
|
||||
kind: 'modified',
|
||||
origin: 'run-created',
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(suggestion.type).toBe('docs');
|
||||
expect(suggestion.message).toBe('docs: update readme');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { ProjectGitBaselineFile, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
|
||||
|
||||
import { buildProjectGitRunChangeSummary } from '@main/git/gitRunChangeSummary';
|
||||
|
||||
const TIMESTAMP = '2026-03-31T00:00:00.000Z';
|
||||
|
||||
function createPreRunSnapshot(): ProjectGitWorkingTreeSnapshot {
|
||||
return {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 2,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 1,
|
||||
untracked: 1,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
unstagedStatus: 'modified',
|
||||
},
|
||||
{
|
||||
path: 'legacy.tmp',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPostRunSnapshot(): ProjectGitWorkingTreeSnapshot {
|
||||
return {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 2,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 1,
|
||||
untracked: 1,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
unstagedStatus: 'modified',
|
||||
},
|
||||
{
|
||||
path: 'notes.txt',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPreRunBaselines(): ProjectGitBaselineFile[] {
|
||||
return [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
combinedDiff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+before\n',
|
||||
},
|
||||
{
|
||||
path: 'legacy.tmp',
|
||||
untrackedContentBase64: Buffer.from('legacy\n', 'utf8').toString('base64'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createPostRunBaselines(): ProjectGitBaselineFile[] {
|
||||
return [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
combinedDiff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+after\n',
|
||||
},
|
||||
{
|
||||
path: 'notes.txt',
|
||||
untrackedContentBase64: Buffer.from('fresh notes\n', 'utf8').toString('base64'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('buildProjectGitRunChangeSummary', () => {
|
||||
test('classifies run-created, pre-existing, and cleaned files', () => {
|
||||
const summary = buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: createPreRunSnapshot(),
|
||||
preRunBaselineFiles: createPreRunBaselines(),
|
||||
postRunSnapshot: createPostRunSnapshot(),
|
||||
postRunBaselineFiles: createPostRunBaselines(),
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
generatedAt: TIMESTAMP,
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 3,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
counts: {
|
||||
added: 0,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 1,
|
||||
cleaned: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.files).toEqual([
|
||||
{
|
||||
path: 'notes.txt',
|
||||
previousPath: undefined,
|
||||
kind: 'untracked',
|
||||
origin: 'run-created',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'untracked',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'notes.txt',
|
||||
previousPath: undefined,
|
||||
newFileContents: 'fresh notes\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'legacy.tmp',
|
||||
previousPath: undefined,
|
||||
kind: 'cleaned',
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'untracked',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'legacy.tmp',
|
||||
previousPath: undefined,
|
||||
newFileContents: 'legacy\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
previousPath: undefined,
|
||||
kind: 'modified',
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'modified',
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'src\\auth.ts',
|
||||
previousPath: undefined,
|
||||
diff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+after\n',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns undefined when nothing changed across the run', () => {
|
||||
const snapshot = createPreRunSnapshot();
|
||||
const baselines = createPreRunBaselines();
|
||||
|
||||
expect(buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: snapshot,
|
||||
preRunBaselineFiles: baselines,
|
||||
postRunSnapshot: snapshot,
|
||||
postRunBaselineFiles: baselines,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
test('marks pre-existing files as non-revertable when the baseline capture has no restore data', () => {
|
||||
const summary = buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: createPreRunSnapshot(),
|
||||
preRunBaselineFiles: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
},
|
||||
],
|
||||
postRunSnapshot: createPostRunSnapshot(),
|
||||
postRunBaselineFiles: createPostRunBaselines(),
|
||||
});
|
||||
|
||||
expect(summary?.files.find((file) => file.path === 'src\\auth.ts')).toMatchObject({
|
||||
path: 'src\\auth.ts',
|
||||
origin: 'pre-existing',
|
||||
canRevert: false,
|
||||
});
|
||||
|
||||
expect(summary?.files.find((file) => file.path === 'legacy.tmp')).toMatchObject({
|
||||
path: 'legacy.tmp',
|
||||
kind: 'cleaned',
|
||||
canRevert: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves empty untracked file previews', () => {
|
||||
const summary = buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 1,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 1,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'empty.txt',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
],
|
||||
},
|
||||
preRunBaselineFiles: [
|
||||
{
|
||||
path: 'empty.txt',
|
||||
untrackedContentBase64: '',
|
||||
},
|
||||
],
|
||||
postRunSnapshot: {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 0,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [],
|
||||
},
|
||||
postRunBaselineFiles: [],
|
||||
});
|
||||
|
||||
expect(summary?.files).toEqual([
|
||||
{
|
||||
path: 'empty.txt',
|
||||
previousPath: undefined,
|
||||
kind: 'cleaned',
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'untracked',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'empty.txt',
|
||||
previousPath: undefined,
|
||||
newFileContents: '',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,22 +55,37 @@ describe('run timeline helpers', () => {
|
||||
const run = createSessionRunRecord({
|
||||
requestId: 'turn-1',
|
||||
project: createProject(),
|
||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||
workspaceKind: 'project',
|
||||
pattern: createPattern(),
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
preRunGitBaselineFiles: [
|
||||
{
|
||||
path: 'src\\alpha.ts',
|
||||
combinedDiff: '@@ -1 +1 @@\n-old\n+new\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(run).toMatchObject({
|
||||
requestId: 'turn-1',
|
||||
projectId: 'project-1',
|
||||
projectPath: 'C:\\workspace\\alpha',
|
||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||
patternId: 'pattern-sequential',
|
||||
patternName: 'Sequential Trio Review',
|
||||
patternMode: 'sequential',
|
||||
triggerMessageId: 'msg-user-1',
|
||||
status: 'running',
|
||||
});
|
||||
expect(run.preRunGitBaselineFiles).toEqual([
|
||||
{
|
||||
path: 'src\\alpha.ts',
|
||||
previousPath: undefined,
|
||||
combinedDiff: '@@ -1 +1 @@\n-old\n+new\n',
|
||||
},
|
||||
]);
|
||||
expect(run.agents).toEqual([
|
||||
{
|
||||
agentId: 'agent-writer',
|
||||
|
||||
Reference in New Issue
Block a user