add git subsystem — daemon-side status, diff, operations + IPC

Shell-outs to git for status (porcelain v1/v2), unified-diff
parsing, and operations (stage, unstage, hunk-apply, discard,
commit, stash, log, pull, push). IPC verbs git.* registered on
the daemon dispatcher with git.changed event emission on
mutations. 42 new core tests.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 11:09:16 +02:00
co-authored by Claude
parent 681ce3b447
commit f619760e7b
9 changed files with 1626 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/src/daemon/git_commands.dart';
import 'package:test/test.dart';
void main() {
late Directory sandbox;
late DaemonDispatcher dispatcher;
late RecordingEventSink sink;
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-cmd-test-');
await Process.run('git', ['init'], workingDirectory: sandbox.path);
await Process.run(
'git',
['config', 'user.email', 'test@test.com'],
workingDirectory: sandbox.path,
);
await Process.run(
'git',
['config', 'user.name', 'Test'],
workingDirectory: sandbox.path,
);
await File('${sandbox.path}/file.txt').writeAsString('hello\n');
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
await Process.run(
'git',
['commit', '-m', 'init'],
workingDirectory: sandbox.path,
);
sink = RecordingEventSink();
dispatcher = DaemonDispatcher();
registerGitCommands(dispatcher, sandbox, sink);
});
tearDown(() async {
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
});
Future<IpcResponse> call(String cmd,
[Map<String, Object?> args = const {}]) {
return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args));
}
test('git.status returns clean status', () async {
final r = await call('git.status');
expect(r.ok, isTrue);
expect(r.data['clean'], isTrue);
expect(r.data['branch'], isNotNull);
});
test('git.status shows untracked files', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
final r = await call('git.status');
expect(r.ok, isTrue);
final untracked = r.data['untracked'] as List;
expect(untracked, hasLength(1));
});
test('git.stage + git.status shows staged file', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
final stage = await call('git.stage', {'paths': ['new.txt']});
expect(stage.ok, isTrue);
final r = await call('git.status');
final staged = r.data['staged'] as List;
expect(staged, hasLength(1));
});
test('git.stage without paths returns error', () async {
final r = await call('git.stage');
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('git.unstage removes from staging', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
await call('git.stage', {'paths': ['new.txt']});
final unstage = await call('git.unstage', {'paths': ['new.txt']});
expect(unstage.ok, isTrue);
final r = await call('git.status');
final staged = r.data['staged'] as List;
expect(staged, isEmpty);
});
test('git.commit creates a commit', () async {
await File('${sandbox.path}/c.txt').writeAsString('x');
await call('git.stage', {'paths': ['c.txt']});
final r = await call('git.commit', {'message': 'test commit'});
expect(r.ok, isTrue);
expect(r.data['hash'], hasLength(40));
});
test('git.commit without message returns error', () async {
final r = await call('git.commit');
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('git.diff returns diffs for modified files', () async {
await File('${sandbox.path}/file.txt').writeAsString('modified\n');
final r = await call('git.diff');
expect(r.ok, isTrue);
final diffs = r.data['diffs'] as List;
expect(diffs, hasLength(1));
});
test('git.diff --staged returns staged diffs', () async {
await File('${sandbox.path}/file.txt').writeAsString('modified\n');
await call('git.stage', {'paths': ['file.txt']});
final r = await call('git.diff', {'staged': true});
expect(r.ok, isTrue);
final diffs = r.data['diffs'] as List;
expect(diffs, hasLength(1));
});
test('git.log returns entries', () async {
final r = await call('git.log');
expect(r.ok, isTrue);
final entries = r.data['entries'] as List;
expect(entries, isNotEmpty);
});
test('git.discard restores a file', () async {
await File('${sandbox.path}/file.txt').writeAsString('changed');
final r = await call('git.discard', {'paths': ['file.txt']});
expect(r.ok, isTrue);
final content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'hello\n');
});
test('git.discard without paths returns error', () async {
final r = await call('git.discard');
expect(r.ok, isFalse);
expect(r.error!.kind, 'user_error');
});
test('mutations emit git.changed events', () async {
await File('${sandbox.path}/e.txt').writeAsString('x');
await call('git.stage', {'paths': ['e.txt']});
expect(
sink.events,
contains(predicate<IpcEvent>((e) => e.kind == 'git.changed')),
);
});
test('git.stage-all stages everything', () async {
await File('${sandbox.path}/a.txt').writeAsString('a');
await File('${sandbox.path}/b.txt').writeAsString('b');
final r = await call('git.stage-all');
expect(r.ok, isTrue);
final status = await call('git.status');
final staged = status.data['staged'] as List;
expect(staged.length, greaterThanOrEqualTo(2));
});
test('git.stash and git.stash-pop round-trip', () async {
await File('${sandbox.path}/file.txt').writeAsString('stash-me');
final stash = await call('git.stash');
expect(stash.ok, isTrue);
var content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'hello\n');
final pop = await call('git.stash-pop');
expect(pop.ok, isTrue);
content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'stash-me');
});
}