commit method
Executes the staged operations.
@param dryRun When true, prints the staged ops and returns DryRun
without touching disk.
@param force When true, bypasses the conflict pre-flight so the
transaction commits over user-modified files.
@return A TransactionResult describing the outcome.
@throws StateError When invoked more than once on the same instance.
Implementation
Future<TransactionResult> commit({
bool dryRun = false,
bool force = false,
}) async {
if (_committed) {
throw StateError(
'InstallTransaction.commit() called twice on the same instance; '
'transactions are one-shot.',
);
}
_committed = true;
// 1. Dry-run short-circuit: print the preview, return without touching disk.
if (dryRun) {
_renderDryRun();
return DryRun(opCount: _ops.length);
}
// 2. Conflict pre-flight. Until Step 7 wires the real ConflictDetector,
// `_detectConflicts` returns the test-seeded list (or an empty list in
// production). The `force` flag bypasses this gate.
final conflicts = _detectConflicts();
if (conflicts.isNotEmpty && !force) {
return Conflict(conflicts: conflicts);
}
// 3. Stage every op into an in-memory write plan. A `null` value marks a
// delete; a non-null value carries the final UTF-8 content to write.
// Unknown / not-yet-implemented op types short-circuit the whole commit
// with an [Error] (no disk side effects yet, so rolledBack is false).
final stagedWrites = <String, String?>{};
for (final op in _ops) {
final stageError = _stageOp(op, stagedWrites);
if (stageError != null) {
return stageError;
}
}
// 4. Atomic disk flush. Write every non-null entry to `<absPath>.tmp`
// first. If any single write throws, loop the successful temps and
// delete them all before returning an [Error] with rolledBack=true.
final committedTemps = <String>[];
try {
for (final entry in stagedWrites.entries) {
final content = entry.value;
if (content == null) continue;
final tmpPath = '${entry.key}.tmp';
_ctx.fs.writeAsString(tmpPath, content);
committedTemps.add(tmpPath);
}
} catch (e) {
for (final tmp in committedTemps) {
_ctx.fs.delete(tmp);
}
return Error(error: e.toString(), rolledBack: true);
}
// 5. All `.tmp` writes succeeded. Rename each one over its target, and
// apply pending deletes. Rename + delete failures here are not rolled
// back: the .tmp swap is the atomic boundary on POSIX. Any partial
// failure past this point is surfaced as an [Error] with rolledBack
// false so the operator can inspect manually.
try {
for (final entry in stagedWrites.entries) {
final absPath = entry.key;
if (entry.value == null) {
_ctx.fs.delete(absPath);
} else {
_ctx.fs.rename('$absPath.tmp', absPath);
}
}
} catch (e) {
return Error(error: e.toString(), rolledBack: false);
}
// 6. Persist the install record BEFORE running shell ops. The atomic
// rename boundary in phase 5 is the point of no return for filesystem
// state; once renames land, the record must reflect what is on disk
// so a downstream shell failure leaves an uninstallable state instead
// of orphaning the changes. Subsequent shell failures are independent
// of install-state durability.
final recordPath = p.join(
_ctx.projectRoot,
'.artisan',
'installed',
'$_pluginName.json',
);
final record = _buildRecord(stagedWrites);
_ctx.fs.writeAsString(recordPath, _encodeJson(record));
// 7. Run deferred shell ops (RunShell). These execute AFTER every file
// mutation has landed so a hook like `flutter pub get` observes the
// final on-disk state. A non-zero exit surfaces as Error; the
// install record from phase 6 stays on disk so the operator can
// `plugin:uninstall` to roll back if the shell precondition cannot
// be satisfied.
final shellError = _runShellOps();
if (shellError != null) {
return shellError;
}
return Success(opCount: _ops.length, recordPath: recordPath);
}