1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:meta/meta.dart';
6
7import '../base/common.dart';
8import '../base/io.dart';
9import '../base/os.dart';
10import '../base/process.dart';
11import '../base/time.dart';
12import '../base/utils.dart';
13import '../cache.dart';
14import '../dart/pub.dart';
15import '../globals.dart' as globals;
16import '../persistent_tool_state.dart';
17import '../project.dart';
18import '../runner/flutter_command.dart';
19import '../version.dart';
20import 'channel.dart';
21
22// The official docs to install Flutter.
23const _flutterInstallDocs = 'https://flutter.dev/setup';
24
25class UpgradeCommand extends FlutterCommand {
26 UpgradeCommand({required bool verboseHelp, UpgradeCommandRunner? commandRunner})
27 : _commandRunner = commandRunner ?? UpgradeCommandRunner() {
28 argParser
29 ..addFlag(
30 'force',
31 abbr: 'f',
32 help: 'Force upgrade the flutter branch, potentially discarding local changes.',
33 negatable: false,
34 )
35 ..addFlag(
36 'continue',
37 hide: !verboseHelp,
38 help:
39 'Trigger the second half of the upgrade flow. This should not be invoked '
40 'manually. It is used re-entrantly by the standard upgrade command after '
41 'the new version of Flutter is available, to hand off the upgrade process '
42 'from the old version to the new version.',
43 )
44 ..addOption(
45 'continue-started-at',
46 hide: !verboseHelp,
47 help:
48 'If "--continue" is provided, an ISO 8601 timestamp of the time that the '
49 'initial upgrade command was started. This should not be invoked manually.',
50 )
51 ..addOption(
52 'working-directory',
53 hide: !verboseHelp,
54 help:
55 'Override the upgrade working directory. '
56 'This is only intended to enable integration testing of the tool itself.',
57 // Also notably, this will override the FakeFlutterVersion if any is set!
58 )
59 ..addFlag(
60 'verify-only',
61 help: 'Checks for any new Flutter updates, without actually fetching them.',
62 negatable: false,
63 );
64 }
65
66 final UpgradeCommandRunner _commandRunner;
67
68 @override
69 final name = 'upgrade';
70
71 @override
72 final description = 'Upgrade your copy of Flutter.';
73
74 @override
75 final String category = FlutterCommandCategory.sdk;
76
77 @override
78 bool get shouldUpdateCache => false;
79
80 UpgradePhase _parsePhaseFromContinueArg() {
81 if (!boolArg('continue')) {
82 return const UpgradePhase.firstHalf();
83 } else {
84 final DateTime? upgradeStartedAt;
85 if (stringArg('continue-started-at') case final String iso8601String) {
86 upgradeStartedAt = DateTime.parse(iso8601String);
87 } else {
88 upgradeStartedAt = null;
89 }
90 return UpgradePhase.secondHalf(upgradeStartedAt: upgradeStartedAt);
91 }
92 }
93
94 @override
95 Future<FlutterCommandResult> runCommand() {
96 _commandRunner.workingDirectory = stringArg('working-directory') ?? Cache.flutterRoot!;
97 return _commandRunner.runCommand(
98 _parsePhaseFromContinueArg(),
99 force: boolArg('force'),
100 testFlow: stringArg('working-directory') != null,
101 gitTagVersion: GitTagVersion.determine(
102 globals.processUtils,
103 globals.platform,
104 workingDirectory: _commandRunner.workingDirectory,
105 ),
106 flutterVersion: stringArg('working-directory') == null
107 ? globals.flutterVersion
108 : FlutterVersion(flutterRoot: _commandRunner.workingDirectory!, fs: globals.fs),
109 verifyOnly: boolArg('verify-only'),
110 );
111 }
112}
113
114@immutable
115sealed class UpgradePhase {
116 const factory UpgradePhase.firstHalf() = _FirstHalf;
117 const factory UpgradePhase.secondHalf({required DateTime? upgradeStartedAt}) = _SecondHalf;
118}
119
120final class _FirstHalf implements UpgradePhase {
121 const _FirstHalf();
122}
123
124final class _SecondHalf implements UpgradePhase {
125 const _SecondHalf({required this.upgradeStartedAt});
126
127 /// What time the original `flutter upgrade` command started at.
128 ///
129 /// If omitted, the initiating client was too old to know to pass this value.
130 final DateTime? upgradeStartedAt;
131}
132
133@visibleForTesting
134class UpgradeCommandRunner {
135 String? workingDirectory; // set in runCommand() above
136
137 @visibleForTesting
138 var clock = const SystemClock();
139
140 Future<FlutterCommandResult> runCommand(
141 UpgradePhase phase, {
142 required bool force,
143 required bool testFlow,
144 required GitTagVersion gitTagVersion,
145 required FlutterVersion flutterVersion,
146 required bool verifyOnly,
147 }) async {
148 switch (phase) {
149 case _FirstHalf():
150 await _runCommandFirstHalf(
151 startedAt: clock.now(),
152 force: force,
153 gitTagVersion: gitTagVersion,
154 flutterVersion: flutterVersion,
155 testFlow: testFlow,
156 verifyOnly: verifyOnly,
157 );
158 case _SecondHalf(:final DateTime? upgradeStartedAt):
159 await _runCommandSecondHalf(flutterVersion);
160 if (upgradeStartedAt != null) {
161 final Duration execution = clock.now().difference(upgradeStartedAt);
162 globals.printStatus('Took ${getElapsedAsMinutesOrSeconds(execution)}');
163 }
164 }
165 return FlutterCommandResult.success();
166 }
167
168 Future<void> _runCommandFirstHalf({
169 required DateTime startedAt,
170 required bool force,
171 required GitTagVersion gitTagVersion,
172 required FlutterVersion flutterVersion,
173 required bool testFlow,
174 required bool verifyOnly,
175 }) async {
176 final FlutterVersion upstreamVersion = await fetchLatestVersion(localVersion: flutterVersion);
177 // It's possible for a given framework revision to have multiple tags (i.e., due to a release
178 // rollback). Verify the upstream version tag isn't newer than the current tag.
179 if (flutterVersion.frameworkRevision == upstreamVersion.frameworkRevision &&
180 flutterVersion.gitTagVersion.gitTag.compareTo(upstreamVersion.gitTagVersion.gitTag) >= 0) {
181 globals.printStatus('Flutter is already up to date on channel ${flutterVersion.channel}');
182 globals.printStatus('$flutterVersion');
183 return;
184 } else if (verifyOnly) {
185 globals.printStatus(
186 'A new version of Flutter is available on channel ${flutterVersion.channel}\n',
187 );
188 globals.printStatus(
189 'The latest version: ${upstreamVersion.frameworkVersion} (revision ${upstreamVersion.frameworkRevisionShort})',
190 emphasis: true,
191 );
192 globals.printStatus(
193 'Your current version: ${flutterVersion.frameworkVersion} (revision ${flutterVersion.frameworkRevisionShort})\n',
194 );
195 globals.printStatus('To upgrade now, run "flutter upgrade".');
196 if (flutterVersion.channel == 'stable') {
197 globals.printStatus('\nSee the announcement and release notes:');
198 globals.printStatus('https://docs.flutter.dev/release/release-notes');
199 }
200 return;
201 }
202 if (!force && gitTagVersion == const GitTagVersion.unknown()) {
203 // If the commit is a recognized branch and not master,
204 // explain that we are avoiding potential damage.
205 if (flutterVersion.channel != 'master' &&
206 kOfficialChannels.contains(flutterVersion.channel)) {
207 throwToolExit(
208 'Unknown flutter tag. Abandoning upgrade to avoid destroying local '
209 'changes. It is recommended to use git directly if not working on '
210 'an official channel.',
211 );
212 // Otherwise explain that local changes can be lost.
213 } else {
214 throwToolExit(
215 'Unknown flutter tag. Abandoning upgrade to avoid destroying local '
216 'changes. If it is okay to remove local changes, then re-run this '
217 'command with "--force".',
218 );
219 }
220 }
221 // If there are uncommitted changes we might be on the right commit but
222 // we should still warn.
223 if (!force && await hasUncommittedChanges()) {
224 throwToolExit(
225 'Your flutter checkout has local changes that would be erased by '
226 'upgrading. If you want to keep these changes, it is recommended that '
227 'you stash them via "git stash" or else commit the changes to a local '
228 'branch. If it is okay to remove local changes, then re-run this '
229 'command with "--force".',
230 );
231 }
232 recordState(flutterVersion);
233 await ChannelCommand.upgradeChannel(flutterVersion);
234 globals.printStatus(
235 'Upgrading Flutter to ${upstreamVersion.frameworkVersion} from ${flutterVersion.frameworkVersion} in $workingDirectory...',
236 );
237 await attemptReset(upstreamVersion.frameworkRevision);
238 if (!testFlow) {
239 await flutterUpgradeContinue(startedAt: startedAt);
240 }
241 }
242
243 void recordState(FlutterVersion flutterVersion) {
244 final Channel? channel = getChannelForName(flutterVersion.channel);
245 if (channel == null) {
246 return;
247 }
248 globals.persistentToolState!.updateLastActiveVersion(flutterVersion.frameworkRevision, channel);
249 }
250
251 @visibleForTesting
252 Future<void> flutterUpgradeContinue({required DateTime startedAt}) async {
253 final int code = await globals.processUtils.stream(
254 <String>[
255 globals.fs.path.join('bin', 'flutter'),
256 'upgrade',
257 '--continue',
258 '--continue-started-at',
259 startedAt.toIso8601String(),
260 '--no-version-check',
261 ],
262 workingDirectory: workingDirectory,
263 allowReentrantFlutter: true,
264 environment: Map<String, String>.of(globals.platform.environment),
265 );
266 if (code != 0) {
267 throwToolExit(null, exitCode: code);
268 }
269 }
270
271 // This method should only be called if the upgrade command is invoked
272 // re-entrantly with the `--continue` flag
273 Future<void> _runCommandSecondHalf(FlutterVersion flutterVersion) async {
274 // Make sure the welcome message re-display is delayed until the end.
275 final PersistentToolState persistentToolState = globals.persistentToolState!;
276 persistentToolState.setShouldRedisplayWelcomeMessage(false);
277 await precacheArtifacts(workingDirectory);
278 await updatePackages(flutterVersion);
279 await runDoctor();
280 // Force the welcome message to re-display following the upgrade.
281 persistentToolState.setShouldRedisplayWelcomeMessage(true);
282 if (globals.flutterVersion.channel == 'master' || globals.flutterVersion.channel == 'main') {
283 globals.printStatus(
284 '\n'
285 'This channel is intended for Flutter contributors. '
286 'This channel is not as thoroughly tested as the "beta" and "stable" channels. '
287 'We do not recommend using this channel for normal use as it more likely to contain serious regressions.\n'
288 '\n'
289 'For information on contributing to Flutter, see our contributing guide:\n'
290 ' https://github.com/flutter/flutter/blob/main/CONTRIBUTING.md\n'
291 '\n'
292 'For the most up to date stable version of flutter, consider using the "beta" channel instead. '
293 'The Flutter "beta" channel enjoys all the same automated testing as the "stable" channel, '
294 'but is updated roughly once a month instead of once a quarter.\n'
295 'To change channel, run the "flutter channel beta" command.',
296 );
297 }
298 }
299
300 @protected
301 Future<bool> hasUncommittedChanges() async {
302 try {
303 final RunResult result = await globals.processUtils.run(
304 <String>['git', 'status', '-s'],
305 throwOnError: true,
306 workingDirectory: workingDirectory,
307 );
308 return result.stdout.trim().isNotEmpty;
309 } on ProcessException catch (error) {
310 throwToolExit(
311 'The tool could not verify the status of the current flutter checkout. '
312 'This might be due to git not being installed or an internal error. '
313 'If it is okay to ignore potential local changes, then re-run this '
314 'command with "--force".\n'
315 'Error: $error.',
316 );
317 }
318 }
319
320 /// Returns the remote HEAD flutter version.
321 ///
322 /// Exits tool if HEAD isn't pointing to a branch, or there is no upstream.
323 @visibleForTesting
324 Future<FlutterVersion> fetchLatestVersion({required FlutterVersion localVersion}) async {
325 String revision;
326 try {
327 // Fetch upstream branch's commits and tags
328 await globals.processUtils.run(
329 <String>['git', 'fetch', '--tags'],
330 throwOnError: true,
331 workingDirectory: workingDirectory,
332 );
333 // Get the latest commit revision of the upstream
334 final RunResult result = await globals.processUtils.run(
335 <String>['git', 'rev-parse', '--verify', kGitTrackingUpstream],
336 throwOnError: true,
337 workingDirectory: workingDirectory,
338 );
339 revision = result.stdout.trim();
340 } on Exception catch (e) {
341 final errorString = e.toString();
342 if (errorString.contains('fatal: HEAD does not point to a branch')) {
343 throwToolExit(
344 'Unable to upgrade Flutter: Your Flutter checkout is currently not '
345 'on a release branch.\n'
346 'Use "flutter channel" to switch to an official channel, and retry. '
347 'Alternatively, re-install Flutter by going to $_flutterInstallDocs.',
348 );
349 } else if (errorString.contains('fatal: no upstream configured for branch')) {
350 throwToolExit(
351 'Unable to upgrade Flutter: The current Flutter branch/channel is '
352 'not tracking any remote repository.\n'
353 'Re-install Flutter by going to $_flutterInstallDocs.',
354 );
355 } else {
356 throwToolExit(errorString);
357 }
358 }
359 // At this point the current checkout should be on HEAD of a branch having
360 // an upstream. Check whether this upstream is "standard".
361 final VersionCheckError? error = VersionUpstreamValidator(
362 version: localVersion,
363 platform: globals.platform,
364 ).run();
365 if (error != null) {
366 throwToolExit(
367 'Unable to upgrade Flutter: '
368 '${error.message}\n'
369 'Reinstalling Flutter may fix this issue. Visit $_flutterInstallDocs '
370 'for instructions.',
371 );
372 }
373 return FlutterVersion.fromRevision(
374 flutterRoot: workingDirectory!,
375 frameworkRevision: revision,
376 fs: globals.fs,
377 );
378 }
379
380 /// Attempts a hard reset to the given revision.
381 ///
382 /// This is a reset instead of fast forward because if we are on a release
383 /// branch with cherry picks, there may not be a direct fast-forward route
384 /// to the next release.
385 @visibleForTesting
386 Future<void> attemptReset(String newRevision) async {
387 try {
388 await globals.processUtils.run(
389 <String>['git', 'reset', '--hard', newRevision],
390 throwOnError: true,
391 workingDirectory: workingDirectory,
392 );
393 } on ProcessException catch (e) {
394 throwToolExit(e.message, exitCode: e.errorCode);
395 }
396 }
397
398 /// Update the user's packages.
399 @protected
400 Future<void> updatePackages(FlutterVersion flutterVersion) async {
401 globals.printStatus('');
402 globals.printStatus(flutterVersion.toString());
403 final String? projectRoot = findProjectRoot(globals.fs);
404 if (projectRoot != null) {
405 globals.printStatus('');
406 await pub.get(
407 context: PubContext.pubUpgrade,
408 project: FlutterProject.fromDirectory(globals.fs.directory(projectRoot)),
409 upgrade: true,
410 );
411 }
412 }
413
414 /// Run flutter doctor in case requirements have changed.
415 @protected
416 Future<void> runDoctor() async {
417 globals.printStatus('');
418 globals.printStatus('Running flutter doctor...');
419 await globals.processUtils.stream(
420 <String>[globals.fs.path.join('bin', 'flutter'), '--no-version-check', 'doctor'],
421 workingDirectory: workingDirectory,
422 allowReentrantFlutter: true,
423 );
424 }
425}
426
427/// Update the engine repository and precache all artifacts.
428///
429/// Check for and download any engine and pkg/ updates. We run the 'flutter'
430/// shell script reentrantly here so that it will download the updated
431/// Dart and so forth if necessary.
432Future<void> precacheArtifacts([String? workingDirectory]) async {
433 globals.printStatus('');
434 globals.printStatus('Upgrading engine...');
435 final int code = await globals.processUtils.stream(
436 <String>[
437 globals.fs.path.join('bin', 'flutter'),
438 '--no-color',
439 '--no-version-check',
440 'precache',
441 ],
442 allowReentrantFlutter: true,
443 environment: Map<String, String>.of(globals.platform.environment),
444 workingDirectory: workingDirectory,
445 );
446 if (code != 0) {
447 throwToolExit(null, exitCode: code);
448 }
449}
450