add Toolchain, GitClient, native directory picker

Toolchain centralizes binary resolution — replaces five ad-hoc
mechanisms (expandedPath, _resolveGit, _resolve, _resolvePtyc,
_existsOnPath). Resolves via Future.delayed after runApp to avoid
blocking the merged UI/platform thread on macOS.

GitClient wraps all git operations with a typed API. Every subprocess
call goes through _run() using toolchain.git + toolchain.gitEnv.
Replaces free functions in operations.dart.

Native directory picker: NSOpenPanel on macOS (method channel in
AppDelegate), GtkFileChooserDialog on Linux. Falls back to text-input
dialog on web or MissingPluginException. Shows "No git repo found"
dialog when the selected directory is not a git repository.

PqlClient and pane commands updated to use Toolchain. ToolCheck
replaced by Toolchain.missing/allOk. All IPC handlers now catch
GitException to prevent unhandled exceptions on the merged thread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-04-25 13:18:39 +02:00
co-authored by Claude Opus 4.6
parent 8edcc78bfe
commit 73e80a55a6
22 changed files with 734 additions and 147 deletions
+260
View File
@@ -0,0 +1,260 @@
/// Typed git client backed by [Toolchain].
///
/// Every subprocess call goes through [_run] which uses the resolved
/// absolute binary path from the toolchain. Parsing is delegated to
/// the existing pure-function parsers in status.dart and diff.dart.
library;
import 'dart:io';
import '../../kernel/src/toolchain.dart';
import 'diff.dart' show GitDiff, parseDiffOutput;
import 'operations.dart' show GitException, GitLogEntry;
import 'status.dart';
class GitClient {
GitClient({required this.toolchain, required this.workDir});
final Toolchain toolchain;
final Directory workDir;
// -- queries --------------------------------------------------------------
Future<GitStatus> status() async {
ProcessResult branchResult;
try {
branchResult = await _run(['status', '--porcelain=v2', '--branch', '-z']);
} on GitException {
return const GitStatus(branch: null, entries: []);
}
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;
}
}
}
}
ProcessResult result;
try {
result = await _run(['status', '--porcelain=v1', '-z']);
} on GitException {
return GitStatus(branch: branch, entries: const [], upstream: upstream, ahead: ahead, behind: behind);
}
if (result.exitCode != 0) {
return GitStatus(branch: branch, upstream: upstream, ahead: ahead, behind: behind, entries: const []);
}
return GitStatus(
branch: branch,
upstream: upstream,
ahead: ahead,
behind: behind,
entries: parsePorcelainV1(result.stdout as String),
);
}
Future<List<GitDiff>> diff({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 r = await _run(args);
if (r.exitCode != 0) return const [];
return parseDiffOutput(r.stdout as String);
}
Future<List<GitLogEntry>> log({int count = 20}) async {
final r = await _run([
'log',
'--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01',
'-n',
'$count',
]);
if (r.exitCode != 0) return const [];
return parseLog(r.stdout as String);
}
Future<String?> currentBranch() async {
final r = await _run(['symbolic-ref', '--short', 'HEAD']);
if (r.exitCode != 0) return null;
return (r.stdout as String).trim();
}
Future<List<({String name, bool current})>> branches() async {
final r = await _run(['branch', '--format=%(refname:short)|%(HEAD)']);
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;
out.add((name: line.substring(0, sep), current: line.substring(sep + 1).trim() == '*'));
}
return out;
}
/// Resolve a path to its git repo root. Returns null if not a git repo.
Future<String?> repoRoot(String path) async {
try {
final r = await Process.run(
toolchain.git,
['rev-parse', '--show-toplevel'],
workingDirectory: path,
);
if (r.exitCode != 0) return null;
final out = (r.stdout as String).trim();
return out.isEmpty ? null : out;
} catch (_) {
return null;
}
}
// -- mutations ------------------------------------------------------------
Future<void> stage(List<String> paths) async {
final args = ['add'];
if (paths.isEmpty) {
args.add('-A');
} else {
args.add('--');
args.addAll(paths);
}
final r = await _run(args);
if (r.exitCode != 0) throw GitException('git add failed', stderr: r.stderr as String);
}
Future<void> unstage(List<String> paths) async {
final args = ['reset', 'HEAD'];
if (paths.isNotEmpty) {
args.add('--');
args.addAll(paths);
}
final r = await _run(args);
if (r.exitCode != 0) throw GitException('git reset failed', stderr: r.stderr as String);
}
Future<void> stageHunk(String patch) => _applyPatch(patch, cached: true);
Future<void> unstageHunk(String patch) => _applyPatch(patch, cached: true, reverse: true);
Future<void> discard(List<String> paths) async {
if (paths.isEmpty) return;
final r = await _run(['checkout', '--', ...paths]);
if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String);
}
Future<String> commit(String message, {bool amend = false}) async {
final args = ['commit', '-m', message];
if (amend) args.add('--amend');
final r = await _run(args);
if (r.exitCode != 0) throw GitException('git commit failed', stderr: r.stderr as String);
final hash = await _run(['rev-parse', 'HEAD']);
return (hash.stdout as String).trim();
}
Future<void> stash({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 _run(args);
if (r.exitCode != 0) throw GitException('git stash failed', stderr: r.stderr as String);
}
Future<void> stashPop() async {
final r = await _run(['stash', 'pop']);
if (r.exitCode != 0) throw GitException('git stash pop failed', stderr: r.stderr as String);
}
Future<String> pull() async {
final r = await _run(['pull']);
if (r.exitCode != 0) throw GitException('git pull failed', stderr: r.stderr as String);
return (r.stdout as String).trim();
}
Future<String> push({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 _run(args);
if (r.exitCode != 0) throw GitException('git push failed', stderr: r.stderr as String);
return ((r.stdout as String) + (r.stderr as String)).trim();
}
Future<void> checkout(String branch) async {
final r = await _run(['checkout', branch]);
if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String);
}
// -- internal -------------------------------------------------------------
Future<ProcessResult> _run(List<String> args) async {
try {
return await Process.run(toolchain.git, args,
workingDirectory: workDir.path,
environment: toolchain.gitEnv);
} on ProcessException catch (e) {
throw GitException('git ${args.first}: ${e.message}', stderr: e.toString());
}
}
Future<void> _applyPatch(String patch, {bool cached = false, bool reverse = false}) async {
final args = ['apply'];
if (cached) args.add('--cached');
if (reverse) args.add('--reverse');
args.addAll(['--unidiff-zero', '-']);
final proc = await Process.start(toolchain.git, args,
workingDirectory: workDir.path,
environment: toolchain.gitEnv);
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);
}
}
}
// -- parsers (pure, no I/O) -------------------------------------------------
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;
}
+3 -1
View File
@@ -7,6 +7,8 @@ library;
import 'dart:io';
import 'operations.dart' show gitBin;
enum DiffLineKind { context, addition, removal, header }
class DiffLine {
@@ -129,7 +131,7 @@ Future<List<GitDiff>> gitDiff(
args.addAll(paths);
}
final result = await Process.run(
'git',
gitBin,
args,
workingDirectory: workDir.path,
);
+33 -13
View File
@@ -7,6 +7,26 @@ library;
import 'dart:io';
import '../pty/env.dart';
/// Resolve git to an absolute path. On macOS the sandbox blocks bare
/// `git` calls; Homebrew's git is a symlink into Cellar so we need
/// the real resolved path.
String get gitBin {
_gitBin ??= _resolveGit();
return _gitBin!;
}
String? _gitBin;
String _resolveGit() {
for (final dir in expandedPath.split(':')) {
if (dir.isEmpty) continue;
final f = File('$dir/git');
if (f.existsSync()) return f.resolveSymbolicLinksSync();
}
return 'git';
}
class GitException implements Exception {
const GitException(this.message, {this.stderr = ''});
final String message;
@@ -52,7 +72,7 @@ Future<void> gitStage(Directory workDir, List<String> paths) async {
args.add('--');
args.addAll(paths);
}
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git add failed', stderr: r.stderr as String);
}
@@ -65,7 +85,7 @@ Future<void> gitUnstage(Directory workDir, List<String> paths) async {
args.add('--');
args.addAll(paths);
}
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git reset failed', stderr: r.stderr as String);
}
@@ -85,7 +105,7 @@ Future<void> gitUnstageHunk(Directory workDir, String patch) async {
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
if (paths.isEmpty) return;
final r = await Process.run(
'git',
gitBin,
['checkout', '--', ...paths],
workingDirectory: workDir.path,
);
@@ -102,13 +122,13 @@ Future<String> gitCommit(
}) async {
final args = ['commit', '-m', message];
if (amend) args.add('--amend');
final r = await Process.run('git', args, workingDirectory: workDir.path);
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(
'git',
gitBin,
['rev-parse', 'HEAD'],
workingDirectory: workDir.path,
);
@@ -126,7 +146,7 @@ Future<void> gitStash(
args.addAll(['-m', message]);
}
if (includeUntracked) args.add('--include-untracked');
final r = await Process.run('git', args, workingDirectory: workDir.path);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git stash failed', stderr: r.stderr as String);
}
@@ -135,7 +155,7 @@ Future<void> gitStash(
/// Pop the top stash entry.
Future<void> gitStashPop(Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['stash', 'pop'],
workingDirectory: workDir.path,
);
@@ -150,7 +170,7 @@ Future<List<GitLogEntry>> gitLog(
int count = 20,
}) async {
final r = await Process.run(
'git',
gitBin,
[
'log',
'--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01',
@@ -166,7 +186,7 @@ Future<List<GitLogEntry>> gitLog(
/// Pull from remote.
Future<String> gitPull(Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['pull'],
workingDirectory: workDir.path,
);
@@ -187,7 +207,7 @@ Future<String> gitPush(
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);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
if (r.exitCode != 0) {
throw GitException('git push failed', stderr: r.stderr as String);
}
@@ -198,7 +218,7 @@ Future<String> gitPush(
Future<List<({String name, bool current})>> gitBranches(
Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['branch', '--format=%(refname:short)|%(HEAD)'],
workingDirectory: workDir.path,
);
@@ -218,7 +238,7 @@ Future<List<({String name, bool current})>> gitBranches(
/// Checkout a branch.
Future<void> gitCheckout(Directory workDir, String branch) async {
final r = await Process.run(
'git',
gitBin,
['checkout', branch],
workingDirectory: workDir.path,
);
@@ -230,7 +250,7 @@ Future<void> gitCheckout(Directory workDir, String branch) async {
/// Get the current branch name.
Future<String?> gitCurrentBranch(Directory workDir) async {
final r = await Process.run(
'git',
gitBin,
['symbolic-ref', '--short', 'HEAD'],
workingDirectory: workDir.path,
);
+24 -12
View File
@@ -7,6 +7,8 @@ library;
import 'dart:io';
import 'operations.dart' show gitBin;
enum GitFileState {
added,
modified,
@@ -110,11 +112,16 @@ class GitStatus {
/// 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,
);
final ProcessResult branchResult;
try {
branchResult = await Process.run(
gitBin,
['status', '--porcelain=v2', '--branch', '-z'],
workingDirectory: workDir.path,
);
} on ProcessException {
return const GitStatus(branch: null, entries: []);
}
String? branch;
String? upstream;
@@ -138,11 +145,16 @@ Future<GitStatus> gitStatus(Directory workDir) async {
}
}
final result = await Process.run(
'git',
['status', '--porcelain=v1', '-z'],
workingDirectory: workDir.path,
);
final ProcessResult result;
try {
result = await Process.run(
gitBin,
['status', '--porcelain=v1', '-z'],
workingDirectory: workDir.path,
);
} on ProcessException {
return GitStatus(branch: branch, entries: const [], upstream: upstream, ahead: ahead, behind: behind);
}
if (result.exitCode != 0) {
return GitStatus(
@@ -154,7 +166,7 @@ Future<GitStatus> gitStatus(Directory workDir) async {
);
}
final entries = _parsePorcelainV1(result.stdout as String);
final entries = parsePorcelainV1(result.stdout as String);
return GitStatus(
branch: branch,
upstream: upstream,
@@ -164,7 +176,7 @@ Future<GitStatus> gitStatus(Directory workDir) async {
);
}
List<GitFileStatus> _parsePorcelainV1(String output) {
List<GitFileStatus> parsePorcelainV1(String output) {
if (output.isEmpty) return const [];
final entries = <GitFileStatus>[];
final parts = output.split('\x00');