version.dart 30.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6 7
import 'package:meta/meta.dart';

import 'base/common.dart';
8
import 'base/file_system.dart';
9
import 'base/io.dart';
10
import 'base/logger.dart';
11
import 'base/process.dart';
12
import 'base/time.dart';
13
import 'cache.dart';
14
import 'convert.dart';
15
import 'globals_null_migrated.dart' as globals;
16

17 18
const String _unknownFrameworkVersion = '0.0.0-unknown';

19 20 21 22 23 24 25 26
/// The names of each channel/branch in order of increasing stability.
enum Channel {
  master,
  dev,
  beta,
  stable,
}

27 28 29 30 31 32 33
// Beware: Keep order in accordance with stability
const Set<String> kOfficialChannels = <String>{
  'master',
  'dev',
  'beta',
  'stable',
};
34

35 36
/// Retrieve a human-readable name for a given [channel].
///
37
/// Requires [kOfficialChannels] to be correctly ordered.
38
String getNameForChannel(Channel channel) {
39
  return kOfficialChannels.elementAt(channel.index);
40 41 42 43 44
}

/// Retrieve the [Channel] representation for a string [name].
///
/// Returns `null` if [name] is not in the list of official channels, according
45
/// to [kOfficialChannels].
46
Channel? getChannelForName(String name) {
47 48
  if (kOfficialChannels.contains(name)) {
    return Channel.values[kOfficialChannels.toList().indexOf(name)];
49 50 51 52
  }
  return null;
}

53
class FlutterVersion {
54 55 56 57 58
  /// Parses the Flutter version from currently available tags in the local
  /// repo.
  ///
  /// Call [fetchTagsAndUpdate] to update the version based on the latest tags
  /// available upstream.
59 60
  FlutterVersion({
    SystemClock clock = const SystemClock(),
61 62
    String? workingDirectory,
    String? frameworkRevision,
63
  }) : _clock = clock,
64 65
       _workingDirectory = workingDirectory {
    _frameworkRevision = frameworkRevision ?? _runGit(
66
      gitLog(<String>['-n', '1', '--pretty=format:%H']).join(' '),
67
      globals.processUtils,
68 69
      _workingDirectory,
    );
70
    _gitTagVersion = GitTagVersion.determine(globals.processUtils, workingDirectory: _workingDirectory, fetchTags: false, gitRef: _frameworkRevision);
71 72 73
    _frameworkVersion = gitTagVersion.frameworkVersionFor(_frameworkRevision);
  }

74
  final SystemClock _clock;
75
  final String? _workingDirectory;
76

77
  /// Fetches tags from the upstream Flutter repository and re-calculates the
78 79 80 81 82 83
  /// version.
  ///
  /// This carries a performance penalty, and should only be called when the
  /// user explicitly wants to get the version, e.g. for `flutter --version` or
  /// `flutter doctor`.
  void fetchTagsAndUpdate() {
84
    _gitTagVersion = GitTagVersion.determine(globals.processUtils, workingDirectory: _workingDirectory, fetchTags: true);
85
    _frameworkVersion = gitTagVersion.frameworkVersionFor(_frameworkRevision);
86
  }
87

88 89
  String? _repositoryUrl;
  String? get repositoryUrl {
90 91 92
    final String _ = channel;
    return _repositoryUrl;
  }
93

94
  String? _channel;
95
  /// The channel is the upstream branch.
96
  /// `master`, `dev`, `beta`, `stable`; or old ones, like `alpha`, `hackathon`, ...
97
  String get channel {
98 99 100
    String? channel = _channel;
    if (channel == null) {
      final String gitChannel = _runGit(
101
        'git rev-parse --abbrev-ref --symbolic @{u}',
102
        globals.processUtils,
103 104
        _workingDirectory,
      );
105
      final int slash = gitChannel.indexOf('/');
106
      if (slash != -1) {
107
        final String remote = gitChannel.substring(0, slash);
108 109
        _repositoryUrl = _runGit(
          'git ls-remote --get-url $remote',
110
          globals.processUtils,
111 112
          _workingDirectory,
        );
113 114 115
        channel = gitChannel.substring(slash + 1);
      } else if (gitChannel.isEmpty) {
        channel = 'unknown';
116
      } else {
117
        channel = gitChannel;
118
      }
119
      _channel = channel;
120
    }
121
    return channel;
122
  }
123

124
  late GitTagVersion _gitTagVersion;
125 126
  GitTagVersion get gitTagVersion => _gitTagVersion;

127 128
  /// The name of the local branch.
  /// Use getBranchName() to read this.
129
  String? _branch;
130

131
  late String _frameworkRevision;
132
  String get frameworkRevision => _frameworkRevision;
133
  String get frameworkRevisionShort => _shortGitRevision(frameworkRevision);
134

135
  String? _frameworkAge;
136
  String get frameworkAge {
137 138
    return _frameworkAge ??= _runGit(
      gitLog(<String>['-n', '1', '--pretty=format:%ar']).join(' '),
139
      globals.processUtils,
140 141
      _workingDirectory,
    );
142
  }
143

144
  late String _frameworkVersion;
145 146
  String get frameworkVersion => _frameworkVersion;

147
  String get dartSdkVersion => globals.cache.dartSdkVersion;
148

149
  String get engineRevision => globals.cache.engineRevision;
150
  String get engineRevisionShort => _shortGitRevision(engineRevision);
151

152
  void ensureVersionFile() {
153
    globals.fs.file(globals.fs.path.join(Cache.flutterRoot!, 'version')).writeAsStringSync(_frameworkVersion);
154
  }
155 156 157

  @override
  String toString() {
158
    final String versionText = frameworkVersion == _unknownFrameworkVersion ? '' : ' $frameworkVersion';
159
    final String flutterText = 'Flutter$versionText • channel $channel${repositoryUrl ?? 'unknown source'}';
160 161 162
    final String frameworkText = 'Framework • revision $frameworkRevisionShort ($frameworkAge) • $frameworkCommitDate';
    final String engineText = 'Engine • revision $engineRevisionShort';
    final String toolsText = 'Tools • Dart $dartSdkVersion';
163

164 165 166 167
    // Flutter 1.10.2-pre.69 • channel master • https://github.com/flutter/flutter.git
    // Framework • revision 340c158f32 (84 minutes ago) • 2018-10-26 11:27:22 -0400
    // Engine • revision 9c46333e14
    // Tools • Dart 2.1.0 (build 2.1.0-dev.8.0 bf26f760b1)
168

169
    return '$flutterText\n$frameworkText\n$engineText\n$toolsText';
170 171
  }

172
  Map<String, Object> toJson() => <String, Object>{
173
    'frameworkVersion': frameworkVersion,
174 175 176 177 178 179 180
    'channel': channel,
    'repositoryUrl': repositoryUrl ?? 'unknown source',
    'frameworkRevision': frameworkRevision,
    'frameworkCommitDate': frameworkCommitDate,
    'engineRevision': engineRevision,
    'dartSdkVersion': dartSdkVersion,
  };
181

182 183
  String get frameworkDate => frameworkCommitDate;

184
  /// A date String describing the last framework commit.
185 186 187 188 189 190 191 192 193 194
  ///
  /// If a git command fails, this will return a placeholder date.
  String get frameworkCommitDate => _latestGitCommitDate(lenient: true);

  // The date of the latest commit on the given branch. If no branch is
  // specified, then it is the current local branch.
  //
  // If lenient is true, and the git command fails, a placeholder date is
  // returned. Otherwise, the VersionCheckError exception is propagated.
  static String _latestGitCommitDate({
195
    String? branch,
196 197
    bool lenient = false,
  }) {
198
    final List<String> args = gitLog(<String>[
199 200 201 202 203
      if (branch != null) branch,
      '-n',
      '1',
      '--pretty=format:%ad',
      '--date=iso',
204
    ]);
205 206 207 208 209 210 211
    try {
      // Don't plumb 'lenient' through directly so that we can print an error
      // if something goes wrong.
      return _runSync(args, lenient: false);
    } on VersionCheckError catch (e) {
      if (lenient) {
        final DateTime dummyDate = DateTime.fromMillisecondsSinceEpoch(0);
212
        globals.printError('Failed to find the latest git commit date: $e\n'
213 214 215 216 217 218 219
          'Returning $dummyDate instead.');
        // Return something that DateTime.parse() can parse.
        return dummyDate.toString();
      } else {
        rethrow;
      }
    }
220 221
  }

222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253
  /// Checks if the currently installed version of Flutter is up-to-date, and
  /// warns the user if it isn't.
  ///
  /// This function must run while [Cache.lock] is acquired because it reads and
  /// writes shared cache files.
  Future<void> checkFlutterVersionFreshness() async {
    // Don't perform update checks if we're not on an official channel.
    if (!kOfficialChannels.contains(channel)) {
      return;
    }
    DateTime localFrameworkCommitDate;
    try {
      localFrameworkCommitDate = DateTime.parse(_latestGitCommitDate(
        lenient: false
      ));
    } on VersionCheckError {
      // Don't perform the update check if the version check failed.
      return;
    }
    final DateTime? latestFlutterCommitDate = await _getLatestAvailableFlutterDate();

    await checkVersionFreshness(
      this,
      clock: _clock,
      localFrameworkCommitDate: localFrameworkCommitDate,
      latestFlutterCommitDate: latestFlutterCommitDate,
      logger: globals.logger,
      cache: globals.cache,
      pauseTime: timeToPauseToLetUserReadTheMessage,
    );
  }

254 255 256 257 258 259
  /// The name of the temporary git remote used to check for the latest
  /// available Flutter framework version.
  ///
  /// In the absence of bugs and crashes a Flutter developer should never see
  /// this remote appear in their `git remote` list, but also if it happens to
  /// persist we do the proper clean-up for extra robustness.
260
  static const String _versionCheckRemote = '__flutter_version_check__';
261 262 263

  /// The date of the latest framework commit in the remote repository.
  ///
264 265
  /// Throws [VersionCheckError] if a git command fails, for example, when the
  /// remote git repository is not reachable due to a network issue.
266
  static Future<String> fetchRemoteFrameworkCommitDate(String branch) async {
267 268 269 270 271 272
    await _removeVersionCheckRemoteIfExists();
    try {
      await _run(<String>[
        'git',
        'remote',
        'add',
273
        _versionCheckRemote,
274
        globals.flutterGit,
275
      ]);
276
      await _run(<String>['git', 'fetch', _versionCheckRemote, branch]);
277 278 279 280
      return _latestGitCommitDate(
        branch: '$_versionCheckRemote/$branch',
        lenient: false,
      );
281 282
    } on VersionCheckError catch (error) {
      if (globals.platform.environment.containsKey('FLUTTER_GIT_URL')) {
283
        globals.logger.printError('Warning: the Flutter git upstream was overridden '
284
        'by the environment variable FLUTTER_GIT_URL = ${globals.flutterGit}');
285 286 287
      }
      globals.logger.printError(error.toString());
      rethrow;
288 289 290 291 292
    } finally {
      await _removeVersionCheckRemoteIfExists();
    }
  }

293
  static Future<void> _removeVersionCheckRemoteIfExists() async {
294 295
    final List<String> remotes = (await _run(<String>['git', 'remote']))
        .split('\n')
296
        .map<String>((String name) => name.trim()) // to account for OS-specific line-breaks
297
        .toList();
298
    if (remotes.contains(_versionCheckRemote)) {
299
      await _run(<String>['git', 'remote', 'remove', _versionCheckRemote]);
300
    }
301 302
  }

303
  /// Return a short string for the version (e.g. `master/0.0.59-pre.92`, `scroll_refactor/a76bc8e22b`).
304
  String getVersionString({ bool redactUnknownBranches = false }) {
305
    if (frameworkVersion != _unknownFrameworkVersion) {
306
      return '${getBranchName(redactUnknownBranches: redactUnknownBranches)}/$frameworkVersion';
307
    }
308
    return '${getBranchName(redactUnknownBranches: redactUnknownBranches)}/$frameworkRevisionShort';
309 310 311 312
  }

  /// Return the branch name.
  ///
313 314
  /// If [redactUnknownBranches] is true and the branch is unknown,
  /// the branch name will be returned as `'[user-branch]'`.
315
  String getBranchName({ bool redactUnknownBranches = false }) {
316
    _branch ??= () {
317
      final String branch = _runGit('git rev-parse --abbrev-ref HEAD', globals.processUtils);
318 319
      return branch == 'HEAD' ? channel : branch;
    }();
320
    if (redactUnknownBranches || _branch!.isEmpty) {
321
      // Only return the branch names we know about; arbitrary branch names might contain PII.
322
      if (!kOfficialChannels.contains(_branch)) {
323
        return '[user-branch]';
324
      }
325
    }
326
    return _branch!;
327 328
  }

329 330 331 332 333 334
  /// Reset the version freshness information by removing the stamp file.
  ///
  /// New version freshness information will be regenerated when
  /// [checkFlutterVersionFreshness] is called after this. This is typically
  /// used when switching channels so that stale information from another
  /// channel doesn't linger.
335
  static Future<void> resetFlutterVersionFreshnessCheck() async {
336
    try {
337
      await globals.cache.getStampFileFor(
338
        VersionCheckStamp.flutterVersionCheckStampFile,
339 340 341 342 343 344
      ).delete();
    } on FileSystemException {
      // Ignore, since we don't mind if the file didn't exist in the first place.
    }
  }

345 346 347 348 349 350 351 352
  /// log.showSignature=false is a user setting and it will break things,
  /// so we want to disable it for every git log call.  This is a convenience
  /// wrapper that does that.
  @visibleForTesting
  static List<String> gitLog(List<String> args) {
    return <String>['git', '-c', 'log.showSignature=false', 'log'] + args;
  }

353 354 355
  /// Gets the release date of the latest available Flutter version.
  ///
  /// This method sends a server request if it's been more than
356
  /// [checkAgeConsideredUpToDate] since the last version check.
357
  ///
358
  /// Returns null if the cached version is out-of-date or missing, and we are
359
  /// unable to reach the server to get the latest version.
360
  Future<DateTime?> _getLatestAvailableFlutterDate() async {
361
    globals.cache.checkLockAcquired();
362
    final VersionCheckStamp versionCheckStamp = await VersionCheckStamp.load(globals.cache, globals.logger);
363

364
    if (versionCheckStamp.lastTimeVersionWasChecked != null) {
365
      final Duration timeSinceLastCheck = _clock.now().difference(
366
        versionCheckStamp.lastTimeVersionWasChecked!,
367
      );
368 369

      // Don't ping the server too often. Return cached value if it's fresh.
370
      if (timeSinceLastCheck < checkAgeConsideredUpToDate) {
371
        return versionCheckStamp.lastKnownRemoteVersion;
372
      }
373 374 375 376
    }

    // Cache is empty or it's been a while since the last server ping. Ping the server.
    try {
377 378 379
      final DateTime remoteFrameworkCommitDate = DateTime.parse(
        await FlutterVersion.fetchRemoteFrameworkCommitDate(channel),
      );
xster's avatar
xster committed
380
      await versionCheckStamp.store(
381 382 383
        newTimeVersionWasChecked: _clock.now(),
        newKnownRemoteVersion: remoteFrameworkCommitDate,
      );
384 385 386 387 388
      return remoteFrameworkCommitDate;
    } on VersionCheckError catch (error) {
      // This happens when any of the git commands fails, which can happen when
      // there's no Internet connectivity. Remote version check is best effort
      // only. We do not prevent the command from running when it fails.
389
      globals.printTrace('Failed to check Flutter version in the remote repository: $error');
390 391 392 393 394
      // Still update the timestamp to avoid us hitting the server on every single
      // command if for some reason we cannot connect (eg. we may be offline).
      await versionCheckStamp.store(
        newTimeVersionWasChecked: _clock.now(),
      );
395 396 397
      return null;
    }
  }
398 399
}

400 401 402 403 404 405 406 407 408
/// Contains data and load/save logic pertaining to Flutter version checks.
@visibleForTesting
class VersionCheckStamp {
  const VersionCheckStamp({
    this.lastTimeVersionWasChecked,
    this.lastKnownRemoteVersion,
    this.lastTimeWarningWasPrinted,
  });

409 410 411
  final DateTime? lastTimeVersionWasChecked;
  final DateTime? lastKnownRemoteVersion;
  final DateTime? lastTimeWarningWasPrinted;
412

413 414
  /// The prefix of the stamp file where we cache Flutter version check data.
  @visibleForTesting
415
  static const String flutterVersionCheckStampFile = 'flutter_version_check';
416

417 418
  static Future<VersionCheckStamp> load(Cache cache, Logger logger) async {
    final String? versionCheckStamp = cache.getStampFor(flutterVersionCheckStampFile);
419 420 421 422

    if (versionCheckStamp != null) {
      // Attempt to parse stamp JSON.
      try {
423
        final dynamic jsonObject = json.decode(versionCheckStamp);
424
        if (jsonObject is Map<String, dynamic>) {
425
          return fromJson(jsonObject);
426
        } else {
427
          logger.printTrace('Warning: expected version stamp to be a Map but found: $jsonObject');
428
        }
429
      } on Exception catch (error, stackTrace) {
430
        // Do not crash if JSON is malformed.
431
        logger.printTrace('${error.runtimeType}: $error\n$stackTrace');
432 433 434 435
      }
    }

    // Stamp is missing or is malformed.
436
    return const VersionCheckStamp();
437 438
  }

439
  static VersionCheckStamp fromJson(Map<String, dynamic> jsonObject) {
440
    DateTime? readDateTime(String property) {
441
      return jsonObject.containsKey(property)
442
          ? DateTime.parse(jsonObject[property] as String)
443
          : null;
444 445
    }

446
    return VersionCheckStamp(
447 448 449 450 451 452
      lastTimeVersionWasChecked: readDateTime('lastTimeVersionWasChecked'),
      lastKnownRemoteVersion: readDateTime('lastKnownRemoteVersion'),
      lastTimeWarningWasPrinted: readDateTime('lastTimeWarningWasPrinted'),
    );
  }

453
  Future<void> store({
454 455 456
    DateTime? newTimeVersionWasChecked,
    DateTime? newKnownRemoteVersion,
    DateTime? newTimeWarningWasPrinted,
457
    Cache? cache,
458 459 460
  }) async {
    final Map<String, String> jsonData = toJson();

461
    if (newTimeVersionWasChecked != null) {
462
      jsonData['lastTimeVersionWasChecked'] = '$newTimeVersionWasChecked';
463
    }
464

465
    if (newKnownRemoteVersion != null) {
466
      jsonData['lastKnownRemoteVersion'] = '$newKnownRemoteVersion';
467
    }
468

469
    if (newTimeWarningWasPrinted != null) {
470
      jsonData['lastTimeWarningWasPrinted'] = '$newTimeWarningWasPrinted';
471
    }
472

473
    const JsonEncoder prettyJsonEncoder = JsonEncoder.withIndent('  ');
474
    (cache ?? globals.cache).setStampFor(flutterVersionCheckStampFile, prettyJsonEncoder.convert(jsonData));
475 476 477
  }

  Map<String, String> toJson({
478 479 480
    DateTime? updateTimeVersionWasChecked,
    DateTime? updateKnownRemoteVersion,
    DateTime? updateTimeWarningWasPrinted,
481 482 483 484 485 486 487
  }) {
    updateTimeVersionWasChecked = updateTimeVersionWasChecked ?? lastTimeVersionWasChecked;
    updateKnownRemoteVersion = updateKnownRemoteVersion ?? lastKnownRemoteVersion;
    updateTimeWarningWasPrinted = updateTimeWarningWasPrinted ?? lastTimeWarningWasPrinted;

    final Map<String, String> jsonData = <String, String>{};

488
    if (updateTimeVersionWasChecked != null) {
489
      jsonData['lastTimeVersionWasChecked'] = '$updateTimeVersionWasChecked';
490
    }
491

492
    if (updateKnownRemoteVersion != null) {
493
      jsonData['lastKnownRemoteVersion'] = '$updateKnownRemoteVersion';
494
    }
495

496
    if (updateTimeWarningWasPrinted != null) {
497
      jsonData['lastTimeWarningWasPrinted'] = '$updateTimeWarningWasPrinted';
498
    }
499 500 501 502 503

    return jsonData;
  }
}

504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520
/// Thrown when we fail to check Flutter version.
///
/// This can happen when we attempt to `git fetch` but there is no network, or
/// when the installation is not git-based (e.g. a user clones the repo but
/// then removes .git).
class VersionCheckError implements Exception {

  VersionCheckError(this.message);

  final String message;

  @override
  String toString() => '$VersionCheckError: $message';
}

/// Runs [command] and returns the standard output as a string.
///
521
/// If [lenient] is true and the command fails, returns an empty string.
522
/// Otherwise, throws a [ToolExit] exception.
523
String _runSync(List<String> command, { bool lenient = true }) {
524
  final ProcessResult results = globals.processManager.runSync(
525 526 527
    command,
    workingDirectory: Cache.flutterRoot,
  );
528

529
  if (results.exitCode == 0) {
530
    return (results.stdout as String).trim();
531
  }
532 533

  if (!lenient) {
534
    throw VersionCheckError(
535
      'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
536
      'Standard out: ${results.stdout}\n'
537 538 539 540 541 542 543
      'Standard error: ${results.stderr}'
    );
  }

  return '';
}

544
String _runGit(String command, ProcessUtils processUtils, [String? workingDirectory]) {
545 546
  return processUtils.runSync(
    command.split(' '),
547
    workingDirectory: workingDirectory ?? Cache.flutterRoot,
548
  ).stdout.trim();
549 550
}

551 552 553 554 555
/// Runs [command] in the root of the Flutter installation and returns the
/// standard output as a string.
///
/// If the command fails, throws a [ToolExit] exception.
Future<String> _run(List<String> command) async {
556
  final ProcessResult results = await globals.processManager.run(command, workingDirectory: Cache.flutterRoot);
557

558
  if (results.exitCode == 0) {
559
    return (results.stdout as String).trim();
560
  }
561

562
  throw VersionCheckError(
563 564 565
    'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
    'Standard error: ${results.stderr}'
  );
566
}
567

568
String _shortGitRevision(String? revision) {
569
  if (revision == null) {
570
    return '';
571
  }
572 573
  return revision.length > 10 ? revision.substring(0, 10) : revision;
}
574

575
/// Version of Flutter SDK parsed from Git.
576
class GitTagVersion {
577 578 579 580 581 582 583 584 585
  const GitTagVersion({
    this.x,
    this.y,
    this.z,
    this.hotfix,
    this.devVersion,
    this.devPatch,
    this.commits,
    this.hash,
586
    this.gitTag,
587
  });
588 589 590 591
  const GitTagVersion.unknown()
    : x = null,
      y = null,
      z = null,
592
      hotfix = null,
593
      commits = 0,
594 595
      devVersion = null,
      devPatch = null,
596 597
      hash = '',
      gitTag = '';
598 599

  /// The X in vX.Y.Z.
600
  final int? x;
601 602

  /// The Y in vX.Y.Z.
603
  final int? y;
604 605

  /// The Z in vX.Y.Z.
606
  final int? z;
607

608
  /// the F in vX.Y.Z+hotfix.F.
609
  final int? hotfix;
610

611
  /// Number of commits since the vX.Y.Z tag.
612
  final int? commits;
613 614

  /// The git hash (or an abbreviation thereof) for this commit.
615
  final String? hash;
616

617
  /// The N in X.Y.Z-dev.N.M.
618
  final int? devVersion;
619

620
  /// The M in X.Y.Z-dev.N.M.
621
  final int? devPatch;
622

623
  /// The git tag that is this version's closest ancestor.
624
  final String? gitTag;
625

626
  static GitTagVersion determine(ProcessUtils processUtils, {String? workingDirectory, bool fetchTags = false, String gitRef = 'HEAD'}) {
627
    if (fetchTags) {
628 629 630 631
      final String channel = _runGit('git rev-parse --abbrev-ref HEAD', processUtils, workingDirectory);
      if (channel == 'dev' || channel == 'beta' || channel == 'stable') {
        globals.printTrace('Skipping request to fetchTags - on well known channel $channel.');
      } else {
632
        _runGit('git fetch ${globals.flutterGit} --tags -f', processUtils, workingDirectory);
633
      }
634
    }
635
    final List<String> tags = _runGit(
636
      'git tag --points-at $gitRef', processUtils, workingDirectory).trim().split('\n');
637 638 639 640

    // Check first for a stable tag
    final RegExp stableTagPattern = RegExp(r'^\d+\.\d+\.\d+$');
    for (final String tag in tags) {
641 642
      if (stableTagPattern.hasMatch(tag.trim())) {
        return parse(tag);
643
      }
644
    }
645 646 647
    // Next check for a dev tag
    final RegExp devTagPattern = RegExp(r'^\d+\.\d+\.\d+-\d+\.\d+\.pre$');
    for (final String tag in tags) {
648 649
      if (devTagPattern.hasMatch(tag.trim())) {
        return parse(tag);
650
      }
651
    }
652 653 654 655 656

    // If we're not currently on a tag, use git describe to find the most
    // recent tag and number of commits past.
    return parse(
      _runGit(
657
        'git describe --match *.*.* --long --tags $gitRef',
658 659 660
        processUtils,
        workingDirectory,
      )
661 662 663
    );
  }

664 665 666 667 668 669
  /// Parse a version string.
  ///
  /// The version string can either be an exact release tag (e.g. '1.2.3' for
  /// stable or 1.2.3-4.5.pre for a dev) or the output of `git describe` (e.g.
  /// for commit abc123 that is 6 commits after tag 1.2.3-4.5.pre, git would
  /// return '1.2.3-4.5.pre-6-gabc123').
670 671
  static GitTagVersion parseVersion(String version) {
    final RegExp versionPattern = RegExp(
672
      r'^(\d+)\.(\d+)\.(\d+)(-\d+\.\d+\.pre)?(?:-(\d+)-g([a-f0-9]+))?$');
673
    final Match? match = versionPattern.firstMatch(version.trim());
674
    if (match == null) {
675 676
      return const GitTagVersion.unknown();
    }
677

678 679 680 681 682 683
    final List<String?> matchGroups = match.groups(<int>[1, 2, 3, 4, 5, 6]);
    final int? x = matchGroups[0] == null ? null : int.tryParse(matchGroups[0]!);
    final int? y = matchGroups[1] == null ? null : int.tryParse(matchGroups[1]!);
    final int? z = matchGroups[2] == null ? null : int.tryParse(matchGroups[2]!);
    final String? devString = matchGroups[3];
    int? devVersion, devPatch;
684
    if (devString != null) {
685
      final Match? devMatch = RegExp(r'^-(\d+)\.(\d+)\.pre$')
686
        .firstMatch(devString);
687 688 689
      final List<String?>? devGroups = devMatch?.groups(<int>[1, 2]);
      devVersion = devGroups?[0] == null ? null : int.tryParse(devGroups![0]!);
      devPatch = devGroups?[1] == null ? null : int.tryParse(devGroups![1]!);
690
    }
691
    // count of commits past last tagged version
692
    final int? commits = matchGroups[4] == null ? 0 : int.tryParse(matchGroups[4]!);
693 694
    final String hash = matchGroups[5] ?? '';

695
    return GitTagVersion(
696 697 698 699 700 701 702 703
      x: x,
      y: y,
      z: z,
      devVersion: devVersion,
      devPatch: devPatch,
      commits: commits,
      hash: hash,
      gitTag: '$x.$y.$z${devString ?? ''}', // e.g. 1.2.3-4.5.pre
704 705 706 707 708 709 710 711 712 713 714 715
    );
  }

  static GitTagVersion parse(String version) {
    GitTagVersion gitTagVersion;

    gitTagVersion = parseVersion(version);
    if (gitTagVersion != const GitTagVersion.unknown()) {
      return gitTagVersion;
    }
    globals.printTrace('Could not interpret results of "git describe": $version');
    return const GitTagVersion.unknown();
716 717 718
  }

  String frameworkVersionFor(String revision) {
719 720
    if (x == null || y == null || z == null || (hash != null && !revision.startsWith(hash!))) {
      return _unknownFrameworkVersion;
721
    }
722 723
    if (commits == 0 && gitTag != null) {
      return gitTag!;
724
    }
725
    if (hotfix != null) {
726
      // This is an unexpected state where untagged commits exist past a hotfix
727
      return '$x.$y.$z+hotfix.${hotfix! + 1}.pre.$commits';
728 729
    }
    if (devPatch != null && devVersion != null) {
730
      return '$x.$y.$z-${devVersion! + 1}.0.pre.$commits';
731
    }
732
    return '$x.$y.${z! + 1}-0.0.pre.$commits';
733 734
  }
}
735 736 737 738 739 740 741 742 743 744

enum VersionCheckResult {
  /// Unable to check whether a new version is available, possibly due to
  /// a connectivity issue.
  unknown,
  /// The current version is up to date.
  versionIsCurrent,
  /// A newer version is available.
  newVersionAvailable,
}
745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863

@visibleForTesting
Future<void> checkVersionFreshness(FlutterVersion version, {
  required DateTime localFrameworkCommitDate,
  required DateTime? latestFlutterCommitDate,
  required SystemClock clock,
  required Cache cache,
  required Logger logger,
  Duration pauseTime = Duration.zero,
}) async {
  // Don't perform update checks if we're not on an official channel.
  if (!kOfficialChannels.contains(version.channel)) {
    return;
  }

  final Duration frameworkAge = clock.now().difference(localFrameworkCommitDate);
  final bool installationSeemsOutdated = frameworkAge > versionAgeConsideredUpToDate(version.channel);

  // Get whether there's a newer version on the remote. This only goes
  // to the server if we haven't checked recently so won't happen on every
  // command.
  final VersionCheckResult remoteVersionStatus = latestFlutterCommitDate == null
      ? VersionCheckResult.unknown
      : latestFlutterCommitDate.isAfter(localFrameworkCommitDate)
        ? VersionCheckResult.newVersionAvailable
        : VersionCheckResult.versionIsCurrent;

  // Do not load the stamp before the above server check as it may modify the stamp file.
  final VersionCheckStamp stamp = await VersionCheckStamp.load(cache, logger);
  final DateTime lastTimeWarningWasPrinted = stamp.lastTimeWarningWasPrinted ?? clock.ago(maxTimeSinceLastWarning * 2);
  final bool beenAWhileSinceWarningWasPrinted = clock.now().difference(lastTimeWarningWasPrinted) > maxTimeSinceLastWarning;

  // We show a warning if either we know there is a new remote version, or we couldn't tell but the local
  // version is outdated.
  final bool canShowWarning =
    remoteVersionStatus == VersionCheckResult.newVersionAvailable ||
      (remoteVersionStatus == VersionCheckResult.unknown &&
        installationSeemsOutdated);

  if (beenAWhileSinceWarningWasPrinted && canShowWarning) {
    final String updateMessage =
      remoteVersionStatus == VersionCheckResult.newVersionAvailable
        ? newVersionAvailableMessage()
        : versionOutOfDateMessage(frameworkAge);
    logger.printStatus(updateMessage, emphasis: true);
    await Future.wait<void>(<Future<void>>[
      stamp.store(
        newTimeWarningWasPrinted: clock.now(),
        cache: cache,
      ),
      Future<void>.delayed(pauseTime),
    ]);
  }
}

/// The amount of time we wait before pinging the server to check for the
/// availability of a newer version of Flutter.
@visibleForTesting
const Duration checkAgeConsideredUpToDate = Duration(days: 3);

/// We warn the user if the age of their Flutter installation is greater than
/// this duration. The durations are slightly longer than the expected release
/// cadence for each channel, to give the user a grace period before they get
/// notified.
///
/// For example, for the beta channel, this is set to five weeks because
/// beta releases happen approximately every month.
@visibleForTesting
Duration versionAgeConsideredUpToDate(String channel) {
  switch (channel) {
    case 'stable':
      return const Duration(days: 365 ~/ 2); // Six months
    case 'beta':
      return const Duration(days: 7 * 8); // Eight weeks
    case 'dev':
      return const Duration(days: 7 * 4); // Four weeks
    default:
      return const Duration(days: 7 * 3); // Three weeks
  }
}

/// The amount of time we wait between issuing a warning.
///
/// This is to avoid annoying users who are unable to upgrade right away.
@visibleForTesting
const Duration maxTimeSinceLastWarning = Duration(days: 1);

/// The amount of time we pause for to let the user read the message about
/// outdated Flutter installation.
///
/// This can be customized in tests to speed them up.
@visibleForTesting
Duration timeToPauseToLetUserReadTheMessage = const Duration(seconds: 2);

@visibleForTesting
String versionOutOfDateMessage(Duration frameworkAge) {
  String warning = 'WARNING: your installation of Flutter is ${frameworkAge.inDays} days old.';
  // Append enough spaces to match the message box width.
  warning += ' ' * (74 - warning.length);

  return '''
╔════════════════════════════════════════════════════════════════════════════╗
$warning
║                                                                            ║
║ To update to the latest version, run "flutter upgrade".                    ║
╚════════════════════════════════════════════════════════════════════════════╝
''';
}

@visibleForTesting
String newVersionAvailableMessage() {
  return '''
╔════════════════════════════════════════════════════════════════════════════╗
║ A new version of Flutter is available!                                     ║
║                                                                            ║
║ To update to the latest version, run "flutter upgrade".                    ║
╚════════════════════════════════════════════════════════════════════════════╝
''';
}