version.dart 31.8 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.dart' as globals;
16

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

19 20 21 22 23 24 25 26
/// This maps old branch names to the names of branches that replaced them.
///
/// For example, in 2021 we deprecated the "dev" channel and transitioned "dev"
/// users to the "beta" channel.
const Map<String, String> kObsoleteBranches = <String, String>{
  'dev': 'beta',
};

27 28
/// The names of each channel/branch in order of increasing stability.
enum Channel {
29
  // TODO(fujino): update to main https://github.com/flutter/flutter/issues/95041
30 31 32 33 34
  master,
  beta,
  stable,
}

35 36
// Beware: Keep order in accordance with stability
const Set<String> kOfficialChannels = <String>{
37
  globals.kDefaultFrameworkChannel,
38 39 40
  'beta',
  'stable',
};
41

42 43
/// Retrieve a human-readable name for a given [channel].
///
44
/// Requires [kOfficialChannels] to be correctly ordered.
45
String getNameForChannel(Channel channel) {
46
  return kOfficialChannels.elementAt(channel.index);
47 48 49 50 51
}

/// Retrieve the [Channel] representation for a string [name].
///
/// Returns `null` if [name] is not in the list of official channels, according
52
/// to [kOfficialChannels].
53
Channel? getChannelForName(String name) {
54 55
  if (kOfficialChannels.contains(name)) {
    return Channel.values[kOfficialChannels.toList().indexOf(name)];
56 57 58 59
  }
  return null;
}

60
class FlutterVersion {
61 62 63 64 65
  /// 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.
66 67
  FlutterVersion({
    SystemClock clock = const SystemClock(),
68 69
    String? workingDirectory,
    String? frameworkRevision,
70
  }) : _clock = clock,
71 72
       _workingDirectory = workingDirectory {
    _frameworkRevision = frameworkRevision ?? _runGit(
73
      gitLog(<String>['-n', '1', '--pretty=format:%H']).join(' '),
74
      globals.processUtils,
75 76
      _workingDirectory,
    );
77
    _gitTagVersion = GitTagVersion.determine(globals.processUtils, workingDirectory: _workingDirectory, gitRef: _frameworkRevision);
78 79 80
    _frameworkVersion = gitTagVersion.frameworkVersionFor(_frameworkRevision);
  }

81
  final SystemClock _clock;
82
  final String? _workingDirectory;
83

84
  /// Fetches tags from the upstream Flutter repository and re-calculates the
85 86 87 88 89 90
  /// 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() {
91
    _gitTagVersion = GitTagVersion.determine(globals.processUtils, workingDirectory: _workingDirectory, fetchTags: true);
92
    _frameworkVersion = gitTagVersion.frameworkVersionFor(_frameworkRevision);
93
  }
94

95 96
  String? _repositoryUrl;
  String? get repositoryUrl {
97
    final String _ = channel; // ignore: no_leading_underscores_for_local_identifiers
98 99
    return _repositoryUrl;
  }
100

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

131
  late GitTagVersion _gitTagVersion;
132 133
  GitTagVersion get gitTagVersion => _gitTagVersion;

134 135
  /// The name of the local branch.
  /// Use getBranchName() to read this.
136
  String? _branch;
137

138
  late String _frameworkRevision;
139
  String get frameworkRevision => _frameworkRevision;
140
  String get frameworkRevisionShort => _shortGitRevision(frameworkRevision);
141

142
  String? _frameworkAge;
143
  String get frameworkAge {
144 145
    return _frameworkAge ??= _runGit(
      gitLog(<String>['-n', '1', '--pretty=format:%ar']).join(' '),
146
      globals.processUtils,
147 148
      _workingDirectory,
    );
149
  }
150

151
  late String _frameworkVersion;
152 153
  String get frameworkVersion => _frameworkVersion;

154 155
  String get devToolsVersion => globals.cache.devToolsVersion;

156
  String get dartSdkVersion => globals.cache.dartSdkVersion;
157

158
  String get engineRevision => globals.cache.engineRevision;
159
  String get engineRevisionShort => _shortGitRevision(engineRevision);
160

161
  void ensureVersionFile() {
162
    globals.fs.file(globals.fs.path.join(Cache.flutterRoot!, 'version')).writeAsStringSync(_frameworkVersion);
163
  }
164 165 166

  @override
  String toString() {
167
    final String versionText = frameworkVersion == _unknownFrameworkVersion ? '' : ' $frameworkVersion';
168
    final String flutterText = 'Flutter$versionText • channel $channel${repositoryUrl ?? 'unknown source'}';
169 170
    final String frameworkText = 'Framework • revision $frameworkRevisionShort ($frameworkAge) • $frameworkCommitDate';
    final String engineText = 'Engine • revision $engineRevisionShort';
171
    final String toolsText = 'Tools • Dart $dartSdkVersion • DevTools $devToolsVersion';
172

173
    // Flutter 1.10.2-pre.69 • channel master • https://github.com/flutter/flutter.git
174
    // Framework • revision 340c158f32 (85 minutes ago) • 2018-10-26 11:27:22 -0400
175 176
    // Engine • revision 9c46333e14
    // Tools • Dart 2.1.0 (build 2.1.0-dev.8.0 bf26f760b1)
177

178
    return '$flutterText\n$frameworkText\n$engineText\n$toolsText';
179 180
  }

181
  Map<String, Object> toJson() => <String, Object>{
182
    'frameworkVersion': frameworkVersion,
183 184 185 186 187 188
    'channel': channel,
    'repositoryUrl': repositoryUrl ?? 'unknown source',
    'frameworkRevision': frameworkRevision,
    'frameworkCommitDate': frameworkCommitDate,
    'engineRevision': engineRevision,
    'dartSdkVersion': dartSdkVersion,
189
    'devToolsVersion': devToolsVersion,
190
  };
191

192 193
  String get frameworkDate => frameworkCommitDate;

194
  /// A date String describing the last framework commit.
195 196 197 198 199 200 201 202 203 204
  ///
  /// 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({
205
    String? branch,
206 207
    bool lenient = false,
  }) {
208
    final List<String> args = gitLog(<String>[
209 210 211 212 213
      if (branch != null) branch,
      '-n',
      '1',
      '--pretty=format:%ad',
      '--date=iso',
214
    ]);
215 216 217 218 219 220 221
    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);
222
        globals.printError('Failed to find the latest git commit date: $e\n'
223 224 225 226 227 228 229
          'Returning $dummyDate instead.');
        // Return something that DateTime.parse() can parse.
        return dummyDate.toString();
      } else {
        rethrow;
      }
    }
230 231
  }

232 233 234 235 236 237 238 239 240 241 242 243
  /// 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 {
244
      localFrameworkCommitDate = DateTime.parse(_latestGitCommitDate());
245 246 247 248 249 250
    } on VersionCheckError {
      // Don't perform the update check if the version check failed.
      return;
    }
    final DateTime? latestFlutterCommitDate = await _getLatestAvailableFlutterDate();

251 252
    return VersionFreshnessValidator(
      version: this,
253 254 255 256 257
      clock: _clock,
      localFrameworkCommitDate: localFrameworkCommitDate,
      latestFlutterCommitDate: latestFlutterCommitDate,
      logger: globals.logger,
      cache: globals.cache,
258 259
      pauseTime: VersionFreshnessValidator.timeToPauseToLetUserReadTheMessage,
    ).run();
260 261
  }

262 263 264 265 266 267
  /// 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.
268
  static const String _versionCheckRemote = '__flutter_version_check__';
269 270 271

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

300
  static Future<void> _removeVersionCheckRemoteIfExists() async {
301 302
    final List<String> remotes = (await _run(<String>['git', 'remote']))
        .split('\n')
303
        .map<String>((String name) => name.trim()) // to account for OS-specific line-breaks
304
        .toList();
305
    if (remotes.contains(_versionCheckRemote)) {
306
      await _run(<String>['git', 'remote', 'remove', _versionCheckRemote]);
307
    }
308 309
  }

310
  /// Return a short string for the version (e.g. `master/0.0.59-pre.92`, `scroll_refactor/a76bc8e22b`).
311
  String getVersionString({ bool redactUnknownBranches = false }) {
312
    if (frameworkVersion != _unknownFrameworkVersion) {
313
      return '${getBranchName(redactUnknownBranches: redactUnknownBranches)}/$frameworkVersion';
314
    }
315
    return '${getBranchName(redactUnknownBranches: redactUnknownBranches)}/$frameworkRevisionShort';
316 317 318 319
  }

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

336 337 338 339 340 341
  /// 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.
342
  static Future<void> resetFlutterVersionFreshnessCheck() async {
343
    try {
344
      await globals.cache.getStampFileFor(
345
        VersionCheckStamp.flutterVersionCheckStampFile,
346 347 348 349 350 351
      ).delete();
    } on FileSystemException {
      // Ignore, since we don't mind if the file didn't exist in the first place.
    }
  }

352 353 354 355 356 357 358 359
  /// 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;
  }

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

371
    final DateTime now = _clock.now();
372
    if (versionCheckStamp.lastTimeVersionWasChecked != null) {
373
      final Duration timeSinceLastCheck = now.difference(
374
        versionCheckStamp.lastTimeVersionWasChecked!,
375
      );
376 377

      // Don't ping the server too often. Return cached value if it's fresh.
378
      if (timeSinceLastCheck < VersionFreshnessValidator.checkAgeConsideredUpToDate) {
379
        return versionCheckStamp.lastKnownRemoteVersion;
380
      }
381 382 383 384
    }

    // Cache is empty or it's been a while since the last server ping. Ping the server.
    try {
385 386 387
      final DateTime remoteFrameworkCommitDate = DateTime.parse(
        await FlutterVersion.fetchRemoteFrameworkCommitDate(channel),
      );
xster's avatar
xster committed
388
      await versionCheckStamp.store(
389
        newTimeVersionWasChecked: now,
390 391
        newKnownRemoteVersion: remoteFrameworkCommitDate,
      );
392 393 394 395 396
      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.
397
      globals.printTrace('Failed to check Flutter version in the remote repository: $error');
398 399 400
      // 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(
401
        newTimeVersionWasChecked: now,
402
      );
403 404 405
      return null;
    }
  }
406 407
}

408 409 410 411 412 413 414 415 416
/// Contains data and load/save logic pertaining to Flutter version checks.
@visibleForTesting
class VersionCheckStamp {
  const VersionCheckStamp({
    this.lastTimeVersionWasChecked,
    this.lastKnownRemoteVersion,
    this.lastTimeWarningWasPrinted,
  });

417 418 419
  final DateTime? lastTimeVersionWasChecked;
  final DateTime? lastKnownRemoteVersion;
  final DateTime? lastTimeWarningWasPrinted;
420

421 422
  /// The prefix of the stamp file where we cache Flutter version check data.
  @visibleForTesting
423
  static const String flutterVersionCheckStampFile = 'flutter_version_check';
424

425 426
  static Future<VersionCheckStamp> load(Cache cache, Logger logger) async {
    final String? versionCheckStamp = cache.getStampFor(flutterVersionCheckStampFile);
427 428 429 430

    if (versionCheckStamp != null) {
      // Attempt to parse stamp JSON.
      try {
431
        final dynamic jsonObject = json.decode(versionCheckStamp);
432
        if (jsonObject is Map<String, dynamic>) {
433
          return fromJson(jsonObject);
434
        } else {
435
          logger.printTrace('Warning: expected version stamp to be a Map but found: $jsonObject');
436
        }
437
      } on Exception catch (error, stackTrace) {
438
        // Do not crash if JSON is malformed.
439
        logger.printTrace('${error.runtimeType}: $error\n$stackTrace');
440 441 442 443
      }
    }

    // Stamp is missing or is malformed.
444
    return const VersionCheckStamp();
445 446
  }

447
  static VersionCheckStamp fromJson(Map<String, dynamic> jsonObject) {
448
    DateTime? readDateTime(String property) {
449
      return jsonObject.containsKey(property)
450
          ? DateTime.parse(jsonObject[property] as String)
451
          : null;
452 453
    }

454
    return VersionCheckStamp(
455 456 457 458 459 460
      lastTimeVersionWasChecked: readDateTime('lastTimeVersionWasChecked'),
      lastKnownRemoteVersion: readDateTime('lastKnownRemoteVersion'),
      lastTimeWarningWasPrinted: readDateTime('lastTimeWarningWasPrinted'),
    );
  }

461
  Future<void> store({
462 463 464
    DateTime? newTimeVersionWasChecked,
    DateTime? newKnownRemoteVersion,
    DateTime? newTimeWarningWasPrinted,
465
    Cache? cache,
466 467 468
  }) async {
    final Map<String, String> jsonData = toJson();

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

473
    if (newKnownRemoteVersion != null) {
474
      jsonData['lastKnownRemoteVersion'] = '$newKnownRemoteVersion';
475
    }
476

477
    if (newTimeWarningWasPrinted != null) {
478
      jsonData['lastTimeWarningWasPrinted'] = '$newTimeWarningWasPrinted';
479
    }
480

481
    const JsonEncoder prettyJsonEncoder = JsonEncoder.withIndent('  ');
482
    (cache ?? globals.cache).setStampFor(flutterVersionCheckStampFile, prettyJsonEncoder.convert(jsonData));
483 484 485
  }

  Map<String, String> toJson({
486 487 488
    DateTime? updateTimeVersionWasChecked,
    DateTime? updateKnownRemoteVersion,
    DateTime? updateTimeWarningWasPrinted,
489 490 491 492 493 494 495
  }) {
    updateTimeVersionWasChecked = updateTimeVersionWasChecked ?? lastTimeVersionWasChecked;
    updateKnownRemoteVersion = updateKnownRemoteVersion ?? lastKnownRemoteVersion;
    updateTimeWarningWasPrinted = updateTimeWarningWasPrinted ?? lastTimeWarningWasPrinted;

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

496
    if (updateTimeVersionWasChecked != null) {
497
      jsonData['lastTimeVersionWasChecked'] = '$updateTimeVersionWasChecked';
498
    }
499

500
    if (updateKnownRemoteVersion != null) {
501
      jsonData['lastKnownRemoteVersion'] = '$updateKnownRemoteVersion';
502
    }
503

504
    if (updateTimeWarningWasPrinted != null) {
505
      jsonData['lastTimeWarningWasPrinted'] = '$updateTimeWarningWasPrinted';
506
    }
507 508 509 510 511

    return jsonData;
  }
}

512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528
/// 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.
///
529
/// If [lenient] is true and the command fails, returns an empty string.
530
/// Otherwise, throws a [ToolExit] exception.
531
String _runSync(List<String> command, { bool lenient = true }) {
532
  final ProcessResult results = globals.processManager.runSync(
533 534 535
    command,
    workingDirectory: Cache.flutterRoot,
  );
536

537
  if (results.exitCode == 0) {
538
    return (results.stdout as String).trim();
539
  }
540 541

  if (!lenient) {
542
    throw VersionCheckError(
543
      'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
544
      'Standard out: ${results.stdout}\n'
545 546 547 548 549 550 551
      'Standard error: ${results.stderr}'
    );
  }

  return '';
}

552
String _runGit(String command, ProcessUtils processUtils, [String? workingDirectory]) {
553 554
  return processUtils.runSync(
    command.split(' '),
555
    workingDirectory: workingDirectory ?? Cache.flutterRoot,
556
  ).stdout.trim();
557 558
}

559 560 561 562 563
/// 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 {
564
  final ProcessResult results = await globals.processManager.run(command, workingDirectory: Cache.flutterRoot);
565

566
  if (results.exitCode == 0) {
567
    return (results.stdout as String).trim();
568
  }
569

570
  throw VersionCheckError(
571 572 573
    'Command exited with code ${results.exitCode}: ${command.join(' ')}\n'
    'Standard error: ${results.stderr}'
  );
574
}
575

576
String _shortGitRevision(String? revision) {
577
  if (revision == null) {
578
    return '';
579
  }
580 581
  return revision.length > 10 ? revision.substring(0, 10) : revision;
}
582

583
/// Version of Flutter SDK parsed from Git.
584
class GitTagVersion {
585 586 587 588 589 590 591 592 593
  const GitTagVersion({
    this.x,
    this.y,
    this.z,
    this.hotfix,
    this.devVersion,
    this.devPatch,
    this.commits,
    this.hash,
594
    this.gitTag,
595
  });
596 597 598 599
  const GitTagVersion.unknown()
    : x = null,
      y = null,
      z = null,
600
      hotfix = null,
601
      commits = 0,
602 603
      devVersion = null,
      devPatch = null,
604 605
      hash = '',
      gitTag = '';
606 607

  /// The X in vX.Y.Z.
608
  final int? x;
609 610

  /// The Y in vX.Y.Z.
611
  final int? y;
612 613

  /// The Z in vX.Y.Z.
614
  final int? z;
615

616
  /// the F in vX.Y.Z+hotfix.F.
617
  final int? hotfix;
618

619
  /// Number of commits since the vX.Y.Z tag.
620
  final int? commits;
621 622

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

625
  /// The N in X.Y.Z-dev.N.M.
626
  final int? devVersion;
627

628
  /// The M in X.Y.Z-dev.N.M.
629
  final int? devPatch;
630

631
  /// The git tag that is this version's closest ancestor.
632
  final String? gitTag;
633

634
  static GitTagVersion determine(ProcessUtils processUtils, {String? workingDirectory, bool fetchTags = false, String gitRef = 'HEAD'}) {
635
    if (fetchTags) {
636 637 638 639
      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 {
640
        _runGit('git fetch ${globals.flutterGit} --tags -f', processUtils, workingDirectory);
641
      }
642
    }
643
    // find all tags attached to the given [gitRef]
644
    final List<String> tags = _runGit(
645
      'git tag --points-at $gitRef', processUtils, workingDirectory).trim().split('\n');
646 647 648 649

    // Check first for a stable tag
    final RegExp stableTagPattern = RegExp(r'^\d+\.\d+\.\d+$');
    for (final String tag in tags) {
650 651
      if (stableTagPattern.hasMatch(tag.trim())) {
        return parse(tag);
652
      }
653
    }
654 655 656
    // Next check for a dev tag
    final RegExp devTagPattern = RegExp(r'^\d+\.\d+\.\d+-\d+\.\d+\.pre$');
    for (final String tag in tags) {
657 658
      if (devTagPattern.hasMatch(tag.trim())) {
        return parse(tag);
659
      }
660
    }
661 662 663 664 665

    // 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(
666
        'git describe --match *.*.* --long --tags $gitRef',
667 668 669
        processUtils,
        workingDirectory,
      )
670 671 672
    );
  }

673 674 675 676 677 678
  /// 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').
679 680
  static GitTagVersion parseVersion(String version) {
    final RegExp versionPattern = RegExp(
681
      r'^(\d+)\.(\d+)\.(\d+)(-\d+\.\d+\.pre)?(?:-(\d+)-g([a-f0-9]+))?$');
682
    final Match? match = versionPattern.firstMatch(version.trim());
683
    if (match == null) {
684 685
      return const GitTagVersion.unknown();
    }
686

687 688 689 690 691 692
    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;
693
    if (devString != null) {
694
      final Match? devMatch = RegExp(r'^-(\d+)\.(\d+)\.pre$')
695
        .firstMatch(devString);
696 697 698
      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]!);
699
    }
700
    // count of commits past last tagged version
701
    final int? commits = matchGroups[4] == null ? 0 : int.tryParse(matchGroups[4]!);
702 703
    final String hash = matchGroups[5] ?? '';

704
    return GitTagVersion(
705 706 707 708 709 710 711 712
      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
713 714 715 716 717 718 719 720 721 722 723 724
    );
  }

  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();
725 726 727
  }

  String frameworkVersionFor(String revision) {
728 729
    if (x == null || y == null || z == null || (hash != null && !revision.startsWith(hash!))) {
      return _unknownFrameworkVersion;
730
    }
731 732
    if (commits == 0 && gitTag != null) {
      return gitTag!;
733
    }
734
    if (hotfix != null) {
735
      // This is an unexpected state where untagged commits exist past a hotfix
736
      return '$x.$y.$z+hotfix.${hotfix! + 1}.pre.$commits';
737 738
    }
    if (devPatch != null && devVersion != null) {
739 740 741
      // The next published release this commit will appear in will be a beta
      // release, thus increment [y].
      return '$x.${y! + 1}.0-0.0.pre.$commits';
742
    }
743
    return '$x.$y.${z! + 1}-0.0.pre.$commits';
744 745
  }
}
746 747 748 749 750 751 752 753 754 755

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,
}
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 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890
/// Determine whether or not the provided [version] is "fresh" and notify the user if appropriate.
///
/// To initiate the validation check, call [run].
///
/// We do not want to check with the upstream git remote for newer commits on
/// every tool invocation, as this would significantly slow down running tool
/// commands. Thus, the tool writes to the [VersionCheckStamp] every time that
/// it actually has fetched commits from upstream, and this validator only
/// checks again if it has been more than [checkAgeConsideredUpToDate] since the
/// last fetch.
///
/// We do not want to notify users with "reasonably" fresh versions about new
/// releases. The method [versionAgeConsideredUpToDate] defines a different
/// duration of freshness for each channel. If [localFrameworkCommitDate] is
/// newer than this duration, then we do not show the warning.
///
/// We do not want to annoy users who intentionally disregard the warning and
/// choose not to upgrade. Thus, we only show the message if it has been more
/// than [maxTimeSinceLastWarning] since the last time the user saw the warning.
class VersionFreshnessValidator {
  VersionFreshnessValidator({
    required this.version,
    required this.localFrameworkCommitDate,
    required this.clock,
    required this.cache,
    required this.logger,
    this.latestFlutterCommitDate,
    this.pauseTime = Duration.zero,
  });

  final FlutterVersion version;
  final DateTime localFrameworkCommitDate;
  final SystemClock clock;
  final Cache cache;
  final Logger logger;
  final Duration pauseTime;
  final DateTime? latestFlutterCommitDate;

  late final DateTime now = clock.now();
  late final Duration frameworkAge = now.difference(localFrameworkCommitDate);

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

  /// The amount of time we wait between issuing a warning.
  ///
  /// This is to avoid annoying users who are unable to upgrade right away.
  @visibleForTesting
  static 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
  static Duration timeToPauseToLetUserReadTheMessage = const Duration(seconds: 2);

  // 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.
  @visibleForTesting
  bool canShowWarning(VersionCheckResult remoteVersionStatus) {
    final bool installationSeemsOutdated = frameworkAge > versionAgeConsideredUpToDate(version.channel);
    if (remoteVersionStatus == VersionCheckResult.newVersionAvailable) {
      return true;
    }
    if (!installationSeemsOutdated) {
      return false;
    }
    return remoteVersionStatus == VersionCheckResult.unknown;
  }

  /// 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 eight weeks because
  /// beta releases happen approximately every month.
  @visibleForTesting
  static 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
    }
  }

  /// Execute validations and print warning to [logger] if necessary.
  Future<void> run() async {
    // Don't perform update checks if we're not on an official channel.
    if (!kOfficialChannels.contains(version.channel)) {
      return;
    }

    // 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;

    if (latestFlutterCommitDate == null) {
      remoteVersionStatus = VersionCheckResult.unknown;
    } else {
      if (latestFlutterCommitDate!.isAfter(localFrameworkCommitDate)) {
        remoteVersionStatus = VersionCheckResult.newVersionAvailable;
      } else {
        remoteVersionStatus = 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 = now.difference(lastTimeWarningWasPrinted) > maxTimeSinceLastWarning;
    if (!beenAWhileSinceWarningWasPrinted) {
      return;
    }

    final bool canShowWarningResult = canShowWarning(remoteVersionStatus);

    if (!canShowWarningResult) {
      return;
    }

    // By this point, we should show the update message
    final String updateMessage;
    switch (remoteVersionStatus) {
      case VersionCheckResult.newVersionAvailable:
891
        updateMessage = _newVersionAvailableMessage;
892 893 894 895 896 897 898
        break;
      case VersionCheckResult.versionIsCurrent:
      case VersionCheckResult.unknown:
        updateMessage = versionOutOfDateMessage(frameworkAge);
        break;
    }

899
    logger.printBox(updateMessage);
900 901
    await Future.wait<void>(<Future<void>>[
      stamp.store(
902
        newTimeWarningWasPrinted: now,
903 904 905 906 907 908 909 910 911 912
        cache: cache,
      ),
      Future<void>.delayed(pauseTime),
    ]);
  }
}

@visibleForTesting
String versionOutOfDateMessage(Duration frameworkAge) {
  return '''
913
WARNING: your installation of Flutter is ${frameworkAge.inDays} days old.
914

915
To update to the latest version, run "flutter upgrade".''';
916
}
917 918 919 920 921

const String _newVersionAvailableMessage = '''
A new version of Flutter is available!

To update to the latest version, run "flutter upgrade".''';