remove the legacy free-function git API (T-385)

operations.dart carried a full second git operation surface
(gitStage/gitCommit/gitStash/gitPush/...) that duplicated GitClient
verb-for-verb, was kept alive only by its own tests, and hid a latent
pipe deadlock in _applyPatch (stdin written without draining stderr).
The file keeps the genuinely shared plumbing — gitBin resolution,
GitException, validateGitRef, GitLogEntry — which GitClient, the
status/diff readers, and the git command handlers consume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 02:16:06 +02:00
co-authored by Claude Fable 5
parent 99dc52052a
commit a59c3658a9
5 changed files with 47 additions and 426 deletions
+6 -201
View File
@@ -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<void> gitStage(Directory workDir, List<String> 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<void> gitUnstage(Directory workDir, List<String> 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<void> gitStageHunk(Directory workDir, String patch) async {
await _applyPatch(workDir, patch, cached: true);
}
/// Unstage a single hunk via `git apply --cached --reverse`.
Future<void> gitUnstageHunk(Directory workDir, String patch) async {
await _applyPatch(workDir, patch, cached: true, reverse: true);
}
/// Discard unstaged changes for [paths]. Uses `git checkout -- <paths>`.
Future<void> gitDiscard(Directory workDir, List<String> 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<String> 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<void> 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<void> 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<List<GitLogEntry>> 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<String> 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<String> 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<List<({String name, bool current})>> 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: `-- <name>` means "restore
/// pathspec `<name>`", not "checkout branch `<name>`". 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<void> 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<String?> 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<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;
}
Future<void> _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);
}
}