git_operations
Git operation workflows for Dart: checkout, stash, and pull with user-guided handling of dirty working trees and untracked files. Built on path_utils for command execution and path resolution.
Overview
git_operations orchestrates common git workflows across one or many repositories. It runs git through a pluggable command runner, logs progress through your existing callbacks, and delegates conflict decisions to your UI (dialogs, CLI prompts, or test fakes).
The package does not render UI itself. You provide resolver callbacks that return what the user chose to do.
Features
GitRepository
Minimal contract your repository model implements:
| Member | Purpose |
|---|---|
name |
Label used in logs and user messages |
currentBranchOnDisk |
Branch name for conflict prompts (may be null) |
getExpandedPath(log) |
Resolve tildes, env vars, and symlinks to the directory git should run in |
class MyRepo implements GitRepository {
@override
String get name => 'my-app';
@override
String? get currentBranchOnDisk => 'main';
@override
String getExpandedPath(void Function(String message) log) => expandPath('~/projects/my-app');
}
GitOperationService
Main entry point. Construct it with GitOperationDependencies.
| Method | What it does |
|---|---|
runCheckoutHandlingUntrackedFiles |
git checkout <branch> with dirty-tree and untracked-file handling |
stashChanges |
git stash push -u (optional file list for partial untracked stashes) |
popStash |
git stash pop with user-visible success/error messages |
pullLatestChangesForRepos |
git pull across a list of repositories |
After a successful checkout or pull, the service calls BaseRepositoryStateService.refreshRepoState so your app can refresh branch/status display.
Conflict resolution
Before checkout or pull, the service checks for uncommitted changes (git status --porcelain -uno). If the working tree is dirty or checkout fails due to untracked files, it calls your resolver and acts on the response.
Untracked files (ResolveUntrackedFilesFunction) — triggered when checkout would overwrite untracked paths:
UserUntrackedFileAction |
Behavior |
|---|---|
stashAndRetry |
Stash listed files, retry checkout |
deleteAndRetry |
git clean -fd on listed files, retry checkout |
skipRepository |
Skip this repo; result has skippedViaUserInput: true |
cancelBatch |
Stop the outer batch; result has batchAborted: true |
null (dismissed) |
Leave checkout failed |
Dirty working tree (ResolveDirtyWorkdirFunction) — triggered when tracked files have local changes:
UserDirtyWorkdirAction |
Behavior |
|---|---|
stashAndRetry |
Stash all changes (including untracked via -u), continue the operation |
skipChange |
Skip this repo only |
cancel |
Abort the batch (batchAborted: true) |
null (dismissed) |
Treated as cancel |
Future<UserUntrackedFileAction?> showUntrackedDialog(
String repoName,
String branchName,
List<String> untrackedFiles,
) async {
// Show UI, return user choice
}
Future<UserDirtyWorkdirAction?> showDirtyDialog(
String repoName,
String branchName,
String porcelainStatus,
) async {
// Show UI, return user choice
}
Batch multi-repo pull
pullLatestChangesForRepos iterates repositories in order. For each repo it expands the path, handles a dirty working tree if needed, then runs git pull.
- Per-repo skip — user chooses
skipChangeorskipRepository; pull continues for remaining repos. - Batch cancel — user chooses
cancelorcancelBatch; loop stops and the result hasbatchAborted: true. - Auto-stash — when the user stashes during pull, affected repo names are collected in
stashedRepoNameson the final result.
final result = await gitOps.pullLatestChangesForRepos(selectedRepos);
if (result.batchAborted) {
// User cancelled — stop your wider orchestration
return;
}
if (!result.success) {
// At least one pull failed; see result.stderr
}
for (final name in result.stashedRepoNames) {
// Offer to pop stash for repos stashed during this pull
}
The same batchAborted flag is set on individual checkout results when the user cancels from an untracked-file or dirty-tree prompt during a multi-repo checkout loop.
GitCommandResult
Result type for git commands and workflow steps:
| Field | Meaning |
|---|---|
success |
Whether the git operation completed successfully |
skippedViaUserInput |
User chose to skip this repo |
batchAborted |
User cancelled the wider batch — stop processing remaining repos |
stashed |
A stash was created as part of resolving a conflict |
stashedRepoNames |
Repo names stashed during a multi-repo pull |
hasUntrackedFilesError |
Checkout failed because untracked files would be overwritten |
untrackedFiles |
Paths parsed from that checkout error |
GitOperationDependencies
Bundles everything GitOperationService needs beyond your repository models:
| Field | Purpose |
|---|---|
commandRunner |
Runs git and supplies log/message callbacks |
repositoryStateService |
Refresh app state after successful operations |
resolveUntrackedFiles |
Untracked-file conflict callback |
resolveDirtyWorkdir |
Dirty working-tree conflict callback |
stashMessage |
git stash push -m message (default: 'Auto-stash') |
Use GitOperationDependencies.fromExecutor when you already have a CommandExecutor from path_utils:
final executor = CommandExecutor(
addStepExecutionLogService: logStep,
addDebugLog: logDebug,
showMessage: showMessage,
executableFinder: finder,
);
final gitOps = GitOperationService(
GitOperationDependencies.fromExecutor(
executor: executor,
repositoryStateService: myRepoStateService,
resolveUntrackedFiles: showUntrackedDialog,
resolveDirtyWorkdir: showDirtyDialog,
stashMessage: 'Auto-stash by MyApp',
),
);
Logging and user-visible messages are read from the command runner — wire those callbacks once on CommandExecutor, not separately on the git service.
GitCommandRunner
Abstraction over git execution. `CommandExecutorGitRunner` wraps path_utils's CommandExecutor for subprocess execution and git-specific error handling. Inject a custom implementation in tests to avoid running real git commands.
BaseRepositoryStateService
Hook for keeping your UI or model in sync after git changes:
class MyRepoStateService implements BaseRepositoryStateService {
@override
Future<void> refreshRepoState(GitRepository repo, String expandedRepoPath) async {
// Re-read branch, status, etc.
}
@override
Future<String?> getWorkdirStatus(GitRepository repo, String expandedRepoPath) async {
// Optional: return porcelain status for display
}
}
User messages
The service emits user-visible notifications (via showMessage on the command runner) for outcomes such as stash failures, batch cancellation, pull completion, and stash pop results. Step-by-step narrative goes to addStepExecutionLog; diagnostic detail goes to addDebugLog.
Dependency on path_utils
git_operations depends on path_utils for:
CommandExecutorsubprocess execution and environment setup- Path expansion helpers used by your
GitRepositoryimplementation
Git command execution, git-specific result parsing, and ignorable git errors (e.g. tag clobber warnings) live in this package.
Example
The example/ app demonstrates:
- Implementing
GitRepository - Running git commands through
GitCommandRunner - Browsing upstream git/git man pages from GitHub (Docs tab; requires network)
Man page text is upstream content from git/git, licensed under GPL-2.0. It is not bundled in this package.
cd example
flutter run -d linux # or macos / windows
Testing
dart pub get
dart test
Implement GitRepository with fixed paths in tests. Provide fake ResolveUntrackedFilesFunction / ResolveDirtyWorkdirFunction implementations and a test double for GitCommandRunner to exercise orchestration without a real git repo.