231 lines
6.4 KiB
Dart
231 lines
6.4 KiB
Dart
/// Git status data model and parser.
|
|
///
|
|
/// Shells out to `git status --porcelain=v1 -z` and parses the
|
|
/// NUL-delimited output into typed [GitFileStatus] entries grouped
|
|
/// by state (staged, unstaged, untracked, conflicted).
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'operations.dart' show gitBin;
|
|
|
|
enum GitFileState {
|
|
added,
|
|
modified,
|
|
deleted,
|
|
renamed,
|
|
copied,
|
|
untracked,
|
|
ignored,
|
|
}
|
|
|
|
enum GitConflictType {
|
|
bothModified,
|
|
bothAdded,
|
|
addedByUs,
|
|
addedByThem,
|
|
deletedByUs,
|
|
deletedByThem,
|
|
bothDeleted,
|
|
}
|
|
|
|
class GitFileStatus {
|
|
const GitFileStatus({
|
|
required this.path,
|
|
required this.indexState,
|
|
required this.workTreeState,
|
|
this.origPath,
|
|
this.conflictType,
|
|
});
|
|
|
|
final String path;
|
|
final GitFileState? indexState;
|
|
final GitFileState? workTreeState;
|
|
final String? origPath;
|
|
final GitConflictType? conflictType;
|
|
|
|
bool get isStaged => indexState != null && indexState != GitFileState.untracked && indexState != GitFileState.ignored && !isConflicted;
|
|
bool get isUnstaged => workTreeState != null && workTreeState != GitFileState.untracked && !isConflicted;
|
|
bool get isUntracked => workTreeState == GitFileState.untracked;
|
|
bool get isConflicted => conflictType != null;
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'path': path,
|
|
if (indexState != null) 'indexState': indexState!.name,
|
|
if (workTreeState != null) 'workTreeState': workTreeState!.name,
|
|
if (origPath != null) 'origPath': origPath,
|
|
if (conflictType != null) 'conflictType': conflictType!.name,
|
|
'staged': isStaged,
|
|
'unstaged': isUnstaged,
|
|
'untracked': isUntracked,
|
|
'conflicted': isConflicted,
|
|
};
|
|
}
|
|
|
|
class GitStatus {
|
|
const GitStatus({
|
|
required this.branch,
|
|
required this.entries,
|
|
this.upstream,
|
|
this.ahead = 0,
|
|
this.behind = 0,
|
|
});
|
|
|
|
final String? branch;
|
|
final String? upstream;
|
|
final int ahead;
|
|
final int behind;
|
|
final List<GitFileStatus> entries;
|
|
|
|
List<GitFileStatus> get staged => entries.where((e) => e.isStaged).toList();
|
|
List<GitFileStatus> get unstaged => entries.where((e) => e.isUnstaged).toList();
|
|
List<GitFileStatus> get untracked => entries.where((e) => e.isUntracked).toList();
|
|
List<GitFileStatus> get conflicted => entries.where((e) => e.isConflicted).toList();
|
|
|
|
bool get isClean => entries.isEmpty;
|
|
bool get hasConflicts => entries.any((e) => e.isConflicted);
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'branch': branch,
|
|
if (upstream != null) 'upstream': upstream,
|
|
'ahead': ahead,
|
|
'behind': behind,
|
|
'clean': isClean,
|
|
'hasConflicts': hasConflicts,
|
|
'staged': [for (final e in staged) e.toJson()],
|
|
'unstaged': [for (final e in unstaged) e.toJson()],
|
|
'untracked': [for (final e in untracked) e.toJson()],
|
|
'conflicted': [for (final e in conflicted) e.toJson()],
|
|
};
|
|
}
|
|
|
|
/// Run `git status` and parse the result.
|
|
Future<GitStatus> gitStatus(Directory workDir) async {
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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(
|
|
branch: branch,
|
|
upstream: upstream,
|
|
ahead: ahead,
|
|
behind: behind,
|
|
entries: const [],
|
|
);
|
|
}
|
|
|
|
final entries = parsePorcelainV1(result.stdout as String);
|
|
return GitStatus(
|
|
branch: branch,
|
|
upstream: upstream,
|
|
ahead: ahead,
|
|
behind: behind,
|
|
entries: entries,
|
|
);
|
|
}
|
|
|
|
List<GitFileStatus> parsePorcelainV1(String output) {
|
|
if (output.isEmpty) return const [];
|
|
final entries = <GitFileStatus>[];
|
|
final parts = output.split('\x00');
|
|
var i = 0;
|
|
while (i < parts.length) {
|
|
final part = parts[i];
|
|
if (part.isEmpty) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (part.length < 4) {
|
|
i++;
|
|
continue;
|
|
}
|
|
final x = part[0]; // index state
|
|
final y = part[1]; // work-tree state
|
|
final path = part.substring(3);
|
|
|
|
// Renames/copies have the original path as the next NUL-delimited
|
|
// field (porcelain v1 with -z).
|
|
String? origPath;
|
|
if (x == 'R' || x == 'C') {
|
|
i++;
|
|
if (i < parts.length) origPath = parts[i];
|
|
}
|
|
|
|
final conflict = _conflictType(x, y);
|
|
entries.add(GitFileStatus(
|
|
path: path,
|
|
indexState: conflict != null ? null : _parseState(x),
|
|
workTreeState: conflict != null ? null : _parseState(y),
|
|
origPath: origPath,
|
|
conflictType: conflict,
|
|
));
|
|
i++;
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
GitConflictType? _conflictType(String x, String y) {
|
|
if (x == 'D' && y == 'D') return GitConflictType.bothDeleted;
|
|
if (x == 'A' && y == 'U') return GitConflictType.addedByUs;
|
|
if (x == 'U' && y == 'D') return GitConflictType.deletedByThem;
|
|
if (x == 'U' && y == 'A') return GitConflictType.addedByThem;
|
|
if (x == 'D' && y == 'U') return GitConflictType.deletedByUs;
|
|
if (x == 'A' && y == 'A') return GitConflictType.bothAdded;
|
|
if (x == 'U' && y == 'U') return GitConflictType.bothModified;
|
|
return null;
|
|
}
|
|
|
|
GitFileState? _parseState(String code) {
|
|
return switch (code) {
|
|
'M' => GitFileState.modified,
|
|
'A' => GitFileState.added,
|
|
'D' => GitFileState.deleted,
|
|
'R' => GitFileState.renamed,
|
|
'C' => GitFileState.copied,
|
|
'?' => GitFileState.untracked,
|
|
'!' => GitFileState.ignored,
|
|
_ => null,
|
|
};
|
|
}
|