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
+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,
};
}