422 lines
19 KiB
TypeScript
422 lines
19 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { execFileSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import type { ToolDefinition } from '../src/tools.js';
|
|
import { buildReadOnlyGitDiffArgs, createGitTools } from '../src/git-tools.js';
|
|
|
|
let tmpDir: string;
|
|
let tools: ToolDefinition[];
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-git-'));
|
|
execFileSync('git', ['init'], { cwd: tmpDir });
|
|
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir });
|
|
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
|
|
tools = createGitTools(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('createGitTools', () => {
|
|
it('returns 10 tools: original 4 + 6 new workflow tools', () => {
|
|
const names = tools.map(t => t.name);
|
|
expect(names).toEqual([
|
|
'git_status', 'git_diff', 'git_log', 'git_commit',
|
|
'git_branch', 'git_stash', 'git_push', 'git_pull', 'git_merge', 'git_pr',
|
|
]);
|
|
});
|
|
|
|
it('git_status shows clean on fresh repo', async () => {
|
|
const status = tools.find(t => t.name === 'git_status')!;
|
|
const result = await status.execute({});
|
|
// Fresh repo with no commits — status should indicate clean/empty
|
|
expect(result).toContain('Clean');
|
|
});
|
|
|
|
it('git_status shows modified files after creating a file', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello world');
|
|
const status = tools.find(t => t.name === 'git_status')!;
|
|
const result = await status.execute({});
|
|
expect(result).toContain('hello.txt');
|
|
});
|
|
|
|
it('git_status refuses to discover a repository above the active workspace', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'private-parent-file.txt'), 'must stay private');
|
|
const nestedWorkspace = path.join(tmpDir, 'managed', 'workspace', 'files');
|
|
fs.mkdirSync(nestedWorkspace, { recursive: true });
|
|
|
|
const nestedTools = createGitTools(nestedWorkspace);
|
|
const status = nestedTools.find(t => t.name === 'git_status')!;
|
|
const result = await status.execute({});
|
|
|
|
expect(result).toBe('Error: No Git repository exists inside the active workspace.');
|
|
expect(result).not.toContain('private-parent-file.txt');
|
|
});
|
|
|
|
it('git_commit cannot stage or commit changes in a repository above the workspace', async () => {
|
|
const parentFile = path.join(tmpDir, 'private-parent-file.txt');
|
|
fs.writeFileSync(parentFile, 'initial');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'parent baseline'], { cwd: tmpDir });
|
|
fs.writeFileSync(parentFile, 'sensitive change');
|
|
|
|
const nestedWorkspace = path.join(tmpDir, 'managed', 'workspace', 'files');
|
|
fs.mkdirSync(nestedWorkspace, { recursive: true });
|
|
const nestedTools = createGitTools(nestedWorkspace);
|
|
const commit = nestedTools.find(t => t.name === 'git_commit')!;
|
|
const result = await commit.execute({ message: 'must not commit parent' });
|
|
|
|
expect(result).toBe('Error: No Git repository exists inside the active workspace.');
|
|
expect(execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8',
|
|
}).trim()).toBe('');
|
|
expect(execFileSync('git', ['log', '-1', '--pretty=%s'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8',
|
|
}).trim()).toBe('parent baseline');
|
|
});
|
|
|
|
it('git_diff shows changes for modified file', async () => {
|
|
// Create initial commit so diff works
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
// Modify the file
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
const result = await diff.execute({});
|
|
expect(result).toContain('modified');
|
|
expect(result).toContain('original');
|
|
});
|
|
|
|
it('git_diff treats an option-looking file as a path and cannot write output', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const outsideOutput = path.join(path.dirname(tmpDir), `${path.basename(tmpDir)}-escaped.diff`);
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
try {
|
|
await diff.execute({ file: `--output=${outsideOutput}` });
|
|
expect(fs.existsSync(outsideOutput)).toBe(false);
|
|
} finally {
|
|
fs.rmSync(outsideOutput, { force: true });
|
|
}
|
|
});
|
|
|
|
it('git_diff does not execute a repository-configured external diff command', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const marker = path.join(tmpDir, 'external-diff-ran');
|
|
const helper = path.join(tmpDir, 'external-diff.cjs');
|
|
fs.writeFileSync(helper, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`);
|
|
execFileSync('git', ['config', 'diff.external', `"${process.execPath}" "${helper}"`], { cwd: tmpDir });
|
|
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
await diff.execute({});
|
|
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
});
|
|
|
|
it('git_diff does not execute an external diff command inherited from the environment', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const marker = path.join(tmpDir, 'environment-diff-ran');
|
|
const helper = path.join(tmpDir, 'environment-diff.cjs');
|
|
fs.writeFileSync(helper, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`);
|
|
const originalExternalDiff = process.env.GIT_EXTERNAL_DIFF;
|
|
process.env.GIT_EXTERNAL_DIFF = `"${process.execPath}" "${helper}"`;
|
|
try {
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
await diff.execute({});
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
} finally {
|
|
if (originalExternalDiff === undefined) delete process.env.GIT_EXTERNAL_DIFF;
|
|
else process.env.GIT_EXTERNAL_DIFF = originalExternalDiff;
|
|
}
|
|
});
|
|
|
|
it('git_diff does not execute a textconv command selected by repository attributes', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), '*.txt diff=unsafe\n');
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const marker = path.join(tmpDir, 'textconv-ran');
|
|
const helper = path.join(tmpDir, 'textconv.cjs');
|
|
fs.writeFileSync(helper, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`);
|
|
execFileSync('git', ['config', 'diff.unsafe.textconv', `"${process.execPath}" "${helper}"`], { cwd: tmpDir });
|
|
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
await diff.execute({});
|
|
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
});
|
|
|
|
it('git_diff does not execute a clean filter selected by repository attributes', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), '*.txt filter=unsafe\n');
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
const marker = path.join(tmpDir, 'clean-filter-ran');
|
|
const helper = path.join(tmpDir, 'clean-filter.cjs');
|
|
fs.writeFileSync(helper, [
|
|
"const fs = require('node:fs');",
|
|
`fs.writeFileSync(${JSON.stringify(marker)}, 'ran');`,
|
|
"process.stdin.pipe(process.stdout);",
|
|
].join('\n'));
|
|
execFileSync('git', ['config', 'filter.unsafe.clean', `"${process.execPath}" "${helper}"`], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
const result = await diff.execute({ file: 'file.txt' });
|
|
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
expect(result).toContain('original');
|
|
expect(result).toContain('modified');
|
|
});
|
|
|
|
it('git_diff does not execute a process filter selected by repository attributes', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), '*.txt filter=unsafe\n');
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
const marker = path.join(tmpDir, 'process-filter-ran');
|
|
const helper = path.join(tmpDir, 'process-filter.cjs');
|
|
fs.writeFileSync(helper, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`);
|
|
execFileSync('git', ['config', 'filter.unsafe.process', `"${process.execPath}" "${helper}"`], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
const result = await diff.execute({ file: 'file.txt' });
|
|
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
expect(result).toContain('original');
|
|
expect(result).toContain('modified');
|
|
});
|
|
|
|
it('git_diff allows a safe staged diff without executing a configured clean filter', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), '*.txt filter=unsafe\n');
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir });
|
|
|
|
const marker = path.join(tmpDir, 'staged-clean-filter-ran');
|
|
const helper = path.join(tmpDir, 'staged-clean-filter.cjs');
|
|
fs.writeFileSync(helper, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`);
|
|
execFileSync('git', ['config', 'filter.unsafe.clean', `"${process.execPath}" "${helper}"`], { cwd: tmpDir });
|
|
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
const result = await diff.execute({ staged: true, file: 'file.txt' });
|
|
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
expect(result).toContain('original');
|
|
expect(result).toContain('modified');
|
|
});
|
|
|
|
it('git_diff allows an unstaged path not selected by a configured filter', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), '*.bin filter=unsafe\n');
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
const marker = path.join(tmpDir, 'unrelated-clean-filter-ran');
|
|
const helper = path.join(tmpDir, 'unrelated-clean-filter.cjs');
|
|
fs.writeFileSync(helper, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`);
|
|
execFileSync('git', ['config', 'filter.unsafe.clean', `"${process.execPath}" "${helper}"`], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const diff = tools.find(t => t.name === 'git_diff')!;
|
|
const result = await diff.execute({ file: 'file.txt' });
|
|
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
expect(result).toContain('original');
|
|
expect(result).toContain('modified');
|
|
});
|
|
|
|
it('git_diff keeps configured filters disabled if attributes activate after argument construction', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
const marker = path.join(tmpDir, 'raced-clean-filter-ran');
|
|
const helper = path.join(tmpDir, 'raced-clean-filter.cjs');
|
|
fs.writeFileSync(helper, [
|
|
"const fs = require('node:fs');",
|
|
`fs.writeFileSync(${JSON.stringify(marker)}, 'ran');`,
|
|
"process.stdin.pipe(process.stdout);",
|
|
].join('\n'));
|
|
execFileSync('git', ['config', 'filter.unsafe.clean', `"${process.execPath}" "${helper}"`], { cwd: tmpDir });
|
|
|
|
const args = buildReadOnlyGitDiffArgs(tmpDir, process.env, { file: 'file.txt' });
|
|
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), '*.txt filter=unsafe\n');
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
const result = execFileSync('git', args, { cwd: tmpDir, encoding: 'utf-8' });
|
|
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
expect(result).toContain('original');
|
|
expect(result).toContain('modified');
|
|
});
|
|
|
|
it('git_log shows commits after committing', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'content');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'first commit'], { cwd: tmpDir });
|
|
|
|
const log = tools.find(t => t.name === 'git_log')!;
|
|
const result = await log.execute({});
|
|
expect(result).toContain('first commit');
|
|
});
|
|
|
|
it('git_commit does atomic add+commit with specified message', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'new.txt'), 'new file content');
|
|
|
|
const commit = tools.find(t => t.name === 'git_commit')!;
|
|
const result = await commit.execute({ message: 'add new file' });
|
|
expect(result).toContain('add new file');
|
|
|
|
// Verify commit is in log
|
|
const logOutput = execFileSync('git', ['log', '--oneline'], { cwd: tmpDir, encoding: 'utf-8' });
|
|
expect(logOutput).toContain('add new file');
|
|
});
|
|
|
|
// ── F2: New git workflow tools ─────────────────────────────────────
|
|
|
|
it('git_branch list shows branches', async () => {
|
|
// Need at least one commit for branches to exist
|
|
fs.writeFileSync(path.join(tmpDir, 'init.txt'), 'init');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
const branch = tools.find(t => t.name === 'git_branch')!;
|
|
const result = await branch.execute({ action: 'list' });
|
|
expect(result).toContain('master');
|
|
});
|
|
|
|
it('git_branch create makes a new branch and switches to it', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'init.txt'), 'init');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
const branch = tools.find(t => t.name === 'git_branch')!;
|
|
await branch.execute({ action: 'create', name: 'feature-x' });
|
|
|
|
const current = execFileSync('git', ['branch', '--show-current'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
expect(current).toBe('feature-x');
|
|
});
|
|
|
|
it('git_branch switch changes branch', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'init.txt'), 'init');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
execFileSync('git', ['checkout', '-b', 'dev'], { cwd: tmpDir });
|
|
execFileSync('git', ['checkout', 'master'], { cwd: tmpDir });
|
|
|
|
const branch = tools.find(t => t.name === 'git_branch')!;
|
|
await branch.execute({ action: 'switch', name: 'dev' });
|
|
|
|
const current = execFileSync('git', ['branch', '--show-current'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
expect(current).toBe('dev');
|
|
});
|
|
|
|
it('git_branch delete removes a branch', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'init.txt'), 'init');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
execFileSync('git', ['branch', 'to-delete'], { cwd: tmpDir });
|
|
|
|
const branch = tools.find(t => t.name === 'git_branch')!;
|
|
await branch.execute({ action: 'delete', name: 'to-delete' });
|
|
|
|
const branches = execFileSync('git', ['branch'], { cwd: tmpDir, encoding: 'utf-8' });
|
|
expect(branches).not.toContain('to-delete');
|
|
});
|
|
|
|
it('git_branch returns error when name is missing for create', async () => {
|
|
const branch = tools.find(t => t.name === 'git_branch')!;
|
|
const result = await branch.execute({ action: 'create' });
|
|
expect(result).toContain('Error');
|
|
});
|
|
|
|
it('git_stash save and pop round-trips changes', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'original');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
// Make a change
|
|
fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'modified');
|
|
|
|
const stash = tools.find(t => t.name === 'git_stash')!;
|
|
await stash.execute({ action: 'save', message: 'wip changes' });
|
|
|
|
// File should be back to original after stash
|
|
const content = fs.readFileSync(path.join(tmpDir, 'file.txt'), 'utf-8');
|
|
expect(content).toBe('original');
|
|
|
|
// Pop stash
|
|
await stash.execute({ action: 'pop' });
|
|
const restored = fs.readFileSync(path.join(tmpDir, 'file.txt'), 'utf-8');
|
|
expect(restored).toBe('modified');
|
|
});
|
|
|
|
it('git_stash list shows empty when no stashes', async () => {
|
|
const stash = tools.find(t => t.name === 'git_stash')!;
|
|
const result = await stash.execute({ action: 'list' });
|
|
expect(result).toBe('No stashes.');
|
|
});
|
|
|
|
it('git_merge merges a branch into current', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'init.txt'), 'init');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
// Create a feature branch with a commit
|
|
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature work');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'feature commit'], { cwd: tmpDir });
|
|
|
|
// Switch back to master and merge
|
|
execFileSync('git', ['checkout', 'master'], { cwd: tmpDir });
|
|
|
|
const merge = tools.find(t => t.name === 'git_merge')!;
|
|
await merge.execute({ branch: 'feature' });
|
|
|
|
// feature.txt should now exist on master
|
|
expect(fs.existsSync(path.join(tmpDir, 'feature.txt'))).toBe(true);
|
|
});
|
|
|
|
it('git_pr returns output (PR description or gh error for local-only repo)', async () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'init.txt'), 'init');
|
|
execFileSync('git', ['add', '.'], { cwd: tmpDir });
|
|
execFileSync('git', ['commit', '-m', 'initial'], { cwd: tmpDir });
|
|
|
|
const pr = tools.find(t => t.name === 'git_pr')!;
|
|
const result = await pr.execute({ title: 'Test PR', body: 'Some changes', base: 'main' });
|
|
// If gh CLI is available but no remote, gh returns an error about remotes.
|
|
// If gh CLI is not available, we get a formatted PR description with the title.
|
|
// Either way, the tool should return a non-empty string without throwing.
|
|
expect(typeof result).toBe('string');
|
|
expect(result.length).toBeGreaterThan(0);
|
|
});
|
|
});
|