diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 4735468f..b73350c2 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4054,3 +4054,9 @@ Acceptance: all three make targets run; the workflow file''s steps each map to a 2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', NULL, '2026-06-11 23:38:58', '2026-06-11 23:38:58', '2026-06-11 23:38:58', NULL, '5d3c1b5fcdfd2f75a20ddf8cca9cb060', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'in_progress', 'review', NULL, '2026-06-11 23:39:03', '2026-06-11 23:39:03', '2026-06-11 23:39:03', NULL, '2be4d4e7daec8409e44c8c51e10cb458', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:01:13', '2026-06-12 00:01:13', '2026-06-12 00:01:13', NULL, '1924fedc940094f43fc5433cd1af7df5', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'description', NULL, 'Split out of the T-385 rat sweep: nothing in production constructs TeamMemberJoined/TeamMemberLeft (verified — only the type definition and tests), yet the team roster surfaces (lib/builtin/claude/src/team_panel_host.dart and the meta sidebar Team tab) populate their member lists EXCLUSIVELY from kernel.events.on() — ghost-fed UI. The real membership source exists: TeamBroker (orchestrator.broker, T-170/T-171) tracks members via addMember/removeMember on team spawn/close. + +Fix: drive both roster surfaces from TeamBroker membership (expose a listenable roster or change stream on the broker), delete TeamMemberJoined/TeamMemberLeft from kernel events/types.dart, and remove the dead listeners. Coordinate with T-395 (meta sidebar split) — whichever lands second adapts. + +Acceptance: spawning a team session through the orchestrator makes the member appear in both surfaces (widget test); the ghost event types are gone from types.dart; no kernel.events team-member subscriptions remain.', NULL, '2026-06-12 00:12:04', '2026-06-12 00:12:04', '2026-06-12 00:12:04', NULL, 'f04b7af4488069d004d99acbf9717cc6', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index ea101154..7d4dc8bf 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4232,3 +4232,23 @@ Fix: repoint the scripts at the repo root (post app/-flattening layout), run eac Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality. 2026-06-12: mechanical fixes done — tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', 'review', 'medium', NULL, NULL, NULL, '2026-06-11 22:00:29', '2026-06-11 23:39:03', NULL, '15d8c06d3292a0f55f8e44ca369b6699', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too): + +- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests. +- ToolCheck — zero callers. +- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56. +- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub). +- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan. +- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI). +- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present. +- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25. + +Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up. + +Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:01:13', NULL, '5232eeb9532e61a3d04e8908d0586a49', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rewire team roster UI to TeamBroker membership; delete ghost TeamMemberJoined/Left events', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 00:11:50', '2026-06-12 00:11:50', NULL, '68babe90a7ccbbc7b554e05243adb9ce', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rewire team roster UI to TeamBroker membership; delete ghost TeamMemberJoined/Left events', 'Split out of the T-385 rat sweep: nothing in production constructs TeamMemberJoined/TeamMemberLeft (verified — only the type definition and tests), yet the team roster surfaces (lib/builtin/claude/src/team_panel_host.dart and the meta sidebar Team tab) populate their member lists EXCLUSIVELY from kernel.events.on() — ghost-fed UI. The real membership source exists: TeamBroker (orchestrator.broker, T-170/T-171) tracks members via addMember/removeMember on team spawn/close. + +Fix: drive both roster surfaces from TeamBroker membership (expose a listenable roster or change stream on the broker), delete TeamMemberJoined/TeamMemberLeft from kernel events/types.dart, and remove the dead listeners. Coordinate with T-395 (meta sidebar split) — whichever lands second adapts. + +Acceptance: spawning a team session through the orchestrator makes the member appear in both surfaces (widget test); the ghost event types are gone from types.dart; no kernel.events team-member subscriptions remain.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 00:11:50', '2026-06-12 00:12:04', NULL, '57f7e568fe7c411fc4b45c6b140d8167', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/src/git/operations.dart b/lib/src/git/operations.dart index fb03f43b..48463ae5 100644 --- a/lib/src/git/operations.dart +++ b/lib/src/git/operations.dart @@ -1,8 +1,9 @@ -/// 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). +/// Shared git plumbing: the resolved `git` binary path, the typed +/// failure ([GitException]), the ref-shaped-argument validator, and the +/// log entry model. The legacy free-function operation API that used to +/// live here duplicated [GitClient] verb-for-verb, had no non-test +/// callers, and carried a latent pipe deadlock in its hunk-apply path — +/// removed in the T-385 dead-code sweep; use [GitClient]. library; import 'dart:io'; @@ -71,199 +72,3 @@ class GitLogEntry { 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(gitBin, 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(gitBin, 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(gitBin, ['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(gitBin, 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(gitBin, ['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(gitBin, 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(gitBin, ['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(gitBin, ['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(gitBin, ['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 { - if (remote != null) validateGitRef(remote, kind: 'remote'); - if (branch != null) validateGitRef(branch, kind: 'branch'); - final args = ['push']; - if (setUpstream) args.add('-u'); - // `--` terminates option parsing — belt-and-suspenders alongside - // the ref validator above. Without it a future caller that bypasses - // the validator could still inject `--upload-pack=...`. - args.add('--'); - if (remote != null) args.add(remote); - if (branch != null) args.add(branch); - final r = await Process.run(gitBin, 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(); -} - -/// List local branches. Returns (name, isCurrent) pairs. -Future> gitBranches(Directory workDir) async { - final r = await Process.run(gitBin, ['branch', '--format=%(refname:short)|%(HEAD)'], workingDirectory: workDir.path); - if (r.exitCode != 0) return const []; - final out = <({String name, bool current})>[]; - for (final line in (r.stdout as String).split('\n')) { - if (line.trim().isEmpty) continue; - final sep = line.lastIndexOf('|'); - if (sep < 0) continue; - final name = line.substring(0, sep); - final head = line.substring(sep + 1).trim(); - out.add((name: name, current: head == '*')); - } - return out; -} - -/// Checkout a branch. -/// -/// `git checkout` overloads positionals: `-- ` means "restore -/// pathspec ``", not "checkout branch ``". So this can't -/// use `--` as an option terminator without changing semantics — the -/// [validateGitRef] guard against `-`-prefixed values is the only -/// argv-injection defence here. Use `gitSwitch` if/when we adopt it. -Future gitCheckout(Directory workDir, String branch) async { - validateGitRef(branch, kind: 'branch'); - final r = await Process.run(gitBin, ['checkout', branch], workingDirectory: workDir.path); - if (r.exitCode != 0) { - throw GitException('git checkout failed', stderr: r.stderr as String); - } -} - -/// Get the current branch name. -Future gitCurrentBranch(Directory workDir) async { - final r = await Process.run(gitBin, ['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/native/linux-x64/ptyc b/native/linux-x64/ptyc deleted file mode 100755 index 06b8a8f2..00000000 Binary files a/native/linux-x64/ptyc and /dev/null differ diff --git a/test/git/operations_test.dart b/test/git/operations_test.dart index 450b238d..c4367dca 100644 --- a/test/git/operations_test.dart +++ b/test/git/operations_test.dart @@ -1,251 +1,41 @@ +/// Tests for the shared git plumbing in operations.dart. The legacy +/// free-function operation API (gitStage/gitCommit/...) was removed in +/// the T-385 dead-code sweep — it duplicated GitClient verb-for-verb +/// with zero non-test callers; GitClient's own tests cover the verbs. +library; + 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); - }); - test('GitException.toString includes the message', () { - const e = GitException('boom'); - expect(e.toString(), contains('boom')); + expect(const GitException('boom').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); + expect(a.toJson(), {'hash': 'h', 'shortHash': 's', 'subject': 'sub', 'author': 'a', 'date': 'd'}); 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('gitPush rejects a -prefixed remote (argv-injection guard)', () async { - try { - await gitPush(sandbox, remote: '--upload-pack=evil', branch: 'main'); - fail('expected GitException'); - } on GitException catch (e) { - expect(e.message, contains('remote')); - } - }); - - test('gitPush rejects a -prefixed branch', () async { - try { - await gitPush(sandbox, remote: 'origin', branch: '--exec=evil'); - fail('expected GitException'); - } on GitException catch (e) { - expect(e.message, contains('branch')); - } - }); - - test('gitCheckout rejects a -prefixed branch', () async { - try { - await gitCheckout(sandbox, '--upload-pack=evil'); - fail('expected GitException'); - } on GitException catch (e) { - expect(e.message, contains('branch')); - } - }); - - test('gitCheckout rejects an empty branch', () async { - try { - await gitCheckout(sandbox, ''); - fail('expected GitException'); - } on GitException catch (e) { - expect(e.message, contains('branch')); - } - }); - test('validateGitRef accepts plain refs', () { expect(() => validateGitRef('main', kind: 'branch'), returnsNormally); expect(() => validateGitRef('feature/foo', kind: 'branch'), returnsNormally); expect(() => validateGitRef('origin', kind: 'remote'), returnsNormally); }); - test('gitPull against no remote throws GitException', () async { - try { - await gitPull(sandbox); - fail('expected GitException'); - } on GitException catch (_) {} + test('validateGitRef rejects empty and -prefixed values (argv-injection guard)', () { + expect(() => validateGitRef(null, kind: 'branch'), throwsA(isA())); + expect(() => validateGitRef('', kind: 'branch'), throwsA(isA())); + expect(() => validateGitRef('--upload-pack=evil', kind: 'remote'), throwsA(isA())); }); - 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', () { + test('gitBin resolves to a runnable git', () async { expect(gitBin, isNotEmpty); + final r = await Process.run(gitBin, ['--version']); + expect(r.exitCode, 0); }); }