├── .github
└── FUNDING.yml
├── .gitignore
├── .idea
└── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── .vscode
└── settings.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── analysis_options.yaml
├── bin
└── git_revision.dart
├── git.rb
├── lib
├── cache.dart
├── cli_app.dart
├── cli_app.g.dart
├── git
│ ├── commit.dart
│ ├── git_client.dart
│ └── local_changes.dart
└── git_revision.dart
├── package
├── git-revision.bat
└── git-revision.sh
├── pubspec.yaml
├── test
├── README.md
├── integration
│ ├── git_integration_test.dart
│ └── util
│ │ └── temp_git.dart
└── unit
│ ├── cli_app_test.dart
│ └── util
│ └── memory_logger.dart
└── tool
├── build.dart
├── clean.dart
├── reformat.dart
├── standalone.dart
└── util
├── archive.dart
├── process.dart
└── utils.dart
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [passsy]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Files and directories created by pub
2 | .packages
3 | .pub/
4 | build/
5 | .dart_tool/
6 | # Remove the following pattern if you wish to check in your lock file
7 | pubspec.lock
8 |
9 | # Directory created by dartdoc
10 | doc/api/
11 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "dart.lineLength": 120,
3 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 0.7.1
4 |
5 | - Fix build number
6 |
7 | ## 0.7.0
8 |
9 | - Support for arm & arm64 architectures
10 | - Automatically use `main` as default branch when `master` doesn't exist
11 |
12 | ## 0.6.0
13 |
14 | - Finally git-revision is available as standalone binary #8
15 | - License was added (Apache v2) #7
16 | - Support for git `<2.2.0` #10
17 | - full Dart2 support #6
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2018 Pascal Welsch
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # git revision
2 |
3 | Git extension to generate a meaningful, human-readable revision for each commit in a git repository.
4 |
5 | ## Installing
6 |
7 | ```bash
8 | dart pub global activate git_revision
9 | ```
10 |
11 | or download the archive from [releases](https://github.com/passsy/git-revision/releases) and install it manually
12 |
13 | ## Usage
14 |
15 | ```
16 | > git revision
17 | 73_feature/user_profile+0_996321c-dirty
18 | ```
19 |
20 | ```
21 | > git revision --full
22 | versionCode: 73
23 | versionName: 73_feature/user_profile+0_996321c-dirty
24 | baseBranch: master
25 | currentBranch: feature/user_profile
26 | sha1: 996321c8a38c0cd0c9ebeb4e9f82615796005202
27 | sha1Short: 996321c
28 | baseBranchCommitCount first-only: 50
29 | baseBranchCommitCount: 50
30 | baseBranchTimeComponent: 23
31 | featureBranchCommitCount: 0
32 | featureBranchTimeComponent: 0
33 | featureOrigin: 996321c8a38c0cd0c9ebeb4e9f82615796005202
34 | yearFactor: 1000
35 | localChanges: 4 +35 -12
36 | ```
37 |
38 | ### Possible revisions
39 |
40 | #### Examples
41 |
42 | ```
43 | 1_a541234
44 |
45 | 1235+1_1234567
46 |
47 | 432+43_a342123-dirty
48 |
49 | 1234_someBranch+43_3423123
50 |
51 | 1234_someBranch+43_3423123-dirty
52 |
53 | 1234_feature/topic_branch-something1234_cool+43_3423123-dirty
54 |
55 | 1234_topic_branch_name+0_3423123-dirty
56 | ```
57 |
58 | #### Schema
59 |
60 | Regex matching any possible revision (above)
61 |
62 | ```
63 | (\d+)(?>_([\w_\-\/]+))?(?>\+(\d+))?_([0-9a-f]{7})(-dirty)?
64 | ```
65 |
66 |
67 | ### Help
68 |
69 | ```
70 | > git revision -h
71 | git revision creates a useful revision for your project beyond 'git describe'
72 | -h, --help Print this usage information.
73 | -v, --version Shows the version information of git revision
74 | -C, --context Run as if git was started in instead of the current working directory
75 | -b, --baseBranch The base branch where most of the development happens, (defaults to master, or main). Often what is set as baseBranch in github. Only on the baseBranch the revision can become only digits.
76 | -y, --yearFactor revision increment count per year
77 | (defaults to "1000")
78 | -d, --stopDebounce time between two commits which are further apart than this stopDebounce (in hours) will not be included into the timeComponent. A project on hold for a few months will therefore not increase the revision drastically when development starts again.
79 | (defaults to "48")
80 | -n, --name a human readable name and identifier of a revision ('73_+21_996321c'). Can be anything which gives the revision more meaning i.e. the number of the PullRequest when building on CI. Allowed characters: [a-zA-Z0-9_-/] any letter, digits, underscore, dash and slash. Invalid characters will be removed.
81 | --full shows full information about the current revision and extracted information
82 | ```
83 |
84 | # License
85 |
86 | ```
87 | Copyright 2018 Pascal Welsch
88 |
89 | Licensed under the Apache License, Version 2.0 (the "License");
90 | you may not use this file except in compliance with the License.
91 | You may obtain a copy of the License at
92 |
93 | http://www.apache.org/licenses/LICENSE-2.0
94 |
95 | Unless required by applicable law or agreed to in writing, software
96 | distributed under the License is distributed on an "AS IS" BASIS,
97 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
98 | See the License for the specific language governing permissions and
99 | limitations under the License.
100 | ```
101 |
--------------------------------------------------------------------------------
/analysis_options.yaml:
--------------------------------------------------------------------------------
1 | include: package:lint/analysis_options.yaml
2 |
3 | linter:
4 | rules:
5 | avoid_print: false
6 |
--------------------------------------------------------------------------------
/bin/git_revision.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io' as io;
3 |
4 | import 'package:git_revision/cli_app.dart';
5 |
6 | Future main(List args) async {
7 | final app = CliApp.production();
8 |
9 | try {
10 | await app.process(args);
11 | io.exit(0);
12 | } catch (e, st) {
13 | if (e is ArgError) {
14 | // These errors are expected.
15 | io.exit(1);
16 | } else {
17 | print('Unexpected error: $e\n$st');
18 | io.exit(1);
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/git.rb:
--------------------------------------------------------------------------------
1 | require "open3"
2 |
3 | module Git
4 | module_function
5 |
6 | def last_revision_commit_of_file(repo, file, before_commit: nil)
7 | args = [before_commit.nil? ? "--skip=1" : before_commit.split("..").first]
8 |
9 | out, = Open3.capture3(
10 | HOMEBREW_SHIMS_PATH/"scm/git", "-C", repo,
11 | "log", "--format=%h", "--abbrev=7", "--max-count=1",
12 | *args, "--", file
13 | )
14 | out.chomp
15 | end
16 |
17 | def last_revision_of_file(repo, file, before_commit: nil)
18 | relative_file = Pathname(file).relative_path_from(repo)
19 |
20 | commit_hash = last_revision_commit_of_file(repo, relative_file, before_commit: before_commit)
21 | out, = Open3.capture3(
22 | HOMEBREW_SHIMS_PATH/"scm/git", "-C", repo,
23 | "show", "#{commit_hash}:#{relative_file}"
24 | )
25 | out
26 | end
27 | end
28 |
29 | module Utils
30 | def self.git_available?
31 | @git_available ||= quiet_system HOMEBREW_SHIMS_PATH/"scm/git", "--version"
32 | end
33 |
34 | def self.git_path
35 | return unless git_available?
36 | @git_path ||= Utils.popen_read(
37 | HOMEBREW_SHIMS_PATH/"scm/git", "--homebrew=print-path"
38 | ).chuzzle
39 | end
40 |
41 | def self.git_version
42 | return unless git_available?
43 | @git_version ||= Utils.popen_read(
44 | HOMEBREW_SHIMS_PATH/"scm/git", "--version"
45 | ).chomp[/git version (\d+(?:\.\d+)*)/, 1]
46 | end
47 |
48 | def self.ensure_git_installed!
49 | return if git_available?
50 |
51 | # we cannot install brewed git if homebrew/core is unavailable.
52 | if CoreTap.instance.installed?
53 | begin
54 | oh1 "Installing #{Formatter.identifier("git")}"
55 | safe_system HOMEBREW_BREW_FILE, "install", "git"
56 | rescue
57 | raise "Git is unavailable"
58 | end
59 | end
60 |
61 | raise "Git is unavailable" unless git_available?
62 | end
63 |
64 | def self.clear_git_available_cache
65 | @git_available = nil
66 | @git_path = nil
67 | @git_version = nil
68 | end
69 |
70 | def self.git_remote_exists?(url)
71 | return true unless git_available?
72 | quiet_system "git", "ls-remote", url
73 | end
74 | end
--------------------------------------------------------------------------------
/lib/cache.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | const bool analyze = false;
4 |
5 | // Mixin allowing to cache Futures
6 | class FutureCacheMixin {
7 | final Map _futureCache = {};
8 |
9 | /// Caches futures
10 | /// [key] cacheKey
11 | Future cache(Future Function() futureProvider, String key) async {
12 | final cached = _futureCache[key] as Future?;
13 | Future future;
14 | if (cached != null) {
15 | future = cached;
16 | } else {
17 | future = futureProvider();
18 | _futureCache[key] = future;
19 | }
20 |
21 | if (analyze) {
22 | if (cached == null) {
23 | print(" > $key");
24 | final start = DateTime.now();
25 | final result = await future;
26 | final diff = DateTime.now().difference(start);
27 | print("${diff.inMilliseconds.toString().padLeft(4)}ms < $key");
28 | return result;
29 | } else {
30 | print(" - $key");
31 | }
32 | }
33 |
34 | return future;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/lib/cli_app.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io' as io;
3 |
4 | import 'package:args/args.dart';
5 | import 'package:git_revision/git_revision.dart';
6 |
7 | part 'cli_app.g.dart';
8 |
9 | class CliApp {
10 | final CliLogger logger;
11 |
12 | // manual injection of the [GitVersioner]
13 | final GitVersioner? Function(GitVersionerConfig config) versionerProvider;
14 |
15 | CliApp(this.logger, this.versionerProvider);
16 |
17 | CliApp.production([CliLogger logger = const CliLogger()]) : this(logger, (config) => GitVersioner(config));
18 |
19 | Future process(List args) async {
20 | final cliArgs = parseCliArgs(args);
21 |
22 | if (cliArgs.showHelp) {
23 | showUsage();
24 | return;
25 | }
26 |
27 | if (cliArgs.showVersion) {
28 | showVersion();
29 | return;
30 | }
31 |
32 | final versioner = versionerProvider(cliArgs.toConfig())!;
33 |
34 | if (cliArgs.fullOutput) {
35 | logger.stdOut(
36 | trimLines(
37 | '''
38 | versionCode: ${await versioner.revision}
39 | versionName: ${await versioner.versionName}
40 | baseBranch: ${await versioner.baseBranch}
41 | currentBranch: ${await versioner.headBranchName}
42 | sha1: ${await versioner.sha1}
43 | sha1Short: ${(await versioner.sha1)?.substring(0, 7)}
44 | completeFirstOnlyBaseBranchCommitCount: ${(await versioner.allFirstBaseBranchCommits).length}
45 | baseBranchCommitCount: ${(await versioner.baseBranchCommits).length}
46 | baseBranchTimeComponent: ${await versioner.baseBranchTimeComponent}
47 | featureBranchCommitCount: ${(await versioner.featureBranchCommits).length}
48 | featureBranchTimeComponent: ${await versioner.featureBranchTimeComponent}
49 | featureOrigin: ${(await versioner.featureBranchOrigin)?.sha1}
50 | yearFactor: ${versioner.config.yearFactor}
51 | localChanges: ${await versioner.localChanges}
52 | ''',
53 | ),
54 | );
55 | } else {
56 | // default output
57 | final revision = await versioner.versionName;
58 | logger.stdOut(revision);
59 | }
60 | }
61 |
62 | static final _cliArgParser = ArgParser()
63 | ..addFlag('help', abbr: 'h', negatable: false, help: 'Print this usage information.')
64 | ..addFlag('version', abbr: 'v', help: 'Shows the version information of git revision', negatable: false)
65 | ..addOption(
66 | 'context',
67 | abbr: 'C',
68 | help: ' Run as if git was started in instead of the current working directory',
69 | )
70 | ..addOption(
71 | 'baseBranch',
72 | abbr: 'b',
73 | help:
74 | 'The base branch where most of the development happens, (defaults to master, or main). Often what is set as baseBranch in github. Only on the baseBranch the revision can become only digits.',
75 | )
76 | ..addOption(
77 | 'yearFactor',
78 | abbr: 'y',
79 | help: 'revision increment count per year',
80 | defaultsTo: GitVersioner.defaultYearFactor.toString(),
81 | )
82 | ..addOption(
83 | 'stopDebounce',
84 | abbr: 'd',
85 | defaultsTo: GitVersioner.defaultStopDebounce.toString(),
86 | help: 'time between two commits '
87 | 'which are further apart than this stopDebounce (in hours) will not be included into the timeComponent. '
88 | 'A project on hold for a few months will therefore not increase the revision drastically when development '
89 | 'starts again.',
90 | )
91 | ..addOption(
92 | 'name',
93 | abbr: 'n',
94 | help: "a human readable name and identifier of a revision ('73_+21_996321c'). "
95 | "Can be anything which gives the revision more meaning i.e. the number of the PullRequest when building on CI. "
96 | "Allowed characters: [a-zA-Z0-9_-/] any letter, digits, underscore, dash and slash. Invalid characters will be removed.",
97 | )
98 | ..addFlag(
99 | 'full',
100 | help: 'shows full information about the current revision and extracted information',
101 | negatable: false,
102 | );
103 |
104 | static GitRevisionCliArgs parseCliArgs(List args) {
105 | final ArgResults argResults = _cliArgParser.parse(args);
106 |
107 | final parsedCliArgs = GitRevisionCliArgs();
108 |
109 | parsedCliArgs.showHelp = argResults['help'] as bool;
110 | parsedCliArgs.showVersion = argResults['version'] as bool;
111 | parsedCliArgs.fullOutput = argResults['full'] as bool;
112 | parsedCliArgs.repoPath = argResults['context'] as String?;
113 | parsedCliArgs.baseBranch = argResults['baseBranch'] as String?;
114 | parsedCliArgs.yearFactor = intArg(argResults, 'yearFactor');
115 | parsedCliArgs.stopDebounce = intArg(argResults, 'stopDebounce');
116 | if (argResults.rest.length == 1) {
117 | final rest = argResults.rest[0];
118 | if (rest.isNotEmpty) {
119 | parsedCliArgs.revision = rest;
120 | }
121 | } else if (argResults.rest.length > 1) {
122 | throw ArgError('expected only one revision argument, found ${argResults.rest.length}: ${argResults.rest}');
123 | }
124 |
125 | final String? rawName = argResults['name'] as String?;
126 | if (rawName != null) {
127 | String safeName = rawName.replaceAll(RegExp(r'[^\w_\-\/]+'), '_').replaceAll('__', '_');
128 |
129 | // trim underscore at start and end
130 | if (safeName[0] == '_') {
131 | safeName = safeName.substring(1, safeName.length - 1);
132 | }
133 | if (safeName[safeName.length - 1] == '_') {
134 | safeName = safeName.substring(0, safeName.length - 2);
135 | }
136 |
137 | if (safeName.isNotEmpty && safeName != '_') {
138 | parsedCliArgs.name = safeName;
139 | }
140 | }
141 |
142 | return parsedCliArgs;
143 | }
144 |
145 | static int intArg(ArgResults args, String name) {
146 | final raw = (args[name] as String?)?.trim();
147 | try {
148 | return int.parse(raw!);
149 | } on FormatException {
150 | throw ArgError("$name is not a integer '$raw'");
151 | }
152 | }
153 |
154 | void showUsage() {
155 | logger.stdOut("git revision creates a useful revision for your project beyond 'git describe'");
156 | logger.stdOut(_cliArgParser.usage);
157 | }
158 |
159 | void showVersion() {
160 | logger.stdOut("Version $versionName");
161 | }
162 | }
163 |
164 | String trimLines(String text) => text.split('\n').map((line) => line.trimLeft()).join('\n').trim();
165 |
166 | class GitRevisionCliArgs {
167 | bool showHelp = false;
168 | bool showVersion = false;
169 |
170 | String? repoPath;
171 | String revision = 'HEAD';
172 | String? name;
173 | String? baseBranch;
174 | int yearFactor = GitVersioner.defaultYearFactor;
175 | int stopDebounce = GitVersioner.defaultStopDebounce;
176 |
177 | bool fullOutput = false;
178 |
179 | @override
180 | String toString() =>
181 | 'GitRevisionCliArgs{helpFlag: $showHelp, versionFlag: $showVersion, baseBranch: $baseBranch, repoPath: $repoPath, yearFactor: $yearFactor, stopDebounce: $stopDebounce}';
182 |
183 | GitVersionerConfig toConfig() => GitVersionerConfig(baseBranch, repoPath, yearFactor, stopDebounce, name, revision);
184 |
185 | @override
186 | bool operator ==(Object other) =>
187 | identical(this, other) ||
188 | other is GitRevisionCliArgs &&
189 | runtimeType == other.runtimeType &&
190 | showHelp == other.showHelp &&
191 | showVersion == other.showVersion &&
192 | repoPath == other.repoPath &&
193 | revision == other.revision &&
194 | name == other.name &&
195 | baseBranch == other.baseBranch &&
196 | yearFactor == other.yearFactor &&
197 | stopDebounce == other.stopDebounce &&
198 | fullOutput == other.fullOutput;
199 |
200 | @override
201 | int get hashCode =>
202 | showHelp.hashCode ^
203 | showVersion.hashCode ^
204 | repoPath.hashCode ^
205 | revision.hashCode ^
206 | name.hashCode ^
207 | baseBranch.hashCode ^
208 | yearFactor.hashCode ^
209 | stopDebounce.hashCode ^
210 | fullOutput.hashCode;
211 | }
212 |
213 | // TODO move out of implementation
214 | class CliLogger {
215 | const CliLogger();
216 |
217 | void stdOut(String s) => io.stdout.writeln(s);
218 |
219 | void stdErr(String s) => io.stderr.writeln(s);
220 | }
221 |
222 | class ArgError implements Exception {
223 | final String message;
224 |
225 | ArgError(this.message);
226 |
227 | @override
228 | String toString() => message;
229 | }
230 |
--------------------------------------------------------------------------------
/lib/cli_app.g.dart:
--------------------------------------------------------------------------------
1 | // GENERATED CODE - DO NOT MODIFY BY HAND
2 |
3 | part of 'cli_app.dart';
4 |
5 | // **************************************************************************
6 | // BuildConfig
7 | // **************************************************************************
8 |
9 | const String versionName = '0.7.1';
10 |
--------------------------------------------------------------------------------
/lib/git/commit.dart:
--------------------------------------------------------------------------------
1 | class Commit {
2 | Commit(this.sha1, this.rawDate);
3 |
4 | String sha1;
5 | String rawDate;
6 | DateTime? parsedDate;
7 |
8 | DateTime get date {
9 | return parsedDate ??= DateTime.fromMillisecondsSinceEpoch(int.parse(rawDate) * 1000);
10 | }
11 |
12 | @override
13 | String toString() {
14 | return 'Commit{sha1: ${sha1.substring(0, 7)}, date: $rawDate';
15 | }
16 |
17 | @override
18 | bool operator ==(Object other) =>
19 | identical(this, other) || other is Commit && runtimeType == other.runtimeType && sha1 == other.sha1;
20 |
21 | @override
22 | int get hashCode => sha1.hashCode;
23 | }
24 |
--------------------------------------------------------------------------------
/lib/git/git_client.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io';
3 |
4 | import 'package:git_revision/cache.dart';
5 | import 'package:git_revision/git/commit.dart';
6 | import 'package:git_revision/git/local_changes.dart';
7 |
8 | /// Access `git` via CLI to gather information about the repo
9 | class GitClient {
10 | factory GitClient(String workingDir) => _GitClientCache(workingDir);
11 |
12 | GitClient._(this.workingDir);
13 |
14 | String workingDir;
15 |
16 | Future> revList(String revision, {bool firstParentOnly = false}) async {
17 | // use commit date not author date. commit date is the one between the prev and next commit. Author date could be anything
18 | final String? result = await _git(
19 | 'rev-list --pretty=%ct%n${firstParentOnly ? ' --first-parent' : ''} $revision',
20 | emptyResultIsError: false,
21 | );
22 | if (result == null) return [];
23 |
24 | return result.split('\n\n').where((c) => c.isNotEmpty).map((rawCommit) {
25 | final lines = rawCommit.split('\n');
26 | return Commit(lines[0].replaceFirst('commit ', ''), lines[1]);
27 | }).toList(growable: false);
28 | }
29 |
30 | /// full Sha1 or `null`
31 | Future sha1(String revision) async {
32 | final hash = await _git('rev-parse $revision', emptyResultIsError: false);
33 | if (hash == null) {
34 | return null;
35 | }
36 | assert(
37 | () {
38 | if (hash.isEmpty) throw ArgumentError("sha1 is empty ''");
39 | if (hash.split('\n').length != 1) throw ArgumentError("sha1 is multiline '$hash'");
40 | return true;
41 | }(),
42 | );
43 |
44 | return hash;
45 | }
46 |
47 | /// branch name of `HEAD` or `null`
48 | Future get headBranchName async {
49 | final name = await _git('symbolic-ref --short -q HEAD', emptyResultIsError: false);
50 | if (name == null) return null;
51 |
52 | // empty branch names can't exits this means no branch name
53 | if (name.isEmpty) return null;
54 |
55 | assert(
56 | () {
57 | if (name.split('\n').length != 1) throw ArgumentError("branch name is multiline '$name'");
58 | return true;
59 | }(),
60 | );
61 | return name;
62 | }
63 |
64 | Future localChanges(String revision) async {
65 | // TODO move this check outside of GitClient
66 | if (revision != 'HEAD') {
67 | // local changes are only interesting for HEAD during active development
68 | return LocalChanges.none;
69 | }
70 |
71 | final changes = await _git('diff --shortstat HEAD', emptyResultIsError: false);
72 | if (changes == null) return null;
73 | return _parseDiffShortStat(changes);
74 | }
75 |
76 | /// returns a Stream of branchNames with prepended remotes where [branchName] exists
77 | ///
78 | /// `git branch --all --list "*$rev"`
79 | Stream branchLocalOrRemote(String branchName) async* {
80 | final String? text = await _git("branch --all --list *$branchName", emptyResultIsError: false);
81 | if (text == null || text.isEmpty) {
82 | return;
83 | }
84 | final branches = text
85 | .split('\n')
86 | // remove asterisk marking the current branch
87 | .map((it) => it.replaceFirst("* ", ""))
88 | .map((it) => it.trim());
89 |
90 | for (final branch in branches) {
91 | yield branch;
92 | }
93 | }
94 |
95 | Future _git(String args, {bool emptyResultIsError = true}) async {
96 | final argList = args.split(' ');
97 |
98 | final processResult = await Process.run('git', argList, workingDirectory: workingDir);
99 | if (processResult.exitCode != 0) {
100 | return null;
101 | }
102 | var text = processResult.stdout as String?;
103 | text = text?.trim();
104 | if (emptyResultIsError) {
105 | if (text == null || text.isEmpty) {
106 | throw ProcessException('git', argList, "returned nothing");
107 | }
108 | }
109 | return text;
110 | }
111 | }
112 |
113 | /// parses the output of `git diff --shortstat`
114 | /// https://github.com/git/git/blob/69e6b9b4f4a91ce90f2c38ed2fa89686f8aff44f/diff.c#L1561
115 | LocalChanges _parseDiffShortStat(String text) {
116 | if (text.isEmpty) return LocalChanges.none;
117 | final parts = text.split(",").map((it) => it.trim());
118 |
119 | var filesChanges = 0;
120 | var additions = 0;
121 | var deletions = 0;
122 |
123 | for (final part in parts) {
124 | if (part.contains("changed")) {
125 | filesChanges = _startingNumber(part) ?? 0;
126 | }
127 | if (part.contains("(+)")) {
128 | additions = _startingNumber(part) ?? 0;
129 | }
130 | if (part.contains("(-)")) {
131 | deletions = _startingNumber(part) ?? 0;
132 | }
133 | }
134 | return LocalChanges(filesChanges, additions, deletions);
135 | }
136 |
137 | final _numberRegEx = RegExp("(\\d+).*");
138 |
139 | /// returns the int of a string it starts with
140 | int? _startingNumber(String text) {
141 | final match = _numberRegEx.firstMatch(text);
142 | if (match != null && match.groupCount >= 1) {
143 | return int.parse(match.group(1)!);
144 | }
145 | return null;
146 | }
147 |
148 | /// Caching layer wrapping the original [GitClient]
149 | class _GitClientCache extends GitClient with FutureCacheMixin {
150 | _GitClientCache(String workingDir) : super._(workingDir);
151 |
152 | @override
153 | Future localChanges(String revision) =>
154 | cache(() => super.localChanges(revision), 'localChanges($revision)');
155 |
156 | @override
157 | Future get headBranchName => cache(() => super.headBranchName, 'headBranchName');
158 |
159 | @override
160 | Future sha1(String revision) => cache(() => super.sha1(revision), 'sha1($revision)');
161 |
162 | @override
163 | Future> revList(String revision, {bool firstParentOnly = false}) {
164 | final name = 'revList($revision, firstParentOnly=$firstParentOnly)';
165 | return cache(() => super.revList(revision, firstParentOnly: firstParentOnly), name);
166 | }
167 |
168 | @override
169 | Future _git(String args, {bool emptyResultIsError = true}) {
170 | final name = 'git $args -- $emptyResultIsError';
171 | return cache(() => super._git(args, emptyResultIsError: emptyResultIsError), name);
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/lib/git/local_changes.dart:
--------------------------------------------------------------------------------
1 | class LocalChanges {
2 | final int filesChanged;
3 | final int additions;
4 | final int deletions;
5 |
6 | static LocalChanges none = const LocalChanges(0, 0, 0);
7 |
8 | const LocalChanges(this.filesChanged, this.additions, this.deletions)
9 | : assert(filesChanged >= 0),
10 | assert(additions >= 0),
11 | assert(deletions >= 0);
12 |
13 | @override
14 | String toString() => '$filesChanged +$additions -$deletions';
15 |
16 | String shortStats() {
17 | if (filesChanged + additions + deletions == 0) {
18 | return 'no changes';
19 | } else {
20 | return 'files changed: $filesChanged, additions(+): $additions, deletions(-): $deletions';
21 | }
22 | }
23 |
24 | @override
25 | bool operator ==(Object other) =>
26 | identical(this, other) ||
27 | other is LocalChanges &&
28 | runtimeType == other.runtimeType &&
29 | filesChanged == other.filesChanged &&
30 | additions == other.additions &&
31 | deletions == other.deletions;
32 |
33 | @override
34 | int get hashCode => filesChanged.hashCode ^ additions.hashCode ^ deletions.hashCode;
35 | }
36 |
--------------------------------------------------------------------------------
/lib/git_revision.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io';
3 |
4 | import 'package:git_revision/cache.dart';
5 | import 'package:git_revision/git/commit.dart';
6 | import 'package:git_revision/git/git_client.dart';
7 | import 'package:git_revision/git/local_changes.dart';
8 |
9 | Duration _year = const Duration(days: 365);
10 |
11 | class GitVersioner {
12 | static int defaultYearFactor = 1000;
13 | static int defaultStopDebounce = 48;
14 |
15 | final GitVersionerConfig config;
16 | final GitClient gitClient;
17 |
18 | /// always returns a version which automatically caches
19 | factory GitVersioner(GitVersionerConfig config) {
20 | final versioner = GitVersioner._(config, GitClient(config.repoPath ?? Directory.current.path));
21 | return _CachedGitVersioner(versioner);
22 | }
23 |
24 | GitVersioner._(this.config, this.gitClient);
25 |
26 | Future get revision async {
27 | final commits = await baseBranchCommits;
28 | final timeComponent = await baseBranchTimeComponent;
29 | return commits.length + timeComponent;
30 | }
31 |
32 | // TODO swap name with revision
33 | Future get versionName async {
34 | final rev = await revision;
35 | final hash = (await gitClient.sha1(config.rev))?.substring(0, 7) ?? "0000000";
36 | final additionalCommits = await featureBranchCommits;
37 |
38 | final _baseBranch = await baseBranch;
39 | if (config.rev == 'HEAD') {
40 | final branch = await gitClient.headBranchName;
41 | final changes = await gitClient.localChanges(config.rev);
42 |
43 | String name = '';
44 | if (branch != null && branch != _baseBranch) {
45 | name = "_$branch";
46 | }
47 | if (config.name != null && config.name != _baseBranch) {
48 | name = "_${config.name}";
49 | }
50 | final furtherPart = additionalCommits.isNotEmpty ? "+${additionalCommits.length}" : '';
51 | final dirtyPart = (changes == LocalChanges.none) ? '' : '-dirty';
52 |
53 | return "$rev$name${furtherPart}_$hash$dirtyPart";
54 | } else {
55 | final furtherPart = additionalCommits.isNotEmpty ? "+${additionalCommits.length}" : '';
56 | String name = '';
57 |
58 | if (!hash.startsWith(config.rev) && config.rev != _baseBranch) {
59 | name = "_${config.rev}";
60 | }
61 | if (config.name != null && config.name != _baseBranch) {
62 | name = "_${config.name}";
63 | }
64 |
65 | return "$rev$name${furtherPart}_$hash";
66 | }
67 | }
68 |
69 | String? _baseBranch;
70 | Future get baseBranch async {
71 | if (_baseBranch != null) {
72 | return _baseBranch!;
73 | }
74 | return _baseBranch = config.baseBranch ??
75 | await () async {
76 | if (await gitClient.branchLocalOrRemote('master').firstOrNull().catchError((_) => null) != null) {
77 | return 'master';
78 | }
79 | if (await gitClient.branchLocalOrRemote('main').firstOrNull().catchError((_) => null) != null) {
80 | return 'main';
81 | }
82 |
83 | // default to main even it it doesn't exist
84 | return 'master';
85 | }();
86 | }
87 |
88 | /// All first-parent commits in baseBranch
89 | ///
90 | /// Most often a subset of [firstHeadBranchCommits]
91 | Future> get allFirstBaseBranchCommits async {
92 | try {
93 | final _baseBranch = await baseBranch;
94 | final base = await gitClient.branchLocalOrRemote(_baseBranch).first;
95 | final commits = await gitClient.revList(base, firstParentOnly: true);
96 | return commits;
97 | } catch (ex) {
98 | return [];
99 | }
100 | }
101 |
102 | /// branch name of `HEAD` or `null`
103 | Future get headBranchName => gitClient.headBranchName;
104 |
105 | /// full Sha1 or `null`
106 | Future get sha1 => gitClient.sha1(config.rev);
107 |
108 | Future get localChanges => gitClient.localChanges(config.rev);
109 |
110 | /// All commits in history of [GitVersionerConfig.rev]
111 | Future> get commits => gitClient.revList(config.rev);
112 |
113 | /// Commit where the featureBranch branched off the baseBranch or the first commit in history in case of an
114 | /// unrelated history
115 | Future get featureBranchOrigin async {
116 | final firstBaseCommits = await allFirstBaseBranchCommits;
117 | final allheadCommits = await commits;
118 |
119 | try {
120 | return allheadCommits.firstWhere((c) => firstBaseCommits.contains(c));
121 | } catch (ex) {
122 | return null;
123 | }
124 | }
125 |
126 | /// All commits in baseBranch which are also in history of [GitVersionerConfig.rev]
127 | ///
128 | /// ignores when current branch is merged into baseBranch in the future. Starts from this commit, first finds
129 | /// where this branch was branched off the base branch and counts the baseBranch commits from there
130 | Future> get baseBranchCommits => featureBranchOrigin.then((origin) {
131 | if (origin == null) return [];
132 | return gitClient.revList(origin.sha1);
133 | });
134 |
135 | /// All commits since [GitVersionerConfig.rev] branched off the base branch
136 | ///
137 | /// This are the commits which are added to this branch which are not yet merged into baseBranch at this point.
138 | /// They may be merged already in the future history which will be ignored here
139 | Future> get featureBranchCommits async {
140 | final origin = await featureBranchOrigin;
141 | if (origin != null) {
142 | return gitClient.revList('${config.rev}...${origin.sha1}');
143 | } else {
144 | // in case of unrelated histories use all commit in history
145 | return commits;
146 | }
147 | }
148 |
149 | Future get baseBranchTimeComponent => baseBranchCommits.then(_timeComponent);
150 |
151 | Future get featureBranchTimeComponent => featureBranchCommits.then(_timeComponent);
152 |
153 | int _timeComponent(List commits) {
154 | if (commits.isEmpty) return 0;
155 |
156 | final completeTime = commits.last.date.difference(commits.first.date).abs();
157 | if (completeTime == Duration.zero) return 0;
158 |
159 | // find gaps
160 | var gaps = Duration.zero;
161 | for (var i = 1; i < commits.length; i++) {
162 | final prev = commits[i];
163 | // rev-list comes in reversed order
164 | final next = commits[i - 1];
165 | final diff = next.date.difference(prev.date).abs();
166 | if (diff.inHours >= config.stopDebounce) {
167 | gaps += diff;
168 | }
169 | }
170 |
171 | // remove huge gaps where no work happened
172 | final workingTime = completeTime - gaps;
173 | final timeComponent = _yearFactor(workingTime);
174 |
175 | return timeComponent;
176 | }
177 |
178 | int _yearFactor(Duration duration) => (duration.inSeconds * config.yearFactor / _year.inSeconds + 0.5).toInt();
179 | }
180 |
181 | class GitVersionerConfig {
182 | String? baseBranch;
183 | String? repoPath;
184 | int yearFactor;
185 | int stopDebounce;
186 | String? name;
187 |
188 | /// The revision for which the version should be calculated
189 | String rev;
190 |
191 | GitVersionerConfig(this.baseBranch, this.repoPath, this.yearFactor, this.stopDebounce, this.name, this.rev)
192 | : assert(yearFactor >= 0),
193 | assert(stopDebounce >= 0),
194 | assert(rev.isNotEmpty);
195 |
196 | @override
197 | bool operator ==(Object other) =>
198 | identical(this, other) ||
199 | other is GitVersionerConfig &&
200 | runtimeType == other.runtimeType &&
201 | baseBranch == other.baseBranch &&
202 | repoPath == other.repoPath &&
203 | yearFactor == other.yearFactor &&
204 | stopDebounce == other.stopDebounce &&
205 | name == other.name &&
206 | rev == other.rev;
207 |
208 | @override
209 | int get hashCode =>
210 | baseBranch.hashCode ^
211 | repoPath.hashCode ^
212 | yearFactor.hashCode ^
213 | stopDebounce.hashCode ^
214 | name.hashCode ^
215 | rev.hashCode;
216 | }
217 |
218 | /// Caching layer for [GitVersioner]. Caches all futures which never produce a different result (if git repo doesn't change)
219 | class _CachedGitVersioner extends GitVersioner with FutureCacheMixin {
220 | final GitVersioner _delegate;
221 |
222 | _CachedGitVersioner(GitVersioner delegate)
223 | : _delegate = delegate,
224 | super._(delegate.config, delegate.gitClient);
225 |
226 | @override
227 | Future get revision => cache(() => _delegate.revision, 'revision');
228 |
229 | @override
230 | Future get featureBranchTimeComponent =>
231 | cache(() => _delegate.featureBranchTimeComponent, 'featureBranchTimeComponent');
232 |
233 | @override
234 | Future get baseBranchTimeComponent => cache(() => _delegate.baseBranchTimeComponent, 'baseBranchTimeComponent');
235 |
236 | @override
237 | Future> get allFirstBaseBranchCommits =>
238 | cache(() => _delegate.allFirstBaseBranchCommits, 'allFirstBaseBranchCommits');
239 |
240 | @override
241 | Future> get featureBranchCommits => cache(() => _delegate.featureBranchCommits, 'featureBranchCommits');
242 |
243 | @override
244 | Future> get baseBranchCommits =>
245 | cache>(() => _delegate.baseBranchCommits, 'baseBranchCommits');
246 |
247 | @override
248 | Future get sha1 => cache(() => _delegate.sha1, 'sha1');
249 |
250 | @override
251 | Future get headBranchName => cache(() => _delegate.headBranchName, 'headBranchName');
252 |
253 | @override
254 | Future get versionName => cache(() => _delegate.versionName, 'versionName');
255 |
256 | @override
257 | Future get localChanges => cache(() => _delegate.localChanges, 'localChanges');
258 |
259 | @override
260 | Future> get commits => cache(() => _delegate.commits, 'commits');
261 |
262 | @override
263 | Future get featureBranchOrigin => cache(() => _delegate.featureBranchOrigin, 'featureBranchOrigin');
264 | }
265 |
266 | extension _StreamFirstOrNull on Stream {
267 | Future firstOrNull() async {
268 | try {
269 | return first;
270 | } catch (_) {
271 | return null;
272 | }
273 | }
274 | }
275 |
--------------------------------------------------------------------------------
/package/git-revision.bat:
--------------------------------------------------------------------------------
1 | set SCRIPTPATH=%~dp0
2 | set arguments=%*
3 | "%SCRIPTPATH%\src\dart.exe" "%SCRIPTPATH%\src\git_revision.dart.snapshot" %arguments%
--------------------------------------------------------------------------------
/package/git-revision.sh:
--------------------------------------------------------------------------------
1 | # Attempt to set APP_HOME
2 | # Resolve links: $0 may be a link
3 | PRG="$0"
4 | # Need this for relative symlinks.
5 | while [ -h "$PRG" ] ; do
6 | ls=`ls -ld "$PRG"`
7 | link=`expr "$ls" : '.*-> \(.*\)$'`
8 | if expr "$link" : '/.*' > /dev/null; then
9 | PRG="$link"
10 | else
11 | PRG=`dirname "$PRG"`"/$link"
12 | fi
13 | done
14 | SAVED="`pwd`"
15 | cd "`dirname \"$PRG\"`/" >/dev/null
16 | APP_HOME="`pwd -P`"
17 | cd "$SAVED" >/dev/null
18 |
19 | DART="$APP_HOME/src/dart"
20 | SNAPSHOT="$APP_HOME/src/git_revision.dart.snapshot"
21 |
22 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
23 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
24 | cd "$(dirname "$0")"
25 | fi
26 |
27 | set -e
28 | "$DART" "$SNAPSHOT" "$@"
--------------------------------------------------------------------------------
/pubspec.yaml:
--------------------------------------------------------------------------------
1 | name: git_revision
2 | version: 0.7.1
3 | description: Git extension to generate a meaningful, human-readable revision for each commit in a git repository.
4 | repository: https://github.com/passsy/git-revision
5 |
6 | environment:
7 | sdk: '>=2.15.0 <3.0.0'
8 |
9 | dependencies:
10 | args: ^2.3.0
11 |
12 | dev_dependencies:
13 | archive: ^3.0.0
14 | collection: ^1.15.0
15 | dart_style: ^2.2.0
16 | http: ^0.13.4
17 | lint: ^1.0.0
18 | mockito: ^5.0.0
19 | path: ^1.8.1
20 | pub_semver: ^2.1.0
21 | synchronized: ^3.0.0
22 | test: ^1.20.0
23 | yaml: ^3.1.0
24 |
25 | executables:
26 | # a valid dart filename doesn't contain a dash -> remapping
27 | git-revision: git_revision
28 |
--------------------------------------------------------------------------------
/test/README.md:
--------------------------------------------------------------------------------
1 | # git-revision tests
2 |
3 | ## Run All
4 |
5 | ```
6 | pub run test
7 |
8 | ```
9 |
10 | ## Structure
11 |
12 | ##### integration
13 |
14 | Creates repositories in a temp directory and uses real git to run test cases against
15 |
16 | ```
17 | pub run test test/integration
18 | ```
19 |
20 | ##### unit
21 |
22 | Unit tests which do not require git to be installed
23 |
24 | ```
25 | pub run test test/unit
26 | ```
27 |
28 |
--------------------------------------------------------------------------------
/test/integration/git_integration_test.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io' as io;
2 |
3 | import 'package:test/test.dart';
4 |
5 | import 'util/temp_git.dart';
6 |
7 | final DateTime initTime = DateTime.utc(2017, DateTime.january, 10);
8 |
9 | void main() {
10 | group('initialize', () {
11 | late TempGit git;
12 | setUp(() async {
13 | git = await makeTempGit();
14 | });
15 |
16 | test('no git', () async {
17 | await git.run(
18 | name: 'init commit',
19 | script: sh(
20 | """
21 | echo 'Hello World' > a.txt
22 | """,
23 | ),
24 | );
25 |
26 | final out = await git.revision(['--full']);
27 |
28 | expect(out, contains('versionCode: 0\n'));
29 | expect(out, contains('versionName: 0_0000000-dirty\n'));
30 | expect(out, contains('baseBranch: master\n'));
31 | expect(out, contains('currentBranch: null\n'));
32 | expect(out, contains('sha1: null\n'));
33 | expect(out, contains('sha1Short: null\n'));
34 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 0\n'));
35 | expect(out, contains('baseBranchCommitCount: 0\n'));
36 | expect(out, contains('featureBranchCommitCount: 0\n'));
37 | expect(out, contains('baseBranchTimeComponent: 0\n'));
38 | expect(out, contains('featureBranchCommitCount: 0\n'));
39 | expect(out, contains('featureBranchTimeComponent: 0\n'));
40 | expect(out, contains('featureOrigin: null\n'));
41 | expect(out, contains('yearFactor: 1000\n'));
42 | expect(out, contains('localChanges: null'));
43 | });
44 |
45 | test('no commmit', () async {
46 | await git.run(
47 | name: 'init commit',
48 | script: sh(
49 | """
50 | git init
51 | echo 'Hello World' > a.txt
52 | """,
53 | ),
54 | );
55 |
56 | final out = await git.revision(['--full']);
57 |
58 | expect(out, contains('versionCode: 0\n'));
59 | expect(out, contains('versionName: 0_0000000-dirty\n'));
60 | expect(out, contains('baseBranch: master\n'));
61 | expect(out, contains('currentBranch: master\n'));
62 | expect(out, contains('sha1: null\n'));
63 | expect(out, contains('sha1Short: null\n'));
64 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 0\n'));
65 | expect(out, contains('baseBranchCommitCount: 0\n'));
66 | expect(out, contains('featureBranchCommitCount: 0\n'));
67 | expect(out, contains('baseBranchTimeComponent: 0\n'));
68 | expect(out, contains('featureBranchCommitCount: 0\n'));
69 | expect(out, contains('featureBranchTimeComponent: 0\n'));
70 | expect(out, contains('featureOrigin: null\n'));
71 | expect(out, contains('yearFactor: 1000\n'));
72 | expect(out, contains('localChanges: null'));
73 | });
74 | });
75 |
76 | group('orphan branch', () {
77 | late TempGit git;
78 | setUp(() async {
79 | git = await makeTempGit();
80 | });
81 |
82 | test('first commit', () async {
83 | git.skipCleanup = true;
84 | print('cd ${git.repo.path} && fork');
85 | await git.run(
86 | name: 'init with 3 commits',
87 | script: sh(
88 | """
89 | git init
90 | echo 'Hello World' > a.txt
91 | git add a.txt
92 |
93 | ${commit("initial commit", initTime)}
94 |
95 | echo 'second commit' > a.txt
96 | ${commit("second commit", initTime.add(hour * 4))}
97 |
98 | echo 'third commit' > a.txt
99 | ${commit("third commit", initTime.add(day))}
100 | """,
101 | ),
102 | );
103 |
104 | await git.run(
105 | name: 'create orphan commit',
106 | script: sh(
107 | """
108 | git checkout --orphan another_root
109 | echo 'World Hello' > a.txt
110 | git add a.txt
111 |
112 | ${commit("orphan commit", initTime.add(day * 2))}
113 | """,
114 | ),
115 | );
116 |
117 | final out = await git.revision(['--full']);
118 |
119 | expect(out, contains('versionCode: 0\n'));
120 | expect(out, contains('versionName: 0_another_root+1_0084417\n'));
121 | expect(out, contains('baseBranch: master\n'));
122 | expect(out, contains('currentBranch: another_root\n'));
123 | expect(out, contains('sha1: 008441704a1f59649f6dbce4487f9a8747edf124\n'));
124 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 3\n'));
125 | expect(out, contains('baseBranchCommitCount: 0\n'));
126 | expect(out, contains('baseBranchTimeComponent: 0\n'));
127 | expect(out, contains('featureBranchCommitCount: 1\n'));
128 | expect(out, contains('featureBranchTimeComponent: 0\n'));
129 | expect(out, contains('featureOrigin: null\n'));
130 | expect(out, contains('yearFactor: 1000\n'));
131 | expect(out, contains('localChanges: 0 +0 -0'));
132 | });
133 |
134 | test('3 commits', () async {
135 | git.skipCleanup = true;
136 | print('cd ${git.repo.path} && fork');
137 | await git.run(
138 | name: 'init with 3 commits',
139 | script: sh(
140 | """
141 | git init
142 | echo 'Hello World' > a.txt
143 | git add a.txt
144 |
145 | ${commit("initial commit", initTime)}
146 |
147 | echo 'second commit' > a.txt
148 | ${commit("second commit", initTime.add(hour * 4))}
149 |
150 | echo 'third commit' > a.txt
151 | ${commit("third commit", initTime.add(day))}
152 | """,
153 | ),
154 | );
155 |
156 | await git.run(
157 | name: 'create orphan commit',
158 | script: sh(
159 | """
160 | git checkout --orphan another_root
161 | echo 'World Hello' > a.txt
162 | git add a.txt
163 |
164 | ${commit("orphan commit", initTime.add(day * 2))}
165 |
166 | echo 'second commit' > a.txt
167 | ${commit("second commit", initTime.add(day * 2 + hour * 4))}
168 |
169 | echo 'third commit' > a.txt
170 | ${commit("third commit", initTime.add(day * 3))}
171 | """,
172 | ),
173 | );
174 |
175 | final out = await git.revision(['--full']);
176 |
177 | expect(out, contains('versionCode: 0\n'));
178 | expect(out, contains('versionName: 0_another_root+3_f2d44aa\n'));
179 | expect(out, contains('baseBranch: master\n'));
180 | expect(out, contains('currentBranch: another_root\n'));
181 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 3\n'));
182 | expect(out, contains('baseBranchCommitCount: 0\n'));
183 | expect(out, contains('baseBranchTimeComponent: 0\n'));
184 | expect(out, contains('featureBranchCommitCount: 3\n'));
185 | expect(out, contains('featureBranchTimeComponent: 3\n'));
186 | expect(out, contains('featureOrigin: null\n'));
187 | expect(out, contains('yearFactor: 1000\n'));
188 | expect(out, contains('localChanges: 0 +0 -0'));
189 | });
190 | });
191 |
192 | group('master only', () {
193 | late TempGit git;
194 | setUp(() async {
195 | git = await makeTempGit();
196 | });
197 |
198 | test('first commmit', () async {
199 | await git.run(
200 | name: 'init commit',
201 | script: sh(
202 | """
203 | git init
204 | echo 'Hello World' > a.txt
205 | git add a.txt
206 |
207 | ${commit("initial commit", initTime)}
208 | """,
209 | ),
210 | );
211 |
212 | final out = await git.revision(['--full']);
213 |
214 | expect(out, contains('versionCode: 1\n'));
215 | expect(out, contains('versionName: 1_b5f9bcd\n'));
216 | expect(out, contains('baseBranch: master\n'));
217 | expect(out, contains('currentBranch: master\n'));
218 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 1\n'));
219 | expect(out, contains('baseBranchCommitCount: 1\n'));
220 | expect(out, contains('featureBranchCommitCount: 0\n'));
221 | expect(out, contains('baseBranchTimeComponent: 0\n'));
222 | expect(out, contains('featureBranchCommitCount: 0\n'));
223 | expect(out, contains('featureBranchTimeComponent: 0\n'));
224 | expect(out, contains('yearFactor: 1000'));
225 | });
226 |
227 | test('3 commits', () async {
228 | await git.run(
229 | name: 'init with 3 commits',
230 | script: sh(
231 | """
232 | git init
233 | echo 'Hello World' > a.txt
234 | git add a.txt
235 |
236 | ${commit("initial commit", initTime)}
237 |
238 | echo 'second commit' > a.txt
239 | ${commit("second commit", initTime.add(hour * 4))}
240 |
241 | echo 'third commit' > a.txt
242 | ${commit("third commit", initTime.add(day))}
243 | """,
244 | ),
245 | );
246 |
247 | final out = await git.revision(['--full']);
248 |
249 | expect(out, contains('versionCode: 6\n'));
250 | expect(out, contains('versionName: 6_f3aacda\n'));
251 | expect(out, contains('baseBranch: master\n'));
252 | expect(out, contains('currentBranch: master\n'));
253 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 3\n'));
254 | expect(out, contains('baseBranchCommitCount: 3\n'));
255 | expect(out, contains('baseBranchTimeComponent: 3\n'));
256 | expect(out, contains('featureBranchCommitCount: 0\n'));
257 | expect(out, contains('featureBranchTimeComponent: 0\n'));
258 | expect(out, contains('yearFactor: 1000'));
259 | });
260 |
261 | test("merge branch with old commits doesn't increase the revision of previous commits", () async {
262 | await git.run(
263 | name: 'init master branch',
264 | script: sh(
265 | """
266 | git init
267 | echo 'Hello World' > a.txt
268 | git add a.txt
269 | ${commit("initial commit", initTime)}
270 |
271 | echo 'second commit' > a.txt
272 | ${commit("second commit", initTime.add(hour * 6))}
273 | """,
274 | ),
275 | );
276 |
277 | // get current revision, should not change afterwards
278 | final out1 = await git.revision(['--full']);
279 | expect(out1, contains('versionCode: 3'));
280 |
281 | await git.run(
282 | name: 'merge feature B',
283 | script: sh(
284 | """
285 | # branch from initial commit
286 | git checkout HEAD^1
287 | git checkout -b 'featureB'
288 | echo 'implement feature B' > b.txt
289 | git add b.txt
290 | # Date is before the last commit on master
291 | ${commit("implement feature B", initTime.add(hour * 4))}
292 |
293 | git checkout featureB
294 | echo 'fix bug' > b.txt
295 | ${commit("fix bug", initTime.add(day))}
296 |
297 | git checkout master
298 | ${merge("featureB", initTime.add(day * 2 + (hour * 1)))}
299 | """,
300 | ),
301 | );
302 |
303 | // revision obviously increased after merge
304 | final out2 = await git.revision(['--full']);
305 | expect(out2, contains('baseBranchTimeComponent: 6\n'));
306 | expect(out2, contains('baseBranchCommitCount: 5\n'));
307 | expect(out2, contains('versionCode: 11\n'));
308 | expect(out2, contains('versionName: 11_99c52ee\n'));
309 |
310 | await git.run(
311 | name: 'go back to commit before merge',
312 | script: sh(
313 | """
314 | git checkout master
315 | git checkout HEAD^1
316 | """,
317 | ),
318 | );
319 |
320 | // same revision as before
321 | final out3 = await git.revision(['--full']);
322 | expect(out3, contains('versionCode: 3\n'));
323 | expect(out3, contains('versionName: 3_51f6726\n'));
324 | });
325 | });
326 |
327 | group('main only', () {
328 | late TempGit git;
329 | setUp(() async {
330 | git = await makeTempGit();
331 | });
332 |
333 | test('first commmit (main)', () async {
334 | await git.run(
335 | name: 'init commit',
336 | script: sh(
337 | """
338 | git init
339 |
340 | # switch to main
341 | git checkout -b main
342 |
343 | echo 'Hello World' > a.txt
344 | git add a.txt
345 |
346 | ${commit("initial commit", initTime)}
347 | """,
348 | ),
349 | );
350 |
351 | final out = await git.revision(['--full']);
352 |
353 | expect(out, contains('versionCode: 1\n'));
354 | expect(out, contains('versionName: 1_b5f9bcd\n'));
355 | expect(out, contains('baseBranch: main\n'));
356 | expect(out, contains('currentBranch: main\n'));
357 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 1\n'));
358 | expect(out, contains('baseBranchCommitCount: 1\n'));
359 | expect(out, contains('featureBranchCommitCount: 0\n'));
360 | expect(out, contains('baseBranchTimeComponent: 0\n'));
361 | expect(out, contains('featureBranchCommitCount: 0\n'));
362 | expect(out, contains('featureBranchTimeComponent: 0\n'));
363 | expect(out, contains('yearFactor: 1000'));
364 | });
365 |
366 | test('3 commits (main)', () async {
367 | await git.run(
368 | name: 'init with 3 commits',
369 | script: sh(
370 | """
371 | git init
372 |
373 | # switch to main
374 | git checkout -b main
375 |
376 | echo 'Hello World' > a.txt
377 | git add a.txt
378 |
379 | ${commit("initial commit", initTime)}
380 |
381 | echo 'second commit' > a.txt
382 | ${commit("second commit", initTime.add(hour * 4))}
383 |
384 | echo 'third commit' > a.txt
385 | ${commit("third commit", initTime.add(day))}
386 | """,
387 | ),
388 | );
389 |
390 | final out = await git.revision(['--full']);
391 |
392 | expect(out, contains('versionCode: 6\n'));
393 | expect(out, contains('versionName: 6_f3aacda\n'));
394 | expect(out, contains('baseBranch: main\n'));
395 | expect(out, contains('currentBranch: main\n'));
396 | expect(out, contains('completeFirstOnlyBaseBranchCommitCount: 3\n'));
397 | expect(out, contains('baseBranchCommitCount: 3\n'));
398 | expect(out, contains('baseBranchTimeComponent: 3\n'));
399 | expect(out, contains('featureBranchCommitCount: 0\n'));
400 | expect(out, contains('featureBranchTimeComponent: 0\n'));
401 | expect(out, contains('yearFactor: 1000'));
402 | });
403 |
404 | test("merge branch with old commits doesn't increase the revision of previous commits (main)", () async {
405 | await git.run(
406 | name: 'init master branch',
407 | script: sh(
408 | """
409 | git init
410 |
411 | # switch to main
412 | git checkout -b main
413 |
414 | echo 'Hello World' > a.txt
415 | git add a.txt
416 | ${commit("initial commit", initTime)}
417 |
418 | echo 'second commit' > a.txt
419 | ${commit("second commit", initTime.add(hour * 6))}
420 | """,
421 | ),
422 | );
423 |
424 | // get current revision, should not change afterwards
425 | final out1 = await git.revision(['--full']);
426 | expect(out1, contains('versionCode: 3'));
427 |
428 | await git.run(
429 | name: 'merge feature B',
430 | script: sh(
431 | """
432 | # branch from initial commit
433 | git checkout HEAD^1
434 | git checkout -b 'featureB'
435 | echo 'implement feature B' > b.txt
436 | git add b.txt
437 | # Date is before the last commit on main
438 | ${commit("implement feature B", initTime.add(hour * 4))}
439 |
440 | git checkout featureB
441 | echo 'fix bug' > b.txt
442 | ${commit("fix bug", initTime.add(day))}
443 |
444 | git checkout main
445 | ${merge("featureB", initTime.add(day * 2 + (hour * 1)))}
446 | """,
447 | ),
448 | );
449 |
450 | // revision obviously increased after merge
451 | final out2 = await git.revision(['--full']);
452 | expect(out2, contains('baseBranchTimeComponent: 6\n'));
453 | expect(out2, contains('baseBranchCommitCount: 5\n'));
454 | expect(out2, contains('versionCode: 11\n'));
455 | expect(out2, contains('versionName: 11_99c52ee\n'));
456 |
457 | await git.run(
458 | name: 'go back to commit before merge',
459 | script: sh(
460 | """
461 | git checkout main
462 | git checkout HEAD^1
463 | """,
464 | ),
465 | );
466 |
467 | // same revision as before
468 | final out3 = await git.revision(['--full']);
469 | expect(out3, contains('versionCode: 3\n'));
470 | expect(out3, contains('versionName: 3_51f6726\n'));
471 | });
472 | });
473 |
474 | group('feature branch', () {
475 | late TempGit git;
476 | setUp(() async {
477 | git = await makeTempGit();
478 | });
479 |
480 | test("no branch name - fallback to sha1", () async {
481 | await git.run(
482 | name: 'init master branch - work on featureB',
483 | script: sh(
484 | """
485 | git init
486 | echo 'Hello World' > a.txt
487 | git add a.txt
488 | ${commit("initial commit", initTime)}
489 |
490 | echo 'second commit' > a.txt
491 | ${commit("second commit", initTime.add(hour * 6))}
492 |
493 | # Work on featureB
494 | git checkout -b 'featureB'
495 | echo 'implement feature B' > b.txt
496 | git add b.txt
497 | # Date is before the last commit on master
498 | ${commit("implement feature B", initTime.add(hour * 4))}
499 |
500 | echo 'fix bug' > b.txt
501 | ${commit("fix bug", initTime.add(day))}
502 | """,
503 | ),
504 | );
505 |
506 | await git.run(
507 | name: 'delete feature branch and stay on detached head',
508 | script: sh(
509 | """
510 | git checkout master
511 | git branch -D featureB
512 | git checkout 331b280
513 | """,
514 | ),
515 | );
516 |
517 | final out = await git.revision(['--full']);
518 | expect(out, contains('versionName: 3+2_331b280\n'));
519 | });
520 |
521 | test("feature branch still has +commits after merge in master", () async {
522 | await git.run(
523 | name: 'init master branch - work on featureB',
524 | script: sh(
525 | """
526 | git init
527 | echo 'Hello World' > a.txt
528 | git add a.txt
529 | ${commit("initial commit", initTime)}
530 |
531 | echo 'second commit' > a.txt
532 | ${commit("second commit", initTime.add(hour * 6))}
533 |
534 | # Work on featureB
535 | git checkout -b 'featureB'
536 | echo 'implement feature B' > b.txt
537 | git add b.txt
538 | # Date is before the last commit on master
539 | ${commit("implement feature B", initTime.add(hour * 4))}
540 |
541 | echo 'fix bug' > b.txt
542 | ${commit("fix bug", initTime.add(day))}
543 | """,
544 | ),
545 | );
546 |
547 | final out = await git.revision(['--full']);
548 | expect(out, contains('versionName: 3_featureB+2_331b280\n'));
549 |
550 | await git.run(
551 | name: 'continue work on master and merge featureB',
552 | script: sh(
553 | """
554 | git checkout master
555 | echo 'third commit' > a.txt
556 | ${commit("third commit", initTime.add(day + (hour * 2)))}
557 |
558 | # Merge feature branch
559 | ${merge("featureB", initTime.add(day + (hour * 3)))}
560 |
561 | # Go back to feature branch
562 | git checkout featureB
563 | """,
564 | ),
565 | );
566 |
567 | // back on featureB the previous output should not change
568 | final out2 = await git.revision(['--full']);
569 | expect(out2, contains('versionName: 3_featureB+2_331b280\n'));
570 | });
571 |
572 | test("git flow - baseBranch=develop - merge develop -> master increases revision", () async {
573 | await git.run(
574 | name: 'init master branch - create develop',
575 | script: sh(
576 | """
577 | git init
578 | echo 'Hello World' > a.txt
579 | git add a.txt
580 | ${commit("initial commit", initTime)}
581 |
582 | echo 'second commit' > a.txt
583 | ${commit("second commit", initTime.add(hour * 4))}
584 |
585 | # Create develop branch
586 | git checkout -b 'develop'
587 | """,
588 | ),
589 | );
590 |
591 | await git.run(
592 | name: 'work on feature B',
593 | script: sh(
594 | """
595 | git checkout develop
596 | git checkout -b 'featureB'
597 | echo 'implement feature B' > b.txt
598 | git add b.txt
599 | # Date is before the last commit on master
600 | ${commit("implement feature B", initTime.add(hour * 6))}
601 |
602 | echo 'fix bug' > b.txt
603 | ${commit("fix bug", initTime.add(day))}
604 | """,
605 | ),
606 | );
607 |
608 | await git.run(
609 | name: 'work on feature C',
610 | script: sh(
611 | """
612 | git checkout develop
613 | git checkout -b 'featureC'
614 | echo 'implement feature C' > c.txt
615 | git add c.txt
616 | ${commit("implement feature C", initTime.add(day + (hour * 4)))}
617 |
618 | echo 'fix a bug' > c.txt
619 | ${commit("fix bug", initTime.add(day * 2))}
620 | """,
621 | ),
622 | );
623 |
624 | await git.run(
625 | name: 'work on feature D',
626 | script: sh(
627 | """
628 | git checkout develop
629 | git checkout -b 'featureD'
630 | echo 'implement feature D' > d.txt
631 | git add d.txt
632 | ${commit("implement feature C", initTime.add(day + (hour * 3)))}
633 |
634 | echo 'fix more bugs' > d.txt
635 | ${commit("fix bug", initTime.add(day + (hour * 5)))}
636 | """,
637 | ),
638 | );
639 |
640 | await git.run(
641 | name: 'merge C then B into develop and release to master',
642 | script: sh(
643 | """
644 | git checkout develop
645 | ${merge('featureC', initTime.add(day * 2 + (hour * 1)))}
646 | ${merge('featureB', initTime.add(day * 2 + (hour * 2)))}
647 |
648 | git checkout master
649 | ${merge("develop", initTime.add(day * 2 + (hour * 3)))}
650 | """,
651 | ),
652 | );
653 |
654 | await git.run(
655 | name: 'merge D into develop and release to master',
656 | script: sh(
657 | """
658 | git checkout develop
659 | ${merge("featureD", initTime.add(day * 2 + (hour * 4)))}
660 |
661 | git checkout master
662 | ${merge("develop", initTime.add(day * 2 + (hour * 5)))}
663 | """,
664 | ),
665 | );
666 |
667 | // master should be only +2 ahead which are the two merge commits (develop -> master)
668 | // master will always +1 ahead of develop even when merging (master -> develop)
669 | final out2 = await git.revision(['--full', '--baseBranch', 'develop']);
670 | expect(out2, contains('versionName: 17_master+2_a658ff8\n'));
671 | });
672 | });
673 |
674 | group('remote', () {
675 | late TempGit git;
676 | setUp(() async {
677 | git = await makeTempGit();
678 | });
679 |
680 | test("master only on remote", () async {
681 | final repo2 = await io.Directory("${git.root.path}${io.Platform.pathSeparator}remoteRepo").create();
682 | await git.run(
683 | name: 'init master branch',
684 | repo: repo2,
685 | script: sh(
686 | """
687 | git init
688 | echo 'Hello World' > a.txt
689 | git add a.txt
690 | ${commit("initial commit", initTime)}
691 |
692 | echo 'second commit' > a.txt
693 | ${commit("second commit", initTime.add(hour * 4))}
694 | """,
695 | ),
696 | );
697 |
698 | await git.run(
699 | name: 'clone and implement feature B',
700 | script: sh(
701 | """
702 | git clone ${repo2.path} .
703 | git checkout -b 'featureB'
704 | echo 'implement feature B' > b.txt
705 | git add b.txt
706 | # Date is before the last commit on master
707 | ${commit("implement feature B", initTime.add(hour * 6))}
708 |
709 | echo 'fix bug' > b.txt
710 | ${commit("fix bug", initTime.add(day))}
711 | """,
712 | ),
713 | );
714 |
715 | final out = await git.revision(['--full']);
716 | expect(out, contains('versionName: 2_featureB+2_d3e1844\n'));
717 |
718 | // now master branch is only available on remote
719 | await git.run(name: 'delete master branch', script: "git branch -d master");
720 |
721 | // output is unchanged
722 | final out2 = await git.revision(['--full']);
723 | expect(out2, contains('versionName: 2_featureB+2_d3e1844\n'));
724 | });
725 |
726 | test("master only on remote which is not called origin", () async {
727 | final repo2 = await io.Directory("${git.root.path}${io.Platform.pathSeparator}remoteRepo").create();
728 | await git.run(
729 | name: 'init master branch',
730 | repo: repo2,
731 | script: sh(
732 | """
733 | git init
734 | echo 'Hello World' > a.txt
735 | git add a.txt
736 | ${commit("initial commit", initTime)}
737 |
738 | echo 'second commit' > a.txt
739 | ${commit("second commit", initTime.add(hour * 4))}
740 | """,
741 | ),
742 | );
743 |
744 | await git.run(
745 | name: 'clone and implement feature B',
746 | script: sh(
747 | """
748 | git clone -o first ${repo2.path} .
749 | git checkout -b 'featureB'
750 | echo 'implement feature B' > b.txt
751 | git add b.txt
752 | ${commit("implement feature B", initTime.add(hour * 6))}
753 |
754 | echo 'fix bug' > b.txt
755 | ${commit("fix bug", initTime.add(day))}
756 | """,
757 | ),
758 | );
759 |
760 | final out = await git.revision(['--full']);
761 | expect(out, contains('versionName: 2_featureB+2_d3e1844\n'));
762 |
763 | // now master branch is only available on remote
764 | await git.run(name: 'delete master branch', script: "git branch -d master");
765 |
766 | // output is unchanged
767 | final out2 = await git.revision(['--full']);
768 | expect(out2, contains('versionName: 2_featureB+2_d3e1844\n'));
769 | });
770 |
771 | test("master only on one remote - multiple remotes", () async {
772 | final repo2 = await io.Directory("${git.root.path}${io.Platform.pathSeparator}remoteRepo").create();
773 | await git.run(
774 | name: 'init master branch',
775 | repo: repo2,
776 | script: sh(
777 | """
778 | git init
779 | echo 'Hello World' > a.txt
780 | git add a.txt
781 | ${commit("initial commit", initTime)}
782 |
783 | echo 'second commit' > a.txt
784 | ${commit("second commit", initTime.add(hour * 4))}
785 | """,
786 | ),
787 | );
788 |
789 | await git.run(
790 | name: 'add remotes and start working',
791 | script: sh(
792 | """
793 | git init
794 | git remote add first ${repo2.path}
795 | git remote add second ${repo2.path}
796 | git remote add third ${repo2.path}
797 |
798 | git remote add zcorrect ${repo2.path}
799 | git pull --no-commit zcorrect master
800 |
801 | git checkout -b 'featureB'
802 | echo 'implement feature B' > b.txt
803 | git add b.txt
804 | ${commit("implement feature B", initTime.add(hour * 6))}
805 | """,
806 | ),
807 | );
808 |
809 | final out = await git.revision(['--full']);
810 | expect(out, contains('versionName: 2_featureB+1_9006da1'));
811 |
812 | // now master branch is only available on remote
813 | await git.run(name: 'delete master branch', script: "git branch -d master");
814 |
815 | // output is unchanged
816 | final out2 = await git.revision(['--full']);
817 | expect(out2, contains('versionName: 2_featureB+1_9006da1\n'));
818 | });
819 | });
820 | }
821 |
--------------------------------------------------------------------------------
/test/integration/util/temp_git.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io' as io;
3 |
4 | import 'package:git_revision/cli_app.dart';
5 | import 'package:test/test.dart';
6 |
7 | import '../../unit/util/memory_logger.dart';
8 |
9 | class TempGit {
10 | /// set to `true` for debugging to skip deletion of the repo folder
11 | ///
12 | /// Usage for debugging
13 | /// ```
14 | /// git.skipCleanup = true;
15 | /// print('cd ${git.repo.path} && stree .');
16 | /// ```
17 | bool skipCleanup = false;
18 |
19 | TempGit();
20 |
21 | late io.Directory repo;
22 | late io.Directory root;
23 |
24 | String get path => root.path;
25 |
26 | int _scriptCount = 0;
27 |
28 | Future setup() async {
29 | root = await io.Directory.systemTemp.createTemp('git-revision-integration-test');
30 | final path = "${root.path}${io.Platform.pathSeparator}repo";
31 | repo = await io.Directory(path).create();
32 | }
33 |
34 | Future cleanup() async {
35 | if (skipCleanup) return;
36 | await root.delete(recursive: true);
37 | }
38 |
39 | Future run({String? name, required String script, io.Directory? repo}) async {
40 | assert(script.isNotEmpty);
41 | final namePostfix = name != null ? "_$name".replaceAll(" ", "_") : "";
42 | final scriptName = "script${_scriptCount++}$namePostfix.sh";
43 | final path = "${root.path}${io.Platform.pathSeparator}$scriptName";
44 | final scriptFile = await io.File(path).create();
45 | final scriptText = sh(
46 | """
47 | # Script ${_scriptCount - 1} '$name'
48 | # Created at ${DateTime.now().toIso8601String()}
49 | set -e
50 | $script
51 | """,
52 | );
53 | await scriptFile.writeAsString(scriptText);
54 |
55 | // execute script
56 | final permission = await io.Process.run('chmod', ['+x', scriptName], workingDirectory: root.path);
57 | _throwOnError(permission);
58 |
59 | repo ??= this.repo;
60 | printOnFailure("\nrunning '$scriptName' in ${repo.path}:");
61 | printOnFailure("\n$scriptText\n\n");
62 | final scriptResult = await io.Process.run('../$scriptName', [], workingDirectory: repo.path, runInShell: true);
63 | _throwOnError(scriptResult);
64 | }
65 |
66 | Future revision(List args, [io.Directory? repo]) async {
67 | repo ??= this.repo;
68 | final logger = MemoryLogger();
69 | final cliApp = CliApp.production(logger);
70 | await cliApp.process(['-C', (repo.path), ...args]);
71 | if (logger.errors.isNotEmpty) {
72 | print("Error!");
73 | print(logger.errors);
74 | throw Exception("CliApp crashed");
75 | }
76 | final messages = logger.messages.join('\n');
77 | printOnFailure("\n> git revision ${args.join(" ")}");
78 | printOnFailure(messages);
79 | return messages;
80 | }
81 | }
82 |
83 | Future makeTempGit() async {
84 | final tempGit = TempGit();
85 | await tempGit.setup();
86 | printOnFailure("cd ${tempGit.repo.path} && git log --pretty=fuller");
87 | addTearDown(() {
88 | tempGit.cleanup();
89 | });
90 | return tempGit;
91 | }
92 |
93 | const Duration hour = Duration(hours: 1);
94 | const Duration day = Duration(days: 1);
95 | const Duration minutes = Duration(minutes: 1);
96 |
97 | String commit(String message, DateTime date, {bool add = true}) => sh(
98 | """
99 | export GIT_COMMITTER_DATE="${date.toIso8601String()}"
100 | git commit -${add ? 'a' : ''}m "$message" --date "\$GIT_COMMITTER_DATE"
101 | unset GIT_COMMITTER_DATE
102 | """,
103 | );
104 |
105 | String merge(String branchToMerge, DateTime date, {bool ff = false}) => sh(
106 | """
107 | git merge${ff ? '' : ' --no-ff'} $branchToMerge --no-commit
108 | ${commit("Merge branch '$branchToMerge'", date)}
109 | """,
110 | );
111 |
112 | void _throwOnError(io.ProcessResult processResult) {
113 | printOnFailure(processResult.stdout as String);
114 | if (processResult.exitCode != 0) {
115 | io.stderr.write("Exit code: ${processResult.exitCode}");
116 | io.stderr.write(processResult.stderr);
117 | throw io.ProcessException(
118 | "",
119 | [],
120 | "out:\n"
121 | "${processResult.stdout as String}\n"
122 | "err:\n"
123 | "${processResult.stderr as String}",
124 | processResult.exitCode,
125 | );
126 | }
127 | }
128 |
129 | /// trims the script
130 | String sh(String script) => script.split('\n').map((line) => line.trimLeft()).join('\n').trim();
131 |
--------------------------------------------------------------------------------
/test/unit/cli_app_test.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | import 'package:git_revision/cli_app.dart';
4 | import 'package:git_revision/git/commit.dart';
5 | import 'package:git_revision/git/git_client.dart';
6 | import 'package:git_revision/git/local_changes.dart';
7 | import 'package:git_revision/git_revision.dart';
8 | import 'package:test/test.dart';
9 |
10 | import 'util/memory_logger.dart';
11 |
12 | void main() {
13 | group('parse args', () {
14 | group('help', () {
15 | test('default', () async {
16 | final parsed = CliApp.parseCliArgs(['']);
17 | expect(parsed.showHelp, false);
18 | });
19 | test('add flag', () async {
20 | final parsed = CliApp.parseCliArgs(['--help']);
21 | expect(parsed.showHelp, true);
22 | });
23 | test('add flag #2', () async {
24 | final parsed = CliApp.parseCliArgs(['-h']);
25 | expect(parsed.showHelp, true);
26 | });
27 | });
28 |
29 | group('version', () {
30 | test('default', () async {
31 | final parsed = CliApp.parseCliArgs(['']);
32 | expect(parsed.showVersion, false);
33 | });
34 | test('add flag', () async {
35 | final parsed = CliApp.parseCliArgs(['--version']);
36 | expect(parsed.showVersion, true);
37 | });
38 | test('add flag #2', () async {
39 | final parsed = CliApp.parseCliArgs(['-v']);
40 | expect(parsed.showVersion, true);
41 | });
42 | });
43 |
44 | group('context', () {
45 | test('default', () async {
46 | final parsed = CliApp.parseCliArgs(['']);
47 | expect(parsed.repoPath, null);
48 | });
49 |
50 | test('set context', () async {
51 | final parsed = CliApp.parseCliArgs(['--context', '../Other Project/']);
52 | expect(parsed.repoPath, '../Other Project/');
53 | });
54 | test('set context #2', () async {
55 | final parsed = CliApp.parseCliArgs(['--context=../Other Project/']);
56 | expect(parsed.repoPath, '../Other Project/');
57 | });
58 |
59 | test('set context with abbr', () async {
60 | final parsed = CliApp.parseCliArgs(['-C', '../Other Project/']);
61 | expect(parsed.repoPath, '../Other Project/');
62 | });
63 |
64 | test('set context with abbr #2', () async {
65 | final parsed = CliApp.parseCliArgs(['-C../Other Project/']);
66 | expect(parsed.repoPath, '../Other Project/');
67 | });
68 | });
69 |
70 | group('baseBranch', () {
71 | test('default', () async {
72 | final parsed = CliApp.parseCliArgs(['']);
73 | expect(parsed.baseBranch, isNull);
74 | });
75 |
76 | test('set baseBranch', () async {
77 | final parsed = CliApp.parseCliArgs(['--baseBranch', 'develop']);
78 | expect(parsed.baseBranch, 'develop');
79 | });
80 | test('set baseBranch #2', () async {
81 | final parsed = CliApp.parseCliArgs(['--baseBranch=develop']);
82 | expect(parsed.baseBranch, 'develop');
83 | });
84 |
85 | test('set baseBranch with abbr', () async {
86 | final parsed = CliApp.parseCliArgs(['-b', 'develop']);
87 | expect(parsed.baseBranch, 'develop');
88 | });
89 |
90 | test('set baseBranch with abbr #2', () async {
91 | final parsed = CliApp.parseCliArgs(['-bdevelop']);
92 | expect(parsed.baseBranch, 'develop');
93 | });
94 | });
95 |
96 | test('set stopDebounce', () async {
97 | final parsed = CliApp.parseCliArgs(['-d 1200']);
98 | expect(parsed.stopDebounce, 1200);
99 | });
100 |
101 | test('report invalid stopDebounce format ', () async {
102 | try {
103 | CliApp.parseCliArgs(['-d asdf']);
104 | fail('did not throw');
105 | } on ArgError catch (e, _) {
106 | expect(e.message, contains("stopDebounce"));
107 | expect(e.message, contains("'asdf'"));
108 | }
109 | });
110 |
111 | group('yearFactor', () {
112 | test('default', () async {
113 | final parsed = CliApp.parseCliArgs(['']);
114 | expect(parsed.yearFactor, GitVersioner.defaultYearFactor);
115 | });
116 |
117 | test('set yearFactor', () async {
118 | final parsed = CliApp.parseCliArgs(['--yearFactor', '1200']);
119 | expect(parsed.yearFactor, 1200);
120 | });
121 | test('set yearFactor #2', () async {
122 | final parsed = CliApp.parseCliArgs(['--yearFactor=1200']);
123 | expect(parsed.yearFactor, 1200);
124 | });
125 |
126 | test('set yearFactor with abbr', () async {
127 | final parsed = CliApp.parseCliArgs(['-y', '1200']);
128 | expect(parsed.yearFactor, 1200);
129 | });
130 |
131 | test('set yearFactor with abbr #2', () async {
132 | final parsed = CliApp.parseCliArgs(['-y1200']);
133 | expect(parsed.yearFactor, 1200);
134 | });
135 |
136 | test('report invalid yearFactor format ', () async {
137 | try {
138 | CliApp.parseCliArgs(['--yearFactor=asdf']);
139 | fail('did not throw');
140 | } on ArgError catch (e, _) {
141 | expect(e.message, contains("yearFactor"));
142 | expect(e.message, contains("'asdf'"));
143 | }
144 | });
145 | });
146 |
147 | group('stopDebounce', () {
148 | test('default', () async {
149 | final parsed = CliApp.parseCliArgs(['']);
150 | expect(parsed.stopDebounce, GitVersioner.defaultStopDebounce);
151 | });
152 |
153 | test('set stopDebounce', () async {
154 | final parsed = CliApp.parseCliArgs(['--stopDebounce', '96']);
155 | expect(parsed.stopDebounce, 96);
156 | });
157 | test('set stopDebounce #2', () async {
158 | final parsed = CliApp.parseCliArgs(['--stopDebounce=96']);
159 | expect(parsed.stopDebounce, 96);
160 | });
161 |
162 | test('set stopDebounce with abbr', () async {
163 | final parsed = CliApp.parseCliArgs(['-d', '96']);
164 | expect(parsed.stopDebounce, 96);
165 | });
166 |
167 | test('set stopDebounce with abbr #2', () async {
168 | final parsed = CliApp.parseCliArgs(['-d96']);
169 | expect(parsed.stopDebounce, 96);
170 | });
171 |
172 | test('report invalid stopDebounce format ', () async {
173 | try {
174 | CliApp.parseCliArgs(['--stopDebounce=asdf']);
175 | fail('did not throw');
176 | } on ArgError catch (e, _) {
177 | expect(e.message, contains("stopDebounce"));
178 | expect(e.message, contains("'asdf'"));
179 | }
180 | });
181 | });
182 |
183 | group('revision', () {
184 | test('default', () async {
185 | final parsed = CliApp.parseCliArgs(['']);
186 | expect(parsed.revision, 'HEAD');
187 | });
188 |
189 | test('set rev', () async {
190 | final parsed = CliApp.parseCliArgs(['someBranch']);
191 | expect(parsed.revision, 'someBranch');
192 | });
193 |
194 | test('set rev before options', () async {
195 | final parsed = CliApp.parseCliArgs(['someBranch', '--baseBranch=asdf']);
196 | expect(parsed.revision, 'someBranch');
197 | });
198 |
199 | test('set rev after options', () async {
200 | final parsed = CliApp.parseCliArgs(['--baseBranch=asdf', 'someBranch']);
201 | expect(parsed.revision, 'someBranch');
202 | });
203 |
204 | test('multiple revs throw', () async {
205 | try {
206 | CliApp.parseCliArgs(['someBranch', 'otherBranch']);
207 | fail('did not throw');
208 | } on ArgError catch (e, _) {
209 | expect(e.message, contains("[someBranch, otherBranch]"));
210 | }
211 | });
212 | });
213 | });
214 |
215 | group('--help', () {
216 | late MemoryLogger output;
217 |
218 | setUp(() async {
219 | output = await _gitRevision("--help");
220 | });
221 |
222 | test('shows intro text', () async {
223 | expect(
224 | output.messages.join(),
225 | startsWith("git revision creates a useful revision for your project beyond 'git describe'"),
226 | );
227 | });
228 |
229 | test('shows usage information', () async {
230 | final usageMessage = output.messages.join();
231 | expect(usageMessage, contains('--help'));
232 | expect(usageMessage, contains('--version'));
233 | });
234 |
235 | test('all fields are filled', () async {
236 | for (final msg in output.messages) {
237 | expect(msg, isNot(contains('null')));
238 | }
239 | });
240 | });
241 |
242 | group('--version', () {
243 | late MemoryLogger output;
244 |
245 | setUp(() async {
246 | output = await _gitRevision("--version");
247 | });
248 |
249 | test('shows version number', () async {
250 | expect(output.messages, hasLength(1));
251 | expect(output.messages[0], contains('Version'));
252 |
253 | // contains a semantic version string (simplified)
254 | final semanticVersion = RegExp(r'.*\d{1,3}\.\d{1,3}\.\d{1,3}.*');
255 | expect(semanticVersion.hasMatch(output.messages[0]), true);
256 | });
257 |
258 | test('all fields are filled', () async {
259 | for (final msg in output.messages) {
260 | expect(msg, isNot(contains('null')));
261 | }
262 | });
263 | });
264 | group('global args order', () {
265 | test('--help outranks --version', () async {
266 | final version = await _gitRevision("--help");
267 | final both = await _gitRevision("--version --help");
268 | expect(both, equals(version));
269 | });
270 |
271 | test('--help outranks revision', () async {
272 | final version = await _gitRevision("--help");
273 | final both = await _gitRevision("HEAD --help");
274 | expect(both, equals(version));
275 | });
276 |
277 | test('--version outranks revision', () async {
278 | final version = await _gitRevision("--version");
279 | final both = await _gitRevision("HEAD --version");
280 | expect(both, equals(version));
281 | });
282 | });
283 |
284 | group('--full', () {
285 | MemoryLogger logger;
286 | CliApp app;
287 | late String log;
288 |
289 | setUp(() async {
290 | logger = MemoryLogger();
291 |
292 | app = CliApp(logger, (config) {
293 | return _FakeGitVersioner(
294 | config: config,
295 | revisionField: 432,
296 | versionNameField: '432-SNAPSHOT',
297 | headBranchNameField: 'myBranch',
298 | sha1Field: '1234567',
299 | allFirstBaseBranchCommitsField: _commits(152),
300 | commitsField: _commits(432),
301 | baseBranchCommitsField: _commits(377),
302 | baseBranchTimeComponentField: 773,
303 | featureBranchCommitsField: _commits(677),
304 | featureBranchTimeComponentField: 776,
305 | featureBranchOriginField: Commit('featureBranchOrigin', '0'),
306 | localChangesField: const LocalChanges(4, 5, 6),
307 | baseBranchField: 'notmain',
308 | );
309 | });
310 | await app.process(['-y 100', 'HEAD', '--baseBranch', 'asdf', '--full']);
311 | log = logger.messages.join('\n');
312 | });
313 |
314 | test('shows revision', () async {
315 | expect(log, contains('versionCode: 432'));
316 | });
317 |
318 | test('shows version name', () async {
319 | expect(log, contains('versionName: 432-SNAPSHOT'));
320 | });
321 |
322 | test('shows current branch', () async {
323 | expect(log, contains('myBranch'));
324 | });
325 |
326 | test('shows featureBranchOrigin', () async {
327 | expect(log, contains('featureOrigin: featureBranchOrigin'));
328 | });
329 |
330 | test('shows base branch', () async {
331 | expect(log, contains('baseBranch'));
332 | });
333 |
334 | test('shows complete first-only base branch commit count', () async {
335 | expect(log, contains('completeFirstOnlyBaseBranchCommitCount: 152'));
336 | });
337 |
338 | test('shows sha1', () async {
339 | expect(log, contains('1234567'));
340 | });
341 |
342 | test('shows base branch time commits', () async {
343 | expect(log, contains('377'));
344 | });
345 |
346 | test('shows base branch time component', () async {
347 | expect(log, contains('773'));
348 | });
349 |
350 | test('shows feature branch time commits', () async {
351 | expect(log, contains('677'));
352 | });
353 |
354 | test('shows feature branch time component', () async {
355 | expect(log, contains('776'));
356 | });
357 |
358 | test('shows local changes', () async {
359 | expect(log, contains('4 +5 -6'));
360 | });
361 |
362 | test('all fields are filled', () async {
363 | // detects new added fields which aren't mocked
364 | expect(log, isNot(contains('null')));
365 | });
366 |
367 | test('baseBranch', () async {
368 | expect(log, contains('baseBranch: notmain'));
369 | });
370 | });
371 | }
372 |
373 | Future _gitRevision(String args) async {
374 | final logger = MemoryLogger();
375 | // creates CliApp without revision part
376 | final app = CliApp(logger, (_) => null);
377 |
378 | await app.process(args.split(' '));
379 |
380 | return logger;
381 | }
382 |
383 | class _FakeGitVersioner implements GitVersioner {
384 | _FakeGitVersioner({
385 | this.allFirstBaseBranchCommitsField,
386 | this.baseBranchCommitsField,
387 | this.baseBranchTimeComponentField,
388 | this.commitsField,
389 | required this.config,
390 | this.featureBranchCommitsField,
391 | this.featureBranchOriginField,
392 | this.featureBranchTimeComponentField,
393 | this.headBranchNameField,
394 | this.localChangesField,
395 | this.revisionField,
396 | this.sha1Field,
397 | this.versionNameField,
398 | this.baseBranchField,
399 | });
400 |
401 | final List? allFirstBaseBranchCommitsField;
402 | @override
403 | Future> get allFirstBaseBranchCommits async => allFirstBaseBranchCommitsField!;
404 |
405 | final List? baseBranchCommitsField;
406 | @override
407 | Future> get baseBranchCommits async => baseBranchCommitsField!;
408 |
409 | final int? baseBranchTimeComponentField;
410 | @override
411 | Future get baseBranchTimeComponent async => baseBranchTimeComponentField!;
412 | final List? commitsField;
413 | @override
414 | Future> get commits async => commitsField!;
415 |
416 | @override
417 | final GitVersionerConfig config;
418 |
419 | List? featureBranchCommitsField;
420 | @override
421 | Future> get featureBranchCommits async => featureBranchCommitsField!;
422 |
423 | Commit? featureBranchOriginField;
424 | @override
425 | Future get featureBranchOrigin async => featureBranchOriginField;
426 |
427 | int? featureBranchTimeComponentField;
428 | @override
429 | Future get featureBranchTimeComponent async => featureBranchTimeComponentField!;
430 |
431 | @override
432 | GitClient get gitClient => throw UnimplementedError();
433 |
434 | String? headBranchNameField;
435 | @override
436 | Future get headBranchName async => headBranchNameField!;
437 |
438 | LocalChanges? localChangesField;
439 | @override
440 | Future get localChanges async => localChangesField!;
441 |
442 | int? revisionField;
443 | @override
444 | Future get revision async => revisionField!;
445 |
446 | String? sha1Field;
447 | @override
448 | Future get sha1 async => sha1Field;
449 |
450 | String? versionNameField;
451 |
452 | @override
453 | Future get versionName async => versionNameField!;
454 |
455 | String? baseBranchField;
456 | @override
457 | Future get baseBranch async => baseBranchField!;
458 | }
459 |
460 | List _commits(int count) {
461 | final now = DateTime.now();
462 | return List.generate(count, (_) {
463 | return Commit("some sha1", now.toIso8601String());
464 | }).toList(growable: false);
465 | }
466 |
--------------------------------------------------------------------------------
/test/unit/util/memory_logger.dart:
--------------------------------------------------------------------------------
1 | import 'package:collection/collection.dart';
2 | import 'package:git_revision/cli_app.dart';
3 |
4 | /// [CliLogger] which stores all messages accessible in memory
5 | class MemoryLogger extends CliLogger {
6 | List messages = [];
7 | List errors = [];
8 |
9 | @override
10 | void stdOut(String s) => messages.add(s);
11 |
12 | @override
13 | void stdErr(String s) => errors.add(s);
14 |
15 | @override
16 | bool operator ==(Object other) =>
17 | identical(this, other) ||
18 | other is MemoryLogger &&
19 | runtimeType == other.runtimeType &&
20 | const IterableEquality().equals(messages, other.messages) &&
21 | const IterableEquality().equals(errors, other.errors);
22 |
23 | @override
24 | int get hashCode => const IterableEquality().hash(messages) ^ const IterableEquality().hash(errors);
25 |
26 | @override
27 | String toString() {
28 | return 'MockLogger{messages: $messages, errors: $errors}';
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tool/build.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io';
3 |
4 | import 'package:dart_style/dart_style.dart';
5 | import 'package:path/path.dart' as path;
6 | import 'package:yaml/yaml.dart';
7 |
8 | import 'util/process.dart';
9 |
10 | Future main(List args) => build();
11 |
12 | Future build() async {
13 | await buildGeneratedSource();
14 |
15 | print("Building snapshot");
16 | // Ensure that the `build/` directory exists.
17 | await Directory('build').create(recursive: true);
18 | final outFile = File("build/git_revision.dart.snapshot");
19 | await sh("dart --snapshot=${outFile.path} bin/git_revision.dart");
20 | assert(outFile.existsSync());
21 | await sh("chmod 755 ${outFile.path}", quiet: true);
22 | print("\nSUCCESS\n");
23 | print("snapshot at ${outFile.absolute.path}");
24 | }
25 |
26 | Future buildGeneratedSource() async {
27 | print("Building generated source");
28 | final content = await File('pubspec.yaml').readAsString();
29 | final yaml = loadYaml(content) as Map;
30 | final version = yaml['version'] as String;
31 |
32 | final files = Directory("lib").listSync();
33 | final sourceFile = files.firstWhere((it) => path.basename(it.path) == 'cli_app.dart');
34 | final partFile = File(sourceFile.path.replaceAll(".dart", ".g.dart"));
35 |
36 | final source = DartFormatter().format('''
37 | // GENERATED CODE - DO NOT MODIFY BY HAND
38 |
39 | part of 'cli_app.dart';
40 |
41 | // **************************************************************************
42 | // BuildConfig
43 | // **************************************************************************
44 |
45 | const String versionName = '$version';
46 | ''',);
47 |
48 | await partFile.writeAsString(source);
49 |
50 | print("wrote ${partFile.path}");
51 | }
52 |
--------------------------------------------------------------------------------
/tool/clean.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | void main(List args) => clean();
4 |
5 | void clean() {
6 | cleanupDir("build");
7 | cleanupDir(".dart_tool");
8 | }
9 |
10 | void cleanupDir(String path) {
11 | final directory = Directory(path);
12 | if (directory.existsSync()) {
13 | directory.deleteSync(recursive: true);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/tool/reformat.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io';
3 |
4 | Future main(List args) => reformat();
5 |
6 | Future reformat() async {
7 | final Process p =
8 | await Process.start('dartfmt', ['--set-exit-if-changed', '--fix', '-l 120', '-w', 'bin', 'lib', 'test', 'tool'])
9 | .then((process) {
10 | stdout.writeln('Reformatting project with dartfmt');
11 |
12 | final out = process.stdout
13 | .map((it) => String.fromCharCodes(it))
14 | .where((it) => !it.contains("Unchanged"))
15 | .map((it) => it.replaceFirst('Formatting directory ', ''))
16 | .map((it) => it.codeUnits);
17 |
18 | stdout.addStream(out);
19 | stderr.addStream(process.stderr);
20 |
21 | return process;
22 | });
23 |
24 | final code = await p.exitCode;
25 | if (code != 0) {
26 | exit(code);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tool/standalone.dart:
--------------------------------------------------------------------------------
1 | // Copyright 2018 Google Inc. Use of this source code is governed by an
2 | // MIT-style license that can be found in the LICENSE file or at
3 | // https://opensource.org/licenses/MIT.
4 |
5 | import 'dart:async';
6 | import 'dart:io';
7 |
8 | import 'package:archive/archive.dart';
9 | import 'package:http/http.dart' as http;
10 | import 'package:pub_semver/pub_semver.dart';
11 | import 'package:synchronized/synchronized.dart';
12 |
13 | import 'build.dart';
14 | import 'util/archive.dart';
15 | import 'util/utils.dart';
16 |
17 | /// Generates the standalone packages
18 | Future main(List args) => standalone();
19 |
20 | /// Big thanks to @nex3 and the https://github.com/sass/dart-sass project where the this process first appeared
21 | Future standalone() async {
22 | await build();
23 |
24 | final platforms = ["linux", "macos", "windows"];
25 | final architectures = ["ia32", "x64", "arm64", 'arm'];
26 | final Version dartVersion = Version.parse(Platform.version.split(" ").first);
27 | final String channel = dartVersion.isPreRelease ? "dev" : "stable";
28 | await Future.wait(
29 | platforms.expand((os) {
30 | return architectures.map((arch) {
31 | return StandaloneBundler(os, arch, dartVersion.toString(), channel).bundle();
32 | });
33 | }),
34 | );
35 | }
36 |
37 | class StandaloneBundler {
38 | final String os;
39 | final String architecture;
40 | final String channel;
41 | final String dartVersion;
42 |
43 | StandaloneBundler(this.os, this.architecture, this.dartVersion, this.channel);
44 |
45 | Future bundle() async {
46 | final sdk = await _downloadDartSdk();
47 | if (sdk == null) {
48 | print("There is no dart sdk available for variant Dart $dartVersion on $os-$architecture, skipping");
49 | return;
50 | }
51 | print("bundling $os-$architecture");
52 | final archive = await _bundleArchive(sdk);
53 | final file = await _writeToDisk(archive);
54 | print("created archive $os-$architecture $file");
55 | }
56 |
57 | /// Do download sequential
58 | final _downloadLock = Lock();
59 |
60 | Future _downloadDartSdk() async {
61 | // TODO: Compile a single executable that embeds the Dart VM and the snapshot
62 | // when dart-lang/sdk#27596 is fixed.
63 | final url = "https://storage.googleapis.com/dart-archive/channels/$channel/"
64 | "release/$dartVersion/sdk/dartsdk-$os-$architecture-release.zip";
65 | final response = await _downloadLock.synchronized(() {
66 | print("Downloading $url");
67 | return http.get(Uri.parse(url));
68 | });
69 | if (response.statusCode == 404) {
70 | return null;
71 | }
72 | print("Downloaded $url");
73 | if (response.statusCode < 200 || response.statusCode >= 300) {
74 | throw "Failed to download package: ${response.statusCode} ${response.reasonPhrase} $url.";
75 | }
76 | final dartSdk = ZipDecoder().decodeBytes(response.bodyBytes);
77 | return dartSdk;
78 | }
79 |
80 | Future _bundleArchive(Archive dartSdk) async {
81 | final archive = Archive();
82 |
83 | // add dart executable
84 | if (os == 'windows') {
85 | final dart = dartSdk.firstWhere((file) => file.name.endsWith("/bin/dart.exe")).content as List;
86 | archive.addFile(fileFromBytes("git-revision/src/dart.exe", dart, executable: true));
87 | } else {
88 | final dart = dartSdk.firstWhere((file) => file.name.endsWith("/bin/dart")).content as List;
89 | archive.addFile(fileFromBytes("git-revision/src/dart", dart, executable: true));
90 | }
91 |
92 | // and the dart license
93 | archive.addFile(
94 | fileFromBytes(
95 | "git-revision/src/DART_LICENSE",
96 | dartSdk.firstWhere((file) => file.name.endsWith("/LICENSE")).content as List,
97 | ),
98 | );
99 |
100 | // add snapshot
101 | // TODO: Use an app snapshots when https://github.com/dart-lang/sdk/issues/28617 is fixed.
102 | archive.addFile(file("git-revision/src/git_revision.dart.snapshot", "build/git_revision.dart.snapshot"));
103 | // and the project license
104 | archive.addFile(file("git-revision/src/LICENSE", "LICENSE"));
105 |
106 | // add executable
107 | if (os == 'windows') {
108 | archive.addFile(file("git-revision/git-revision.bat", "package/git-revision.bat", executable: true));
109 | } else {
110 | archive.addFile(file("git-revision/git-revision", "package/git-revision.sh", executable: true));
111 | }
112 |
113 | return archive;
114 | }
115 |
116 | Future _writeToDisk(final Archive archive) async {
117 | final version = await projectVersion();
118 | final prefix = 'build/git_revision-$version-$os-$architecture';
119 | late String output;
120 | late List? Function(Archive archive) encode;
121 | if (os == 'windows') {
122 | output = "$prefix.zip";
123 | encode = (archive) => ZipEncoder().encode(archive);
124 | } else {
125 | output = "$prefix.tar.gz";
126 | encode = (archive) => GZipEncoder().encode(TarEncoder().encode(archive));
127 | }
128 | print("Saving $output...");
129 | final file = File(output);
130 | if (file.existsSync()) {
131 | file.deleteSync();
132 | }
133 | await file.writeAsBytes(encode(archive)!);
134 | return file;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/tool/util/archive.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'dart:io';
3 |
4 | import 'package:archive/archive.dart';
5 |
6 | /// Creates an [ArchiveFile] with the given [path] and [data].
7 | ///
8 | /// If [executable] is `true`, this marks the file as executable.
9 | ArchiveFile fileFromBytes(String path, List data, {bool executable = false}) =>
10 | ArchiveFile(path, data.length, data)
11 | ..mode = executable ? 495 : 428
12 | ..lastModTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
13 |
14 | /// Creates a UTF-8-encoded [ArchiveFile] with the given [path] and [contents].
15 | ///
16 | /// If [executable] is `true`, this marks the file as executable.
17 | ArchiveFile fileFromString(String path, String contents, {bool executable = false}) =>
18 | fileFromBytes(path, utf8.encode(contents), executable: executable);
19 |
20 | /// Creates an [ArchiveFile] at the archive path [target] from the local file at
21 | /// [source].
22 | ///
23 | /// If [executable] is `true`, this marks the file as executable.
24 | ArchiveFile file(String target, String source, {bool executable = false}) =>
25 | fileFromBytes(target, File(source).readAsBytesSync(), executable: executable);
26 |
--------------------------------------------------------------------------------
/tool/util/process.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io';
3 |
4 | /// executes the shell command, exits on error
5 | Future sh(String command, {bool quiet = false, String? description}) async {
6 | if (!quiet) print("=> $command");
7 | final split = command.split(" ");
8 | final process = await Process.start(split[0], split.skip(1).toList());
9 | final out = quiet ? Future.value() : stdout.addStream(process.stdout);
10 | final err = stderr.addStream(process.stderr);
11 | await Future.wait([out, err]);
12 |
13 | exitCode = await process.exitCode;
14 | if (exitCode > 0) {
15 | exit(exitCode);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tool/util/utils.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io';
3 |
4 | import 'package:yaml/yaml.dart';
5 |
6 | /// The version of git_revision.
7 | Future projectVersion() async {
8 | final content = await File('pubspec.yaml').readAsString();
9 | final yaml = loadYaml(content) as Map;
10 | final version = yaml['version'] as String;
11 | assert(version.isNotEmpty);
12 | return version;
13 | }
14 |
--------------------------------------------------------------------------------