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:
co-authored by
Claude Opus 4.6
parent
8edcc78bfe
commit
73e80a55a6
@@ -5,11 +5,8 @@
|
||||
/// can refresh.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../git/diff.dart';
|
||||
import '../git/operations.dart';
|
||||
import '../git/status.dart';
|
||||
import '../git/client.dart';
|
||||
import '../git/operations.dart' show GitException;
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
@@ -17,22 +14,30 @@ import 'dispatcher.dart';
|
||||
|
||||
void registerGitCommands(
|
||||
DaemonDispatcher d,
|
||||
Directory workDir,
|
||||
GitClient git,
|
||||
DaemonEventSink events,
|
||||
) {
|
||||
d.register('git.status', (req) async {
|
||||
final status = await gitStatus(workDir);
|
||||
return IpcResponse.ok(id: req.id, data: status.toJson());
|
||||
try {
|
||||
final status = await git.status();
|
||||
return IpcResponse.ok(id: req.id, data: status.toJson());
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
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()],
|
||||
});
|
||||
try {
|
||||
final staged = req.args['staged'] as bool? ?? false;
|
||||
final paths = _pathList(req.args['paths']);
|
||||
final diffs = await git.diff(staged: staged, paths: paths);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'staged': staged,
|
||||
'diffs': [for (final d in diffs) d.toJson()],
|
||||
});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('git.stage', (req) async {
|
||||
@@ -49,7 +54,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitStage(workDir, paths);
|
||||
await git.stage(paths);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'staged': paths});
|
||||
} on GitException catch (e) {
|
||||
@@ -59,7 +64,7 @@ void registerGitCommands(
|
||||
|
||||
d.register('git.stage-all', (req) async {
|
||||
try {
|
||||
await gitStage(workDir, const []);
|
||||
await git.stage(const []);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'staged': 'all'});
|
||||
} on GitException catch (e) {
|
||||
@@ -70,7 +75,7 @@ void registerGitCommands(
|
||||
d.register('git.unstage', (req) async {
|
||||
final paths = _pathList(req.args['paths']);
|
||||
try {
|
||||
await gitUnstage(workDir, paths);
|
||||
await git.unstage(paths);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'unstaged': paths});
|
||||
} on GitException catch (e) {
|
||||
@@ -91,7 +96,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitStageHunk(workDir, patch);
|
||||
await git.stageHunk(patch);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -112,7 +117,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitUnstageHunk(workDir, patch);
|
||||
await git.unstageHunk(patch);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -133,7 +138,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitDiscard(workDir, paths);
|
||||
await git.discard(paths);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'discarded': paths});
|
||||
} on GitException catch (e) {
|
||||
@@ -154,7 +159,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
final hash = await gitCommit(workDir, message);
|
||||
final hash = await git.commit(message);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'hash': hash});
|
||||
} on GitException catch (e) {
|
||||
@@ -166,7 +171,7 @@ void registerGitCommands(
|
||||
final message = req.args['message'] as String?;
|
||||
final includeUntracked = req.args['includeUntracked'] as bool? ?? false;
|
||||
try {
|
||||
await gitStash(workDir, message: message, includeUntracked: includeUntracked);
|
||||
await git.stash(message: message, includeUntracked: includeUntracked);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'stashed': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -176,7 +181,7 @@ void registerGitCommands(
|
||||
|
||||
d.register('git.stash-pop', (req) async {
|
||||
try {
|
||||
await gitStashPop(workDir);
|
||||
await git.stashPop();
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'popped': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -185,16 +190,20 @@ void registerGitCommands(
|
||||
});
|
||||
|
||||
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()],
|
||||
});
|
||||
try {
|
||||
final count = (req.args['count'] as num?)?.toInt() ?? 20;
|
||||
final entries = await git.log(count: count);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'entries': [for (final e in entries) e.toJson()],
|
||||
});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('git.pull', (req) async {
|
||||
try {
|
||||
final output = await gitPull(workDir);
|
||||
final output = await git.pull();
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'output': output});
|
||||
} on GitException catch (e) {
|
||||
@@ -207,12 +216,7 @@ void registerGitCommands(
|
||||
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,
|
||||
);
|
||||
final output = await git.push(remote: remote, branch: branch, setUpstream: setUpstream);
|
||||
return IpcResponse.ok(id: req.id, data: {'output': output});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
@@ -220,13 +224,14 @@ void registerGitCommands(
|
||||
});
|
||||
|
||||
d.register('git.branches', (req) async {
|
||||
final branches = await gitBranches(workDir);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'branches': [
|
||||
for (final b in branches)
|
||||
{'name': b.name, 'current': b.current},
|
||||
],
|
||||
});
|
||||
try {
|
||||
final b = await git.branches();
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'branches': [for (final e in b) {'name': e.name, 'current': e.current}],
|
||||
});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('git.checkout', (req) async {
|
||||
@@ -242,7 +247,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitCheckout(workDir, branch);
|
||||
await git.checkout(branch);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'branch': branch});
|
||||
} on GitException catch (e) {
|
||||
|
||||
@@ -15,10 +15,11 @@ import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../panes/pane.dart';
|
||||
import '../panes/registry.dart';
|
||||
import '../../kernel/src/toolchain.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {String defaultPtycPath = 'ptyc'}) {
|
||||
d.register('pane.spawn', (req) => _spawn(req, registry, defaultPtycPath));
|
||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required Toolchain toolchain}) {
|
||||
d.register('pane.spawn', (req) => _spawn(req, registry, toolchain));
|
||||
d.register('pane.list', (req) => _list(req, registry));
|
||||
d.register('pane.close', (req) => _close(req, registry));
|
||||
d.register('pane.write', (req) => _write(req, registry));
|
||||
@@ -47,7 +48,14 @@ IpcResponse _notFound(String id, String message) => IpcResponse.err(
|
||||
),
|
||||
);
|
||||
|
||||
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String defaultPtycPath) async {
|
||||
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain toolchain) async {
|
||||
// Wait for toolchain resolution if it hasn't completed yet.
|
||||
if (!toolchain.resolved) {
|
||||
await Future.any([
|
||||
toolchain.waitForResolution(),
|
||||
Future.delayed(const Duration(seconds: 5)),
|
||||
]);
|
||||
}
|
||||
final args = req.args;
|
||||
final rawArgv = args['argv'];
|
||||
if (rawArgv is! List || rawArgv.isEmpty) {
|
||||
@@ -84,7 +92,7 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String default
|
||||
cols: (args['cols'] as num?)?.toInt() ?? 80,
|
||||
rows: (args['rows'] as num?)?.toInt() ?? 24,
|
||||
title: args['title'] as String?,
|
||||
ptycPath: (args['ptyc_path'] as String?) ?? defaultPtycPath,
|
||||
ptycPath: (args['ptyc_path'] as String?) ?? toolchain.ptyc,
|
||||
);
|
||||
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||
} catch (e) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
+18
-7
@@ -8,6 +8,8 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../kernel/src/toolchain.dart';
|
||||
|
||||
class PqlException implements Exception {
|
||||
const PqlException(this.message, {this.exitCode = 1, this.stderr = ''});
|
||||
final String message;
|
||||
@@ -19,10 +21,10 @@ class PqlException implements Exception {
|
||||
}
|
||||
|
||||
class PqlClient {
|
||||
PqlClient({required this.workDir, this.pqlBinary = 'pql'});
|
||||
PqlClient({required this.workDir, required this.toolchain});
|
||||
|
||||
final Directory workDir;
|
||||
final String pqlBinary;
|
||||
final Toolchain toolchain;
|
||||
|
||||
Future<List<Map<String, Object?>>> files({String? glob, int? limit}) async {
|
||||
final args = ['files'];
|
||||
@@ -165,11 +167,20 @@ class PqlClient {
|
||||
}
|
||||
|
||||
Future<Object?> _run(List<String> args) async {
|
||||
final r = await Process.run(
|
||||
pqlBinary,
|
||||
args,
|
||||
workingDirectory: workDir.path,
|
||||
);
|
||||
final ProcessResult r;
|
||||
try {
|
||||
r = await Process.run(
|
||||
toolchain.pql,
|
||||
args,
|
||||
workingDirectory: workDir.path,
|
||||
);
|
||||
} on ProcessException catch (e) {
|
||||
throw PqlException(
|
||||
'pql ${args.first}: ${e.message}',
|
||||
exitCode: e.errorCode,
|
||||
stderr: e.toString(),
|
||||
);
|
||||
}
|
||||
final stderr = (r.stderr as String).trim();
|
||||
// Exit 2 = zero matches — valid empty result, not an error.
|
||||
if (r.exitCode != 0 && r.exitCode != 2) {
|
||||
|
||||
Reference in New Issue
Block a user