diff --git a/pubspec.yaml b/pubspec.yaml index f693f52c..8ba1d188 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 76 +coverage_floor: 77 # Project metadata (was project.yaml, folded in per D-056). # version: above is the single source of truth. The Makefile reads diff --git a/test/git/diff_test.dart b/test/git/diff_test.dart index abfa9379..519b8f97 100644 --- a/test/git/diff_test.dart +++ b/test/git/diff_test.dart @@ -207,5 +207,96 @@ index abc..def 100644 final diffs = await gitDiff(sandbox); expect(diffs, isEmpty); }); + + test('paths argument narrows the diff to that file', () async { + await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n'); + await File('${sandbox.path}/other.txt').writeAsString('o\n'); + final scoped = await gitDiff(sandbox, paths: ['file.txt']); + expect(scoped, hasLength(1)); + expect(scoped.first.path, 'file.txt'); + }); + + test('returns empty on a non-git directory (exit != 0)', () async { + final notGit = await Directory.systemTemp.createTemp('clide-diff-not-'); + addTearDown(() => notGit.deleteSync(recursive: true)); + expect(await gitDiff(notGit), isEmpty); + }); + }); + + group('GitHunk shape', () { + test('toPatch round-trips header + every line kind', () { + const hunk = GitHunk( + header: '@@ -1,3 +1,4 @@ ctx', + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 4, + lines: [ + DiffLine(kind: DiffLineKind.context, text: 'line1', oldLineNo: 1, newLineNo: 1), + DiffLine(kind: DiffLineKind.removal, text: 'line2', oldLineNo: 2), + DiffLine(kind: DiffLineKind.addition, text: 'line2-new', newLineNo: 2), + DiffLine(kind: DiffLineKind.header, text: r'\ No newline at end of file'), + ], + ); + final patch = hunk.toPatch(); + expect(patch, contains('@@ -1,3 +1,4 @@ ctx')); + expect(patch, contains(' line1')); + expect(patch, contains('-line2')); + expect(patch, contains('+line2-new')); + expect(patch, contains(r'\ No newline at end of file')); + }); + + test('GitDiff.toJson serialises with optional oldPath only when set', () { + const withOld = GitDiff(path: 'b.txt', oldPath: 'a.txt', hunks: [], isRenamed: true); + expect(withOld.toJson().containsKey('oldPath'), isTrue); + const noOld = GitDiff(path: 'b.txt', hunks: []); + expect(noOld.toJson().containsKey('oldPath'), isFalse); + }); + }); + + group('parseDiffOutput — edge cases', () { + test('skips lines outside any diff --git block', () { + const output = '''pre-diff garbage +another non-diff line +diff --git a/f.txt b/f.txt +--- a/f.txt ++++ b/f.txt +@@ -1 +1 @@ +-old ++new +'''; + final diffs = parseDiffOutput(output); + expect(diffs, hasLength(1)); + }); + + test('captures the "\\ No newline at end of file" marker as a header line', () { + const output = '''diff --git a/f.txt b/f.txt +--- a/f.txt ++++ b/f.txt +@@ -1,1 +1,1 @@ +-old ++new +\\ No newline at end of file +'''; + final diffs = parseDiffOutput(output); + expect(diffs, hasLength(1)); + final hunk = diffs.first.hunks.single; + expect(hunk.lines.any((l) => l.kind == DiffLineKind.header), isTrue); + }); + + test('skips a malformed @@ header (parseHunk returns null)', () { + const output = '''diff --git a/f.txt b/f.txt +--- a/f.txt ++++ b/f.txt +@@ not a valid hunk header @@ +@@ -1 +1 @@ +-old ++new +'''; + final diffs = parseDiffOutput(output); + expect(diffs, hasLength(1)); + // Only the valid @@ produces a hunk. + expect(diffs.first.hunks, hasLength(1)); + }); }); } diff --git a/test/git/operations_test.dart b/test/git/operations_test.dart index 9f4a8fa2..501ca000 100644 --- a/test/git/operations_test.dart +++ b/test/git/operations_test.dart @@ -104,4 +104,162 @@ void main() { final branch = await gitCurrentBranch(sandbox); expect(branch, isNotNull); }); + + test('GitException.toString includes the message', () { + const e = GitException('boom'); + expect(e.toString(), contains('boom')); + }); + + test('GitLogEntry.toJson serialises every field (body omitted when empty)', () { + const a = GitLogEntry( + hash: 'h', + shortHash: 's', + subject: 'sub', + author: 'a', + date: 'd', + ); + expect(a.toJson().containsKey('body'), isFalse); + const b = GitLogEntry( + hash: 'h', + shortHash: 's', + subject: 'sub', + author: 'a', + date: 'd', + body: 'bd', + ); + expect(b.toJson()['body'], 'bd'); + }); + + test('gitStage with a bogus path throws GitException', () async { + try { + await gitStage(sandbox, ['no-such-file-here']); + fail('expected GitException'); + } on GitException catch (_) {} + }); + + test('gitUnstage with no paths unstages everything', () async { + await File('${sandbox.path}/a.txt').writeAsString('x'); + await File('${sandbox.path}/b.txt').writeAsString('y'); + await gitStage(sandbox, ['a.txt', 'b.txt']); + await gitUnstage(sandbox, const []); + final r = await Process.run( + 'git', + ['diff', '--cached', '--name-only'], + workingDirectory: sandbox.path, + ); + expect((r.stdout as String).trim(), isEmpty); + }); + + test('gitStageHunk + gitUnstageHunk apply a patch via _applyPatch', () async { + await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n'); + final patchResult = await Process.run( + 'git', + ['diff', '-U0'], + workingDirectory: sandbox.path, + ); + final patch = patchResult.stdout as String; + await gitStageHunk(sandbox, patch); + final cached = await Process.run( + 'git', + ['diff', '--cached', '--name-only'], + workingDirectory: sandbox.path, + ); + expect((cached.stdout as String).trim(), 'file.txt'); + await gitUnstageHunk(sandbox, patch); + final cleared = await Process.run( + 'git', + ['diff', '--cached', '--name-only'], + workingDirectory: sandbox.path, + ); + expect((cleared.stdout as String).trim(), isEmpty); + }); + + test('_applyPatch surfaces stderr in the GitException on a bad patch', () async { + try { + await gitStageHunk(sandbox, 'not a valid patch\n'); + fail('expected GitException'); + } on GitException catch (e) { + expect(e.stderr, isNotEmpty); + } + }); + + test('gitBranches lists branches and marks the current one', () async { + await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path); + final branches = await gitBranches(sandbox); + final names = branches.map((b) => b.name).toList(); + expect(names, containsAll(['feature/a'])); + expect(branches.any((b) => b.current), isTrue); + }); + + test('gitBranches returns empty on a non-git directory', () async { + final notGit = await Directory.systemTemp.createTemp('clide-git-not-'); + addTearDown(() => notGit.deleteSync(recursive: true)); + expect(await gitBranches(notGit), isEmpty); + }); + + test('gitCheckout switches branches; an unknown branch throws', () async { + await Process.run('git', ['branch', 'next'], workingDirectory: sandbox.path); + await gitCheckout(sandbox, 'next'); + expect(await gitCurrentBranch(sandbox), 'next'); + try { + await gitCheckout(sandbox, 'does-not-exist'); + fail('expected GitException'); + } on GitException catch (_) {} + }); + + test('gitPull + gitPush round-trip against a local bare remote', () async { + final remote = await Directory.systemTemp.createTemp('clide-git-remote-'); + addTearDown(() => remote.deleteSync(recursive: true)); + await Process.run('git', ['init', '--bare'], workingDirectory: remote.path); + await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path); + final pushOut = await gitPush(sandbox, remote: 'origin', branch: 'main', setUpstream: true); + expect(pushOut, isNotEmpty); + // Clone elsewhere and pull on the original. Cheaper: just call gitPull + // and confirm it doesn't throw (already up-to-date). + final pullOut = await gitPull(sandbox); + expect(pullOut, isA()); + }); + + test('gitPush against no remote throws GitException', () async { + try { + await gitPush(sandbox); + fail('expected GitException'); + } on GitException catch (_) {} + }); + + test('gitPull against no remote throws GitException', () async { + try { + await gitPull(sandbox); + fail('expected GitException'); + } on GitException catch (_) {} + }); + + test('gitLog returns empty on a non-git directory', () async { + final notGit = await Directory.systemTemp.createTemp('clide-git-log-'); + addTearDown(() => notGit.deleteSync(recursive: true)); + expect(await gitLog(notGit), isEmpty); + }); + + test('gitCurrentBranch returns null on a non-git directory', () async { + final notGit = await Directory.systemTemp.createTemp('clide-git-cb-'); + addTearDown(() => notGit.deleteSync(recursive: true)); + expect(await gitCurrentBranch(notGit), isNull); + }); + + test('gitStashPop on an empty stash throws GitException', () async { + try { + await gitStashPop(sandbox); + fail('expected GitException'); + } on GitException catch (_) {} + }); + + test('gitDiscard with an empty list returns without invoking git', () async { + // Empty list short-circuits before the subprocess call; just verify + // it doesn't throw. + await gitDiscard(sandbox, const []); + }); + + test('gitBin resolves to a usable binary path', () { + expect(gitBin, isNotEmpty); + }); } diff --git a/test/git/status_test.dart b/test/git/status_test.dart index 10d17908..5ddeacaa 100644 --- a/test/git/status_test.dart +++ b/test/git/status_test.dart @@ -92,4 +92,45 @@ void main() { expect(status.ahead, isZero); expect(status.behind, isZero); }); + + test('branch.upstream + branch.ab populate upstream/ahead/behind', () async { + final remote = await Directory.systemTemp.createTemp('clide-status-remote-'); + addTearDown(() => remote.deleteSync(recursive: true)); + await Process.run('git', ['init', '--bare'], workingDirectory: remote.path); + await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path); + await Process.run('git', ['push', '-u', 'origin', 'HEAD'], workingDirectory: sandbox.path); + // Add a commit so we have ahead > 0. + await File('${sandbox.path}/ahead.txt').writeAsString('x'); + await Process.run('git', ['add', '.'], workingDirectory: sandbox.path); + await Process.run('git', ['commit', '-m', 'ahead'], workingDirectory: sandbox.path); + final s = await gitStatus(sandbox); + expect(s.upstream, contains('origin/')); + expect(s.ahead, 1); + expect(s.behind, 0); + }); + + test('gitStatus on a non-git directory returns an empty branchless status', () async { + final notGit = await Directory.systemTemp.createTemp('clide-status-not-'); + addTearDown(() => notGit.deleteSync(recursive: true)); + final s = await gitStatus(notGit); + expect(s.entries, isEmpty); + }); + + test('rename in porcelain output captures the original path', () async { + await File('${sandbox.path}/a.txt').writeAsString('content\n'); + await Process.run('git', ['add', '.'], workingDirectory: sandbox.path); + await Process.run('git', ['commit', '-m', 'add a'], workingDirectory: sandbox.path); + await Process.run('git', ['mv', 'a.txt', 'renamed.txt'], workingDirectory: sandbox.path); + final s = await gitStatus(sandbox); + final renamed = s.entries.firstWhere((e) => e.path == 'renamed.txt'); + expect(renamed.origPath, 'a.txt'); + }); + + test('parsePorcelainV1 handles empty input + short / empty parts', () { + expect(parsePorcelainV1(''), isEmpty); + // 'X' is too short (< 4 chars), should be skipped. + expect(parsePorcelainV1('X\x00'), isEmpty); + // Empty token-only input — skipped. + expect(parsePorcelainV1('\x00'), isEmpty); + }); }