test sweep: cover src/git/client.dart (T-91)
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s
23 integration tests in test/git/client_test.dart driving a real git binary against per-test sandbox repos. Covers every GitClient public method that wasn't already exercised through the daemon command suite: - Queries: status (clean + with upstream tracking ahead/behind), diff with explicit paths, log, currentBranch, branches (with the current-marker check), repoRoot (in-repo + non-repo paths). - Mutations: unstage with paths, commit-returns-hash, discard, discard with empty list, stash + stashPop with includeUntracked, checkout, stageHunk + unstageHunk via _applyPatch. - Error surface: mutations throw GitException on non-zero exit, _applyPatch surfaces stderr from a bad patch, bad git binary path → ProcessException → GitException, queries return empty fallbacks on a non-git workDir. - parseLog standalone parser: empty input, short-record skip, full record with body, 5-field record without body. Coverage: src/git/client.dart 76/141 -> 124/141 (88%). The remaining 17 lines are the pull / push paths (would need a real remote with matching credentials — out of scope) and the status GitException catch branches that require an in-flight git process failure mid-call. Total coverage 75.42% -> 76.04%; floor bumped to 76. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide
|
||||
|
||||
# Pre-push line-coverage floor. Ratchets up only — see D-66.
|
||||
# Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`.
|
||||
coverage_floor: 75
|
||||
coverage_floor: 76
|
||||
|
||||
# Project metadata (was project.yaml, folded in per D-056).
|
||||
# version: above is the single source of truth. The Makefile reads
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/// Integration tests for `lib/src/git/client.dart` — drives a real
|
||||
/// git binary against a temp sandbox, covering the GitClient public
|
||||
/// API + the parseLog parser.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/git/operations.dart' show GitException;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
Toolchain _toolchain() {
|
||||
final t = Toolchain();
|
||||
t.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
||||
return t;
|
||||
}
|
||||
|
||||
Future<Directory> _newRepo({String filename = 'file.txt', String contents = 'hello\n'}) async {
|
||||
final dir = await Directory.systemTemp.createTemp('clide-git-client-');
|
||||
await Process.run('git', ['init', '-b', 'main'], workingDirectory: dir.path);
|
||||
await Process.run('git', ['config', 'user.email', 'test@test.com'], workingDirectory: dir.path);
|
||||
await Process.run('git', ['config', 'user.name', 'Test'], workingDirectory: dir.path);
|
||||
await File('${dir.path}/$filename').writeAsString(contents);
|
||||
await Process.run('git', ['add', '.'], workingDirectory: dir.path);
|
||||
await Process.run('git', ['commit', '-m', 'init'], workingDirectory: dir.path);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('GitClient — queries', () {
|
||||
late Directory sandbox;
|
||||
late GitClient git;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await _newRepo();
|
||||
git = GitClient(toolchain: _toolchain(), workDir: sandbox);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('status returns branch + entries on a clean repo', () async {
|
||||
final s = await git.status();
|
||||
expect(s.branch, 'main');
|
||||
expect(s.entries, isEmpty);
|
||||
});
|
||||
|
||||
test('status with an upstream parses ahead/behind counts', () async {
|
||||
// Set up a bare repo as a remote and track it.
|
||||
final remote = await Directory.systemTemp.createTemp('clide-git-remote-');
|
||||
addTearDown(() => remote.deleteSync(recursive: true));
|
||||
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
|
||||
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['push', '-u', 'origin', 'main'], workingDirectory: sandbox.path);
|
||||
// Add an uncommitted divergence to make ahead/behind interesting.
|
||||
await File('${sandbox.path}/extra.txt').writeAsString('x');
|
||||
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['commit', '-m', 'second'], workingDirectory: sandbox.path);
|
||||
final s = await git.status();
|
||||
expect(s.branch, 'main');
|
||||
expect(s.upstream, contains('origin/main'));
|
||||
expect(s.ahead, 1);
|
||||
expect(s.behind, 0);
|
||||
});
|
||||
|
||||
test('diff with explicit paths narrows the diff to those files', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
await File('${sandbox.path}/other.txt').writeAsString('other');
|
||||
final scoped = await git.diff(paths: ['file.txt']);
|
||||
expect(scoped, hasLength(1));
|
||||
expect(scoped.first.path, 'file.txt');
|
||||
});
|
||||
|
||||
test('log returns commit entries', () async {
|
||||
final entries = await git.log(count: 5);
|
||||
expect(entries, isNotEmpty);
|
||||
expect(entries.first.subject, 'init');
|
||||
});
|
||||
|
||||
test('currentBranch returns the current branch name', () async {
|
||||
expect(await git.currentBranch(), 'main');
|
||||
});
|
||||
|
||||
test('branches lists local branches and marks the current one', () async {
|
||||
await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path);
|
||||
final all = await git.branches();
|
||||
final names = all.map((b) => b.name).toList();
|
||||
expect(names, containsAll(['main', 'feature/a']));
|
||||
final main = all.firstWhere((b) => b.name == 'main');
|
||||
expect(main.current, isTrue);
|
||||
});
|
||||
|
||||
test('repoRoot returns the repo path for an in-repo dir', () async {
|
||||
final root = await git.repoRoot(sandbox.path);
|
||||
expect(root, isNotNull);
|
||||
// Resolve symlinks both ways: realpath may differ from sandbox.path.
|
||||
expect(File(root!).existsSync() || Directory(root).existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('repoRoot returns null for a non-git directory', () async {
|
||||
final notGit = await Directory.systemTemp.createTemp('clide-not-git-');
|
||||
addTearDown(() => notGit.deleteSync(recursive: true));
|
||||
expect(await git.repoRoot(notGit.path), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('GitClient — mutations', () {
|
||||
late Directory sandbox;
|
||||
late GitClient git;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await _newRepo();
|
||||
git = GitClient(toolchain: _toolchain(), workDir: sandbox);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('unstage with explicit paths resets only those', () async {
|
||||
await File('${sandbox.path}/new.txt').writeAsString('x');
|
||||
await git.stage(['new.txt']);
|
||||
await git.unstage(['new.txt']);
|
||||
final r = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
|
||||
expect((r.stdout as String).trim(), isEmpty);
|
||||
});
|
||||
|
||||
test('commit creates a commit and returns the HEAD hash', () async {
|
||||
await File('${sandbox.path}/c.txt').writeAsString('commit me');
|
||||
await git.stage(['c.txt']);
|
||||
final hash = await git.commit('add c');
|
||||
expect(hash, hasLength(40));
|
||||
});
|
||||
|
||||
test('discard restores tracked files to HEAD', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('mutated');
|
||||
await git.discard(['file.txt']);
|
||||
final contents = await File('${sandbox.path}/file.txt').readAsString();
|
||||
expect(contents, 'hello\n');
|
||||
});
|
||||
|
||||
test('discard with an empty list is a no-op', () async {
|
||||
await git.discard(const []);
|
||||
});
|
||||
|
||||
test('stash + stashPop save and restore working-tree changes', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('mutated\n');
|
||||
await git.stash(message: 'wip', includeUntracked: true);
|
||||
// After stash, the working tree is restored to HEAD.
|
||||
expect(await File('${sandbox.path}/file.txt').readAsString(), 'hello\n');
|
||||
await git.stashPop();
|
||||
expect(await File('${sandbox.path}/file.txt').readAsString(), 'mutated\n');
|
||||
});
|
||||
|
||||
test('checkout switches to an existing branch', () async {
|
||||
await Process.run('git', ['branch', 'feature/x'], workingDirectory: sandbox.path);
|
||||
await git.checkout('feature/x');
|
||||
expect(await git.currentBranch(), 'feature/x');
|
||||
});
|
||||
|
||||
test('stageHunk applies a patch to the index; unstageHunk reverses it', () async {
|
||||
// Modify file.txt and produce a patch for the change.
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
final patchProc = await Process.run('git', ['diff', '-U0'], workingDirectory: sandbox.path);
|
||||
final patch = patchProc.stdout as String;
|
||||
await git.stageHunk(patch);
|
||||
// After stageHunk, the change is in the index.
|
||||
final cached = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
|
||||
expect((cached.stdout as String).trim(), 'file.txt');
|
||||
await git.unstageHunk(patch);
|
||||
// After unstageHunk, the index is clean again.
|
||||
final cleared = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
|
||||
expect((cleared.stdout as String).trim(), isEmpty);
|
||||
});
|
||||
|
||||
test('mutations throw GitException on non-zero exit', () async {
|
||||
// commit with nothing staged → non-zero exit.
|
||||
try {
|
||||
await git.commit('nothing');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('_applyPatch surfaces stderr in the GitException on bad patch', () async {
|
||||
try {
|
||||
await git.stageHunk('not a valid patch\n');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.toString(), contains('apply'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('GitClient — error surface', () {
|
||||
test('a bad git binary path makes _run throw GitException', () async {
|
||||
final t = Toolchain();
|
||||
t.applyResolved(const ResolvedPaths(git: '/tmp/clide-no-such-git-binary'));
|
||||
final dir = await Directory.systemTemp.createTemp('clide-git-bad-');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final git = GitClient(toolchain: t, workDir: dir);
|
||||
try {
|
||||
await git.commit('whatever');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.toString(), contains('git'));
|
||||
}
|
||||
});
|
||||
|
||||
test('queries return empty fallbacks when git exits non-zero', () async {
|
||||
// workDir is a temp dir that's NOT a git repo — every command exits
|
||||
// non-zero. Each query method returns its empty fallback.
|
||||
final dir = await Directory.systemTemp.createTemp('clide-not-git-');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final git = GitClient(toolchain: _toolchain(), workDir: dir);
|
||||
expect(await git.diff(), isEmpty);
|
||||
expect(await git.log(), isEmpty);
|
||||
expect(await git.currentBranch(), isNull);
|
||||
expect(await git.branches(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseLog — pure parser', () {
|
||||
test('empty input returns an empty list', () {
|
||||
expect(parseLog(''), isEmpty);
|
||||
expect(parseLog(' \n'), isEmpty);
|
||||
});
|
||||
|
||||
test('skips records with fewer than 5 fields', () {
|
||||
// Only 3 fields between record separators.
|
||||
expect(parseLog('abc\x00def\x00ghi\x01'), isEmpty);
|
||||
});
|
||||
|
||||
test('parses a full record with a body', () {
|
||||
const record = 'fullhash\x00short\x00subject line\x00Author\x002026-05-13\x00body text\x01';
|
||||
final r = parseLog(record);
|
||||
expect(r, hasLength(1));
|
||||
expect(r.first.hash, 'fullhash');
|
||||
expect(r.first.subject, 'subject line');
|
||||
expect(r.first.body, 'body text');
|
||||
});
|
||||
|
||||
test('handles records without a body (5 fields exactly)', () {
|
||||
const record = 'fullhash\x00short\x00subject\x00Author\x002026-05-13\x01';
|
||||
final r = parseLog(record);
|
||||
expect(r, hasLength(1));
|
||||
expect(r.first.body, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user