/// 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). 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; final String stderr; @override String toString() => 'GitException: $message'; } class GitLogEntry { const GitLogEntry({ required this.hash, required this.shortHash, required this.subject, required this.author, required this.date, this.body = '', }); final String hash; final String shortHash; final String subject; final String author; final String date; final String body; Map toJson() => { 'hash': hash, 'shortHash': shortHash, 'subject': subject, 'author': author, 'date': date, 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 { final args = ['push']; if (setUpstream) args.add('-u'); 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. Future gitCheckout(Directory workDir, String branch) async { 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, ); } }