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/editor/buffer.dart';
|
||||||
export 'src/files/ignore.dart';
|
export 'src/files/ignore.dart';
|
||||||
export 'src/files/listing.dart' show FileEntry, listDir;
|
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/envelope.dart';
|
||||||
export 'src/ipc/paths.dart';
|
export 'src/ipc/paths.dart';
|
||||||
export 'src/ipc/schema_v1.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user