diff --git a/lib/clide.dart b/lib/clide.dart index cfe5a20d..229bcfdd 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -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'; diff --git a/lib/src/daemon/git_commands.dart b/lib/src/daemon/git_commands.dart new file mode 100644 index 00000000..b7903a81 --- /dev/null +++ b/lib/src/daemon/git_commands.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 _pathList(Object? raw) { + if (raw is List) return raw.cast(); + 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, + ), + ); +} diff --git a/lib/src/git/diff.dart b/lib/src/git/diff.dart new file mode 100644 index 00000000..9a3c37e7 --- /dev/null +++ b/lib/src/git/diff.dart @@ -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 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 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 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 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 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> gitDiff( + Directory workDir, { + bool staged = false, + List 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 parseDiffOutput(String output) { + if (output.isEmpty) return const []; + final diffs = []; + 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 = []; + 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 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 = []; + 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, + ); +} diff --git a/lib/src/git/operations.dart b/lib/src/git/operations.dart new file mode 100644 index 00000000..c14a0e8d --- /dev/null +++ b/lib/src/git/operations.dart @@ -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 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 gitStage(Directory workDir, List 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 gitUnstage(Directory workDir, List 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 gitStageHunk(Directory workDir, String patch) async { + await _applyPatch(workDir, patch, cached: true); +} + +/// Unstage a single hunk via `git apply --cached --reverse`. +Future gitUnstageHunk(Directory workDir, String patch) async { + await _applyPatch(workDir, patch, cached: true, reverse: true); +} + +/// Discard unstaged changes for [paths]. Uses `git checkout -- `. +Future gitDiscard(Directory workDir, List 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 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 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 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> 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 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 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 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 _parseLog(String output) { + if (output.trim().isEmpty) return const []; + final records = output.split('\x01'); + final entries = []; + 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 _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, + ); + } +} diff --git a/lib/src/git/status.dart b/lib/src/git/status.dart new file mode 100644 index 00000000..50f928ff --- /dev/null +++ b/lib/src/git/status.dart @@ -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 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 entries; + + List get staged => + entries.where((e) => e.isStaged).toList(); + List get unstaged => + entries.where((e) => e.isUnstaged).toList(); + List get untracked => + entries.where((e) => e.isUntracked).toList(); + List get conflicted => + entries.where((e) => e.isConflicted).toList(); + + bool get isClean => entries.isEmpty; + bool get hasConflicts => entries.any((e) => e.isConflicted); + + Map 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(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 _parsePorcelainV1(String output) { + if (output.isEmpty) return const []; + final entries = []; + 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, + }; +} diff --git a/test/daemon/git_commands_test.dart b/test/daemon/git_commands_test.dart new file mode 100644 index 00000000..1adf0023 --- /dev/null +++ b/test/daemon/git_commands_test.dart @@ -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 call(String cmd, + [Map 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((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'); + }); +} diff --git a/test/git/diff_test.dart b/test/git/diff_test.dart new file mode 100644 index 00000000..5ffad22a --- /dev/null +++ b/test/git/diff_test.dart @@ -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); + }); + }); +} diff --git a/test/git/operations_test.dart b/test/git/operations_test.dart new file mode 100644 index 00000000..9f4a8fa2 --- /dev/null +++ b/test/git/operations_test.dart @@ -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()), + ); + }); + + 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); + }); +} diff --git a/test/git/status_test.dart b/test/git/status_test.dart new file mode 100644 index 00000000..10d17908 --- /dev/null +++ b/test/git/status_test.dart @@ -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); + }); +}