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
+4
View File
@@ -20,6 +20,10 @@ export 'src/daemon/dispatcher.dart';
export 'src/editor/buffer.dart';
export 'src/files/ignore.dart';
export 'src/files/listing.dart' show FileEntry, listDir;
export 'src/git/diff.dart' show GitDiff, GitHunk, DiffLine, DiffLineKind;
export 'src/git/operations.dart' show GitLogEntry, GitException;
export 'src/git/status.dart'
show GitStatus, GitFileStatus, GitFileState, GitConflictType;
export 'src/ipc/envelope.dart';
export 'src/ipc/paths.dart';
export 'src/ipc/schema_v1.dart';
+248
View File
@@ -0,0 +1,248 @@
/// Registers `git.*` command handlers on the daemon dispatcher.
///
/// Verbs follow D-006's subsystem contract. Every mutation emits a
/// `git.changed` event so subscribers (the git panel, `clide tail`)
/// can refresh.
library;
import 'dart:io';
import '../git/diff.dart';
import '../git/operations.dart';
import '../git/status.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
import 'dispatcher.dart';
void registerGitCommands(
DaemonDispatcher d,
Directory workDir,
DaemonEventSink events,
) {
d.register('git.status', (req) async {
final status = await gitStatus(workDir);
return IpcResponse.ok(id: req.id, data: status.toJson());
});
d.register('git.diff', (req) async {
final staged = req.args['staged'] as bool? ?? false;
final paths = _pathList(req.args['paths']);
final diffs = await gitDiff(workDir, staged: staged, paths: paths);
return IpcResponse.ok(id: req.id, data: {
'staged': staged,
'diffs': [for (final d in diffs) d.toJson()],
});
});
d.register('git.stage', (req) async {
final paths = _pathList(req.args['paths']);
if (paths.isEmpty) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'git.stage requires paths',
hint: 'pass {paths: ["file.txt"]}',
),
);
}
try {
await gitStage(workDir, paths);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'staged': paths});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.stage-all', (req) async {
try {
await gitStage(workDir, const []);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'staged': 'all'});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.unstage', (req) async {
final paths = _pathList(req.args['paths']);
try {
await gitUnstage(workDir, paths);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'unstaged': paths});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.stage-hunk', (req) async {
final patch = req.args['patch'] as String?;
if (patch == null || patch.isEmpty) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'git.stage-hunk requires a patch',
),
);
}
try {
await gitStageHunk(workDir, patch);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'applied': true});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.unstage-hunk', (req) async {
final patch = req.args['patch'] as String?;
if (patch == null || patch.isEmpty) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'git.unstage-hunk requires a patch',
),
);
}
try {
await gitUnstageHunk(workDir, patch);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'applied': true});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.discard', (req) async {
final paths = _pathList(req.args['paths']);
if (paths.isEmpty) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'git.discard requires paths',
),
);
}
try {
await gitDiscard(workDir, paths);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'discarded': paths});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.commit', (req) async {
final message = req.args['message'] as String?;
if (message == null || message.isEmpty) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'git.commit requires a message',
),
);
}
try {
final hash = await gitCommit(workDir, message);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'hash': hash});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.stash', (req) async {
final message = req.args['message'] as String?;
final includeUntracked = req.args['includeUntracked'] as bool? ?? false;
try {
await gitStash(workDir, message: message, includeUntracked: includeUntracked);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'stashed': true});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.stash-pop', (req) async {
try {
await gitStashPop(workDir);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: const {'popped': true});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.log', (req) async {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
final entries = await gitLog(workDir, count: count);
return IpcResponse.ok(id: req.id, data: {
'entries': [for (final e in entries) e.toJson()],
});
});
d.register('git.pull', (req) async {
try {
final output = await gitPull(workDir);
_emitChanged(events);
return IpcResponse.ok(id: req.id, data: {'output': output});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
d.register('git.push', (req) async {
final remote = req.args['remote'] as String?;
final branch = req.args['branch'] as String?;
final setUpstream = req.args['setUpstream'] as bool? ?? false;
try {
final output = await gitPush(
workDir,
remote: remote,
branch: branch,
setUpstream: setUpstream,
);
return IpcResponse.ok(id: req.id, data: {'output': output});
} on GitException catch (e) {
return _gitError(req.id, e);
}
});
}
List<String> _pathList(Object? raw) {
if (raw is List) return raw.cast<String>();
if (raw is String) return [raw];
return const [];
}
void _emitChanged(DaemonEventSink events) {
events.emit(IpcEvent(
subsystem: 'git',
kind: 'git.changed',
timestamp: DateTime.now().toUtc(),
data: const {},
));
}
IpcResponse _gitError(String id, GitException e) {
return IpcResponse.err(
id: id,
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: e.message,
hint: e.stderr.isNotEmpty ? e.stderr : null,
),
);
}
+296
View File
@@ -0,0 +1,296 @@
/// Git diff data model and parser.
///
/// Shells out to `git diff` and parses unified-diff output into
/// typed [GitDiff] / [GitHunk] / [DiffLine] structures. Supports
/// both staged (`--cached`) and unstaged diffs.
library;
import 'dart:io';
enum DiffLineKind { context, addition, removal, header }
class DiffLine {
const DiffLine({
required this.kind,
required this.text,
this.oldLineNo,
this.newLineNo,
});
final DiffLineKind kind;
final String text;
final int? oldLineNo;
final int? newLineNo;
Map<String, Object?> toJson() => {
'kind': kind.name,
'text': text,
if (oldLineNo != null) 'oldLineNo': oldLineNo,
if (newLineNo != null) 'newLineNo': newLineNo,
};
}
class GitHunk {
const GitHunk({
required this.header,
required this.oldStart,
required this.oldCount,
required this.newStart,
required this.newCount,
required this.lines,
});
final String header;
final int oldStart;
final int oldCount;
final int newStart;
final int newCount;
final List<DiffLine> lines;
int get additions => lines.where((l) => l.kind == DiffLineKind.addition).length;
int get removals => lines.where((l) => l.kind == DiffLineKind.removal).length;
String toPatch() {
final buf = StringBuffer()..writeln(header);
for (final line in lines) {
switch (line.kind) {
case DiffLineKind.addition:
buf.writeln('+${line.text}');
case DiffLineKind.removal:
buf.writeln('-${line.text}');
case DiffLineKind.context:
buf.writeln(' ${line.text}');
case DiffLineKind.header:
buf.writeln(line.text);
}
}
return buf.toString();
}
Map<String, Object?> toJson() => {
'header': header,
'oldStart': oldStart,
'oldCount': oldCount,
'newStart': newStart,
'newCount': newCount,
'additions': additions,
'removals': removals,
'lines': [for (final l in lines) l.toJson()],
};
}
class GitDiff {
const GitDiff({
required this.path,
required this.hunks,
this.oldPath,
this.isBinary = false,
this.isNew = false,
this.isDeleted = false,
this.isRenamed = false,
});
final String path;
final String? oldPath;
final List<GitHunk> hunks;
final bool isBinary;
final bool isNew;
final bool isDeleted;
final bool isRenamed;
int get additions => hunks.fold(0, (s, h) => s + h.additions);
int get removals => hunks.fold(0, (s, h) => s + h.removals);
Map<String, Object?> toJson() => {
'path': path,
if (oldPath != null) 'oldPath': oldPath,
'binary': isBinary,
'new': isNew,
'deleted': isDeleted,
'renamed': isRenamed,
'additions': additions,
'removals': removals,
'hunks': [for (final h in hunks) h.toJson()],
};
}
/// Run `git diff` and parse the result.
///
/// [staged] controls `--cached`. [paths] narrows to specific files.
Future<List<GitDiff>> gitDiff(
Directory workDir, {
bool staged = false,
List<String> paths = const [],
}) async {
final args = ['diff', '--unified=3'];
if (staged) args.add('--cached');
if (paths.isNotEmpty) {
args.add('--');
args.addAll(paths);
}
final result = await Process.run(
'git',
args,
workingDirectory: workDir.path,
);
if (result.exitCode != 0) return const [];
return parseDiffOutput(result.stdout as String);
}
/// Parse unified-diff text into [GitDiff] objects.
List<GitDiff> parseDiffOutput(String output) {
if (output.isEmpty) return const [];
final diffs = <GitDiff>[];
final lines = output.split('\n');
var i = 0;
while (i < lines.length) {
if (!lines[i].startsWith('diff --git ')) {
i++;
continue;
}
String? path;
String? oldPath;
var isBinary = false;
var isNew = false;
var isDeleted = false;
var isRenamed = false;
// Parse the diff header block.
final diffLine = lines[i];
final aMatch = RegExp(r'^diff --git a/(.+) b/(.+)$').firstMatch(diffLine);
if (aMatch != null) {
oldPath = aMatch.group(1);
path = aMatch.group(2);
}
i++;
while (i < lines.length && !lines[i].startsWith('diff --git ')) {
final line = lines[i];
if (line.startsWith('new file mode')) {
isNew = true;
} else if (line.startsWith('deleted file mode')) {
isDeleted = true;
} else if (line.startsWith('rename from ')) {
isRenamed = true;
oldPath = line.substring('rename from '.length);
} else if (line.startsWith('rename to ')) {
path = line.substring('rename to '.length);
} else if (line.startsWith('Binary files')) {
isBinary = true;
} else if (line.startsWith('--- a/')) {
oldPath = line.substring('--- a/'.length);
} else if (line.startsWith('+++ b/')) {
path = line.substring('+++ b/'.length);
} else if (line.startsWith('@@')) {
break; // Start parsing hunks.
}
i++;
}
if (path == null) {
continue;
}
final hunks = <GitHunk>[];
while (i < lines.length && !lines[i].startsWith('diff --git ')) {
final line = lines[i];
if (!line.startsWith('@@')) {
i++;
continue;
}
final hunk = _parseHunk(lines, i);
if (hunk != null) {
hunks.add(hunk.hunk);
i = hunk.endIndex;
} else {
i++;
}
}
diffs.add(GitDiff(
path: path,
oldPath: isRenamed ? oldPath : null,
hunks: hunks,
isBinary: isBinary,
isNew: isNew,
isDeleted: isDeleted,
isRenamed: isRenamed,
));
}
return diffs;
}
class _HunkParseResult {
const _HunkParseResult(this.hunk, this.endIndex);
final GitHunk hunk;
final int endIndex;
}
final _hunkHeaderRe = RegExp(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$');
_HunkParseResult? _parseHunk(List<String> lines, int start) {
final match = _hunkHeaderRe.firstMatch(lines[start]);
if (match == null) return null;
final oldStart = int.parse(match.group(1)!);
final oldCount = int.parse(match.group(2) ?? '1');
final newStart = int.parse(match.group(3)!);
final newCount = int.parse(match.group(4) ?? '1');
final header = lines[start];
final hunkLines = <DiffLine>[];
var oldLine = oldStart;
var newLine = newStart;
var i = start + 1;
while (i < lines.length) {
final line = lines[i];
if (line.startsWith('diff --git ') || line.startsWith('@@')) break;
if (line.startsWith('+')) {
hunkLines.add(DiffLine(
kind: DiffLineKind.addition,
text: line.substring(1),
newLineNo: newLine,
));
newLine++;
} else if (line.startsWith('-')) {
hunkLines.add(DiffLine(
kind: DiffLineKind.removal,
text: line.substring(1),
oldLineNo: oldLine,
));
oldLine++;
} else if (line.startsWith(' ')) {
hunkLines.add(DiffLine(
kind: DiffLineKind.context,
text: line.substring(1),
oldLineNo: oldLine,
newLineNo: newLine,
));
oldLine++;
newLine++;
} else if (line == r'\ No newline at end of file') {
hunkLines.add(DiffLine(
kind: DiffLineKind.header,
text: line,
));
} else {
break;
}
i++;
}
return _HunkParseResult(
GitHunk(
header: header,
oldStart: oldStart,
oldCount: oldCount,
newStart: newStart,
newCount: newCount,
lines: hunkLines,
),
i,
);
}
+260
View File
@@ -0,0 +1,260 @@
/// Git operations — staging, committing, stashing, log, pull, push.
///
/// Each function shells out to `git` and returns either a typed result
/// or throws [GitException] on failure. All operations are workspace-
/// rooted (take a [Directory] argument).
library;
import 'dart:io';
class GitException implements Exception {
const GitException(this.message, {this.stderr = ''});
final String message;
final String stderr;
@override
String toString() => 'GitException: $message';
}
class GitLogEntry {
const GitLogEntry({
required this.hash,
required this.shortHash,
required this.subject,
required this.author,
required this.date,
this.body = '',
});
final String hash;
final String shortHash;
final String subject;
final String author;
final String date;
final String body;
Map<String, Object?> toJson() => {
'hash': hash,
'shortHash': shortHash,
'subject': subject,
'author': author,
'date': date,
if (body.isNotEmpty) 'body': body,
};
}
/// Stage files. Empty [paths] means stage all (`git add -A`).
Future<void> gitStage(Directory workDir, List<String> paths) async {
final args = ['add'];
if (paths.isEmpty) {
args.add('-A');
} else {
args.add('--');
args.addAll(paths);
}
final r = await Process.run('git', args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git add failed', stderr: r.stderr as String);
}
}
/// Unstage files. Empty [paths] means unstage all.
Future<void> gitUnstage(Directory workDir, List<String> paths) async {
final args = ['reset', 'HEAD'];
if (paths.isNotEmpty) {
args.add('--');
args.addAll(paths);
}
final r = await Process.run('git', args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git reset failed', stderr: r.stderr as String);
}
}
/// Stage a single hunk via `git apply --cached`.
Future<void> gitStageHunk(Directory workDir, String patch) async {
await _applyPatch(workDir, patch, cached: true);
}
/// Unstage a single hunk via `git apply --cached --reverse`.
Future<void> gitUnstageHunk(Directory workDir, String patch) async {
await _applyPatch(workDir, patch, cached: true, reverse: true);
}
/// Discard unstaged changes for [paths]. Uses `git checkout -- <paths>`.
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
if (paths.isEmpty) return;
final r = await Process.run(
'git',
['checkout', '--', ...paths],
workingDirectory: workDir.path,
);
if (r.exitCode != 0) {
throw GitException('git checkout failed', stderr: r.stderr as String);
}
}
/// Commit staged changes.
Future<String> gitCommit(
Directory workDir,
String message, {
bool amend = false,
}) async {
final args = ['commit', '-m', message];
if (amend) args.add('--amend');
final r = await Process.run('git', args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git commit failed', stderr: r.stderr as String);
}
// Return the new commit hash.
final hashResult = await Process.run(
'git',
['rev-parse', 'HEAD'],
workingDirectory: workDir.path,
);
return (hashResult.stdout as String).trim();
}
/// Stash working changes.
Future<void> gitStash(
Directory workDir, {
String? message,
bool includeUntracked = false,
}) async {
final args = ['stash', 'push'];
if (message != null) {
args.addAll(['-m', message]);
}
if (includeUntracked) args.add('--include-untracked');
final r = await Process.run('git', args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git stash failed', stderr: r.stderr as String);
}
}
/// Pop the top stash entry.
Future<void> gitStashPop(Directory workDir) async {
final r = await Process.run(
'git',
['stash', 'pop'],
workingDirectory: workDir.path,
);
if (r.exitCode != 0) {
throw GitException('git stash pop failed', stderr: r.stderr as String);
}
}
/// Git log. Returns the most recent [count] entries.
Future<List<GitLogEntry>> gitLog(
Directory workDir, {
int count = 20,
}) async {
final r = await Process.run(
'git',
[
'log',
'--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01',
'-n',
'$count',
],
workingDirectory: workDir.path,
);
if (r.exitCode != 0) return const [];
return _parseLog(r.stdout as String);
}
/// Pull from remote.
Future<String> gitPull(Directory workDir) async {
final r = await Process.run(
'git',
['pull'],
workingDirectory: workDir.path,
);
if (r.exitCode != 0) {
throw GitException('git pull failed', stderr: r.stderr as String);
}
return (r.stdout as String).trim();
}
/// Push to remote.
Future<String> gitPush(
Directory workDir, {
String? remote,
String? branch,
bool setUpstream = false,
}) async {
final args = ['push'];
if (setUpstream) args.add('-u');
if (remote != null) args.add(remote);
if (branch != null) args.add(branch);
final r = await Process.run('git', args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git push failed', stderr: r.stderr as String);
}
return ((r.stdout as String) + (r.stderr as String)).trim();
}
/// Get the current branch name.
Future<String?> gitCurrentBranch(Directory workDir) async {
final r = await Process.run(
'git',
['symbolic-ref', '--short', 'HEAD'],
workingDirectory: workDir.path,
);
if (r.exitCode != 0) return null;
return (r.stdout as String).trim();
}
// ---------------------------------------------------------------------------
List<GitLogEntry> _parseLog(String output) {
if (output.trim().isEmpty) return const [];
final records = output.split('\x01');
final entries = <GitLogEntry>[];
for (final record in records) {
final trimmed = record.trim();
if (trimmed.isEmpty) continue;
final fields = trimmed.split('\x00');
if (fields.length < 5) continue;
entries.add(GitLogEntry(
hash: fields[0],
shortHash: fields[1],
subject: fields[2],
author: fields[3],
date: fields[4],
body: fields.length > 5 ? fields[5].trim() : '',
));
}
return entries;
}
Future<void> _applyPatch(
Directory workDir,
String patch, {
bool cached = false,
bool reverse = false,
}) async {
final args = ['apply'];
if (cached) args.add('--cached');
if (reverse) args.add('--reverse');
args.add('--unidiff-zero');
args.add('-');
final proc = await Process.start(
'git',
args,
workingDirectory: workDir.path,
);
proc.stdin.write(patch);
await proc.stdin.close();
final exitCode = await proc.exitCode;
if (exitCode != 0) {
final stderr = await proc.stderr
.transform(const SystemEncoding().decoder)
.join();
throw GitException(
'git apply failed',
stderr: stderr,
);
}
}
+229
View File
@@ -0,0 +1,229 @@
/// Git status data model and parser.
///
/// Shells out to `git status --porcelain=v1 -z` and parses the
/// NUL-delimited output into typed [GitFileStatus] entries grouped
/// by state (staged, unstaged, untracked, conflicted).
library;
import 'dart:io';
enum GitFileState {
added,
modified,
deleted,
renamed,
copied,
untracked,
ignored,
}
enum GitConflictType {
bothModified,
bothAdded,
addedByUs,
addedByThem,
deletedByUs,
deletedByThem,
bothDeleted,
}
class GitFileStatus {
const GitFileStatus({
required this.path,
required this.indexState,
required this.workTreeState,
this.origPath,
this.conflictType,
});
final String path;
final GitFileState? indexState;
final GitFileState? workTreeState;
final String? origPath;
final GitConflictType? conflictType;
bool get isStaged =>
indexState != null &&
indexState != GitFileState.untracked &&
indexState != GitFileState.ignored &&
!isConflicted;
bool get isUnstaged =>
workTreeState != null &&
workTreeState != GitFileState.untracked &&
!isConflicted;
bool get isUntracked => workTreeState == GitFileState.untracked;
bool get isConflicted => conflictType != null;
Map<String, Object?> toJson() => {
'path': path,
if (indexState != null) 'indexState': indexState!.name,
if (workTreeState != null) 'workTreeState': workTreeState!.name,
if (origPath != null) 'origPath': origPath,
if (conflictType != null) 'conflictType': conflictType!.name,
'staged': isStaged,
'unstaged': isUnstaged,
'untracked': isUntracked,
'conflicted': isConflicted,
};
}
class GitStatus {
const GitStatus({
required this.branch,
required this.entries,
this.upstream,
this.ahead = 0,
this.behind = 0,
});
final String? branch;
final String? upstream;
final int ahead;
final int behind;
final List<GitFileStatus> entries;
List<GitFileStatus> get staged =>
entries.where((e) => e.isStaged).toList();
List<GitFileStatus> get unstaged =>
entries.where((e) => e.isUnstaged).toList();
List<GitFileStatus> get untracked =>
entries.where((e) => e.isUntracked).toList();
List<GitFileStatus> get conflicted =>
entries.where((e) => e.isConflicted).toList();
bool get isClean => entries.isEmpty;
bool get hasConflicts => entries.any((e) => e.isConflicted);
Map<String, Object?> toJson() => {
'branch': branch,
if (upstream != null) 'upstream': upstream,
'ahead': ahead,
'behind': behind,
'clean': isClean,
'hasConflicts': hasConflicts,
'staged': [for (final e in staged) e.toJson()],
'unstaged': [for (final e in unstaged) e.toJson()],
'untracked': [for (final e in untracked) e.toJson()],
'conflicted': [for (final e in conflicted) e.toJson()],
};
}
/// Run `git status` and parse the result.
Future<GitStatus> gitStatus(Directory workDir) async {
final branchResult = await Process.run(
'git',
['status', '--porcelain=v2', '--branch', '-z'],
workingDirectory: workDir.path,
);
String? branch;
String? upstream;
int ahead = 0;
int behind = 0;
if (branchResult.exitCode == 0) {
final output = branchResult.stdout as String;
for (final line in output.split('\x00')) {
if (line.startsWith('# branch.head ')) {
branch = line.substring('# branch.head '.length);
} else if (line.startsWith('# branch.upstream ')) {
upstream = line.substring('# branch.upstream '.length);
} else if (line.startsWith('# branch.ab ')) {
final parts = line.substring('# branch.ab '.length).split(' ');
if (parts.length >= 2) {
ahead = int.tryParse(parts[0].replaceFirst('+', '')) ?? 0;
behind = int.tryParse(parts[1].replaceFirst('-', '')) ?? 0;
}
}
}
}
final result = await Process.run(
'git',
['status', '--porcelain=v1', '-z'],
workingDirectory: workDir.path,
);
if (result.exitCode != 0) {
return GitStatus(
branch: branch,
upstream: upstream,
ahead: ahead,
behind: behind,
entries: const [],
);
}
final entries = _parsePorcelainV1(result.stdout as String);
return GitStatus(
branch: branch,
upstream: upstream,
ahead: ahead,
behind: behind,
entries: entries,
);
}
List<GitFileStatus> _parsePorcelainV1(String output) {
if (output.isEmpty) return const [];
final entries = <GitFileStatus>[];
final parts = output.split('\x00');
var i = 0;
while (i < parts.length) {
final part = parts[i];
if (part.isEmpty) {
i++;
continue;
}
if (part.length < 4) {
i++;
continue;
}
final x = part[0]; // index state
final y = part[1]; // work-tree state
final path = part.substring(3);
// Renames/copies have the original path as the next NUL-delimited
// field (porcelain v1 with -z).
String? origPath;
if (x == 'R' || x == 'C') {
i++;
if (i < parts.length) origPath = parts[i];
}
final conflict = _conflictType(x, y);
entries.add(GitFileStatus(
path: path,
indexState: conflict != null ? null : _parseState(x),
workTreeState: conflict != null ? null : _parseState(y),
origPath: origPath,
conflictType: conflict,
));
i++;
}
return entries;
}
GitConflictType? _conflictType(String x, String y) {
if (x == 'D' && y == 'D') return GitConflictType.bothDeleted;
if (x == 'A' && y == 'U') return GitConflictType.addedByUs;
if (x == 'U' && y == 'D') return GitConflictType.deletedByThem;
if (x == 'U' && y == 'A') return GitConflictType.addedByThem;
if (x == 'D' && y == 'U') return GitConflictType.deletedByUs;
if (x == 'A' && y == 'A') return GitConflictType.bothAdded;
if (x == 'U' && y == 'U') return GitConflictType.bothModified;
return null;
}
GitFileState? _parseState(String code) {
return switch (code) {
'M' => GitFileState.modified,
'A' => GitFileState.added,
'D' => GitFileState.deleted,
'R' => GitFileState.renamed,
'C' => GitFileState.copied,
'?' => GitFileState.untracked,
'!' => GitFileState.ignored,
_ => null,
};
}
+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');
});
}
+213
View File
@@ -0,0 +1,213 @@
import 'dart:io';
import 'package:clide/src/git/diff.dart';
import 'package:test/test.dart';
void main() {
group('parseDiffOutput', () {
test('parses a simple modification', () {
const output = '''diff --git a/file.txt b/file.txt
index abc1234..def5678 100644
--- a/file.txt
+++ b/file.txt
@@ -1,3 +1,4 @@
line1
-line2
+line2-modified
+line3-new
line4
''';
final diffs = parseDiffOutput(output);
expect(diffs, hasLength(1));
expect(diffs.first.path, 'file.txt');
expect(diffs.first.hunks, hasLength(1));
expect(diffs.first.additions, 2);
expect(diffs.first.removals, 1);
});
test('parses a new file', () {
const output = '''diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..abc1234
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+hello
+world
''';
final diffs = parseDiffOutput(output);
expect(diffs, hasLength(1));
expect(diffs.first.isNew, isTrue);
expect(diffs.first.additions, 2);
});
test('parses a deleted file', () {
const output = '''diff --git a/old.txt b/old.txt
deleted file mode 100644
index abc1234..0000000
--- a/old.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-hello
-world
''';
final diffs = parseDiffOutput(output);
expect(diffs, hasLength(1));
expect(diffs.first.isDeleted, isTrue);
expect(diffs.first.removals, 2);
});
test('parses a rename', () {
const output = '''diff --git a/old.txt b/new.txt
similarity index 100%
rename from old.txt
rename to new.txt
''';
final diffs = parseDiffOutput(output);
expect(diffs, hasLength(1));
expect(diffs.first.isRenamed, isTrue);
expect(diffs.first.path, 'new.txt');
expect(diffs.first.oldPath, 'old.txt');
});
test('parses multiple hunks', () {
const output = '''diff --git a/multi.txt b/multi.txt
index abc..def 100644
--- a/multi.txt
+++ b/multi.txt
@@ -1,3 +1,3 @@
line1
-old2
+new2
line3
@@ -10,3 +10,3 @@
line10
-old11
+new11
line12
''';
final diffs = parseDiffOutput(output);
expect(diffs, hasLength(1));
expect(diffs.first.hunks, hasLength(2));
expect(diffs.first.hunks[0].oldStart, 1);
expect(diffs.first.hunks[1].oldStart, 10);
});
test('parses binary file', () {
const output = '''diff --git a/image.png b/image.png
Binary files a/image.png and b/image.png differ
''';
final diffs = parseDiffOutput(output);
expect(diffs, hasLength(1));
expect(diffs.first.isBinary, isTrue);
expect(diffs.first.hunks, isEmpty);
});
test('empty output returns empty list', () {
expect(parseDiffOutput(''), isEmpty);
});
test('hunk line numbers are correct', () {
const output = '''diff --git a/f.txt b/f.txt
index abc..def 100644
--- a/f.txt
+++ b/f.txt
@@ -5,4 +5,5 @@
ctx
-removed
+added1
+added2
ctx2
''';
final diffs = parseDiffOutput(output);
final hunk = diffs.first.hunks.first;
expect(hunk.oldStart, 5);
expect(hunk.newStart, 5);
final additions = hunk.lines.where((l) => l.kind == DiffLineKind.addition).toList();
expect(additions[0].newLineNo, 6);
expect(additions[1].newLineNo, 7);
final removals = hunk.lines.where((l) => l.kind == DiffLineKind.removal).toList();
expect(removals[0].oldLineNo, 6);
});
test('multiple diffs in one output', () {
const output = '''diff --git a/a.txt b/a.txt
index abc..def 100644
--- a/a.txt
+++ b/a.txt
@@ -1 +1 @@
-old
+new
diff --git a/b.txt b/b.txt
index abc..def 100644
--- a/b.txt
+++ b/b.txt
@@ -1 +1 @@
-old
+new
''';
final diffs = parseDiffOutput(output);
expect(diffs, hasLength(2));
expect(diffs[0].path, 'a.txt');
expect(diffs[1].path, 'b.txt');
});
});
group('gitDiff (live)', () {
late Directory sandbox;
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-diff-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('line1\nline2\n');
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
await Process.run(
'git',
['commit', '-m', 'init'],
workingDirectory: sandbox.path,
);
});
tearDown(() async {
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
});
test('returns unstaged diff after modification', () async {
await File('${sandbox.path}/file.txt')
.writeAsString('line1\nmodified\n');
final diffs = await gitDiff(sandbox);
expect(diffs, hasLength(1));
expect(diffs.first.path, 'file.txt');
expect(diffs.first.additions, greaterThan(0));
});
test('returns staged diff with staged: true', () async {
await File('${sandbox.path}/file.txt')
.writeAsString('line1\nmodified\n');
await Process.run(
'git',
['add', 'file.txt'],
workingDirectory: sandbox.path,
);
final diffs = await gitDiff(sandbox, staged: true);
expect(diffs, hasLength(1));
});
test('returns empty for clean repo', () async {
final diffs = await gitDiff(sandbox);
expect(diffs, isEmpty);
});
});
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:io';
import 'package:clide/src/git/operations.dart';
import 'package:test/test.dart';
void main() {
late Directory sandbox;
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-ops-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,
);
});
tearDown(() async {
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
});
test('gitStage stages a file', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
await gitStage(sandbox, ['new.txt']);
final r = await Process.run(
'git',
['diff', '--cached', '--name-only'],
workingDirectory: sandbox.path,
);
expect((r.stdout as String).trim(), 'new.txt');
});
test('gitUnstage unstages a file', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
await gitStage(sandbox, ['new.txt']);
await gitUnstage(sandbox, ['new.txt']);
final r = await Process.run(
'git',
['diff', '--cached', '--name-only'],
workingDirectory: sandbox.path,
);
expect((r.stdout as String).trim(), isEmpty);
});
test('gitCommit creates a commit', () async {
await File('${sandbox.path}/c.txt').writeAsString('commit me');
await gitStage(sandbox, ['c.txt']);
final hash = await gitCommit(sandbox, 'test commit');
expect(hash, hasLength(40));
final r = await Process.run(
'git',
['log', '-1', '--format=%s'],
workingDirectory: sandbox.path,
);
expect((r.stdout as String).trim(), 'test commit');
});
test('gitCommit with nothing staged throws', () async {
expect(
() => gitCommit(sandbox, 'empty'),
throwsA(isA<GitException>()),
);
});
test('gitLog returns entries', () async {
final entries = await gitLog(sandbox);
expect(entries, hasLength(1));
expect(entries.first.subject, 'init');
expect(entries.first.hash, hasLength(40));
});
test('gitDiscard restores a file', () async {
await File('${sandbox.path}/file.txt').writeAsString('changed');
await gitDiscard(sandbox, ['file.txt']);
final content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'hello\n');
});
test('gitStash and gitStashPop round-trip', () async {
await File('${sandbox.path}/file.txt').writeAsString('stashed');
await gitStash(sandbox);
var content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'hello\n');
await gitStashPop(sandbox);
content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'stashed');
});
test('gitCurrentBranch returns branch name', () async {
final branch = await gitCurrentBranch(sandbox);
expect(branch, isNotNull);
});
}
+95
View File
@@ -0,0 +1,95 @@
import 'dart:io';
import 'package:clide/src/git/status.dart';
import 'package:test/test.dart';
void main() {
late Directory sandbox;
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-git-status-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,
);
// Initial commit so HEAD exists.
await File('${sandbox.path}/.gitkeep').writeAsString('');
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
await Process.run(
'git',
['commit', '-m', 'init'],
workingDirectory: sandbox.path,
);
});
tearDown(() async {
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
});
test('clean repo returns empty entries', () async {
final status = await gitStatus(sandbox);
expect(status.isClean, isTrue);
expect(status.branch, isNotNull);
});
test('untracked file appears in untracked', () async {
await File('${sandbox.path}/new.txt').writeAsString('hello');
final status = await gitStatus(sandbox);
expect(status.untracked, hasLength(1));
expect(status.untracked.first.path, 'new.txt');
});
test('staged file appears in staged', () async {
await File('${sandbox.path}/staged.txt').writeAsString('x');
await Process.run(
'git',
['add', 'staged.txt'],
workingDirectory: sandbox.path,
);
final status = await gitStatus(sandbox);
expect(status.staged, hasLength(1));
expect(status.staged.first.path, 'staged.txt');
expect(status.staged.first.indexState, GitFileState.added);
});
test('modified tracked file appears in unstaged', () async {
await File('${sandbox.path}/.gitkeep').writeAsString('changed');
final status = await gitStatus(sandbox);
expect(status.unstaged, hasLength(1));
expect(status.unstaged.first.workTreeState, GitFileState.modified);
});
test('deleted file appears in unstaged', () async {
await File('${sandbox.path}/.gitkeep').delete();
final status = await gitStatus(sandbox);
expect(status.unstaged, hasLength(1));
expect(status.unstaged.first.workTreeState, GitFileState.deleted);
});
test('file staged and then modified appears in both', () async {
await File('${sandbox.path}/both.txt').writeAsString('v1');
await Process.run(
'git',
['add', 'both.txt'],
workingDirectory: sandbox.path,
);
await File('${sandbox.path}/both.txt').writeAsString('v2');
final status = await gitStatus(sandbox);
expect(status.staged.any((e) => e.path == 'both.txt'), isTrue);
expect(status.unstaged.any((e) => e.path == 'both.txt'), isTrue);
});
test('branch info is populated', () async {
final status = await gitStatus(sandbox);
expect(status.branch, isNotNull);
expect(status.ahead, isZero);
expect(status.behind, isZero);
});
}