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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user