UNPKG

10.1 kBJavaScriptView Raw
1/*
2 * Copyright 2018 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13const fs = require('fs');
14const path = require('path');
15const os = require('os');
16
17const ignore = require('ignore');
18const ini = require('ini');
19const fse = require('fs-extra');
20const { GitUrl } = require('@adobe/helix-shared');
21const git = require('isomorphic-git');
22
23// cache for isomorphic-git API
24// see https://isomorphic-git.org/docs/en/cache
25const cache = {};
26
27class GitUtils {
28 /**
29 * Determines whether the working tree directory contains uncommitted or unstaged changes.
30 *
31 * @param {string} dir working tree directory path of the git repo
32 * @param {string} [homedir] optional users home directory
33 * @returns {Promise<boolean>} `true` if there are uncommitted/unstaged changes; otherwise `false`
34 */
35 static async isDirty(dir, homedir = os.homedir()) {
36 // see https://isomorphic-git.org/docs/en/statusMatrix
37 const HEAD = 1;
38 const WORKDIR = 2;
39 const STAGE = 3;
40 const matrix = await git.statusMatrix({ fs, dir, cache });
41 let modified = matrix
42 .filter((row) => !(row[HEAD] === row[WORKDIR] && row[WORKDIR] === row[STAGE]));
43 if (modified.length === 0) {
44 return false;
45 }
46
47 // ignore submodules
48 // see https://github.com/adobe/helix-cli/issues/614
49 const gitModules = path.resolve(dir, '.gitmodules');
50 if (await fse.pathExists(gitModules)) {
51 const modules = ini.parse(await fse.readFile(gitModules, 'utf-8'));
52 Object.keys(modules).forEach((key) => {
53 const module = modules[key];
54 if (module.path) {
55 modified = modified.filter((row) => !row[0].startsWith(module.path));
56 }
57 });
58 if (modified.length === 0) {
59 return false;
60 }
61 }
62
63 // workaround for https://github.com/isomorphic-git/isomorphic-git/issues/1076
64 // TODO: remove once #1076 has been resolved.
65 let ign;
66 const localeIgnore = path.resolve(dir, '.gitignore');
67 if (await fse.pathExists(localeIgnore)) {
68 ign = ignore();
69 ign.add(await fse.readFile(localeIgnore, 'utf-8'));
70 }
71
72 // need to re-check the modified against the globally ignored
73 // see: https://github.com/isomorphic-git/isomorphic-git/issues/444
74 const globalConfig = path.resolve(homedir, '.gitconfig');
75 const config = ini.parse(await fse.readFile(globalConfig, 'utf-8'));
76 const globalIgnore = path.resolve(homedir, (config.core && config.core.excludesfile) || '.gitignore_global');
77 if (await fse.pathExists(globalIgnore)) {
78 ign = ign || ignore();
79 ign.add(await fse.readFile(globalIgnore, 'utf-8'));
80 }
81
82 if (ign) {
83 modified = modified.filter((row) => !ign.ignores(row[0]));
84 if (modified.length === 0) {
85 return false;
86 }
87 }
88
89 // filter out the deleted ones for the checks below
90 const existing = modified.filter((row) => row[WORKDIR] > 0).map((row) => row[0]);
91 if (existing.length < modified.length) {
92 return true;
93 }
94
95 // we also need to filter out the non-files and non-symlinks.
96 // see: https://github.com/isomorphic-git/isomorphic-git/issues/705
97 const stats = await Promise.all(existing.map((file) => fse.lstat(path.resolve(dir, file))));
98 const files = stats.filter((stat) => stat.isFile() || stat.isSymbolicLink());
99 return files.length > 0;
100 }
101
102 /**
103 * Checks if the given file is missing or ignored by git.
104 *
105 * @param {string} dir working tree directory path of the git repo
106 * @param {string} filepath file to check
107 * @param {string} [homedir] optional users home directory
108 * @returns {Promise<boolean>} `true` if the file is ignored.
109 */
110 static async isIgnored(dir, filepath, homedir = os.homedir()) {
111 if (!(await fse.pathExists(path.resolve(dir, filepath)))) {
112 return true;
113 }
114 if (!(await fse.pathExists(path.resolve(dir, '.git')))) {
115 return true;
116 }
117
118 const status = await git.status({
119 fs, dir, filepath, cache,
120 });
121 if (status === 'ignored') {
122 return true;
123 }
124
125 // need to re-check the modified against the globally ignored
126 // see: https://github.com/isomorphic-git/isomorphic-git/issues/444
127 const globalConfig = path.resolve(homedir, '.gitconfig');
128 const config = ini.parse(await fse.readFile(globalConfig, 'utf-8'));
129 const globalIgnore = path.resolve(homedir, (config.core && config.core.excludesfile) || '.gitignore_global');
130 if (await fse.pathExists(globalIgnore)) {
131 const ign = ignore().add(await fse.readFile(globalIgnore, 'utf-8'));
132 return ign.ignores(filepath);
133 }
134
135 return false;
136 }
137
138 /**
139 * Returns the name of the current branch. If `HEAD` is at a tag, the name of the tag
140 * will be returned instead.
141 *
142 * @param {string} dir working tree directory path of the git repo
143 * @returns {Promise<string>} current branch or tag
144 */
145 static async getBranch(dir) {
146 // current branch name
147 const currentBranch = await git.currentBranch({ fs, dir, fullname: false });
148 // current commit sha
149 const rev = await git.resolveRef({ fs, dir, ref: 'HEAD' });
150 // reverse-lookup tag from commit sha
151 const allTags = await git.listTags({ fs, dir });
152
153 // iterate sequentially over tags to avoid OOME
154 for (const tag of allTags) {
155 /* eslint-disable no-await-in-loop */
156 const oid = await git.resolveRef({ fs, dir, ref: tag });
157 const obj = await git.readObject({
158 fs, dir, oid, cache,
159 });
160 const commitSha = obj.type === 'tag'
161 ? await git.resolveRef({ fs, dir, ref: obj.object.object }) // annotated tag
162 : oid; // lightweight tag
163 if (commitSha === rev) {
164 return tag;
165 }
166 }
167 // HEAD is not at a tag, return current branch
168 return currentBranch;
169 }
170
171 /**
172 * Returns `dirty` if the working tree directory contains uncommitted/unstaged changes.
173 * Otherwise returns the encoded (any non word character replaced by `-`)
174 * current branch or tag.
175 *
176 * @param {string} dir working tree directory path of the git repo
177 * @returns {Promise<string>} `dirty` or encoded current branch/tag
178 */
179 static async getBranchFlag(dir) {
180 const dirty = await GitUtils.isDirty(dir);
181 const branch = await GitUtils.getBranch(dir);
182 return dirty ? 'dirty' : branch.replace(/[\W]/g, '-');
183 }
184
185 /**
186 * Returns the encoded (any non word character replaced by `-`) `origin` remote url.
187 * If no `origin` remote url is defined `local--<basename of current working dir>`
188 * will be returned instead.
189 *
190 * @param {string} dir working tree directory path of the git repo
191 * @returns {Promise<string>} `dirty` or encoded current branch/tag
192 */
193 static async getRepository(dir) {
194 const repo = (await GitUtils.getOrigin(dir))
195 .replace(/[\W]/g, '-');
196 return repo !== '' ? repo : `local--${path.basename(dir)}`;
197 }
198
199 /**
200 * Returns the `origin` remote url or `''` if none is defined.
201 *
202 * @param {string} dir working tree directory path of the git repo
203 * @returns {Promise<string>} `origin` remote url
204 */
205 static async getOrigin(dir) {
206 try {
207 const rmt = (await git.listRemotes({ fs, dir })).find((entry) => entry.remote === 'origin');
208 return typeof rmt === 'object' ? rmt.url : '';
209 } catch (e) {
210 // don't fail if directory is not a git repository
211 return '';
212 }
213 }
214
215 /**
216 * Same as #getOrigin() but returns a `GitUrl` instance instead of a string.
217 *
218 * @param {string} dir working tree directory path of the git repo
219 * @returns {Promise<GitUrl>} `origin` remote url ot {@code null} if not available
220 */
221 static async getOriginURL(dir) {
222 const origin = await GitUtils.getOrigin(dir);
223 return origin ? new GitUrl(origin) : null;
224 }
225
226 /**
227 * Returns the sha of the current (i.e. `HEAD`) commit.
228 *
229 * @param {string} dir working tree directory path of the git repo
230 * @returns {Promise<string>} sha of the current (i.e. `HEAD`) commit
231 */
232 static async getCurrentRevision(dir) {
233 return git.resolveRef({ fs, dir, ref: 'HEAD' });
234 }
235
236 /**
237 * Returns the commit oid of the curent commit referenced by `ref`
238 *
239 * @param {string} dir git repo path
240 * @param {string} ref reference (branch, tag or commit sha)
241 * @returns {Promise<string>} commit oid of the curent commit referenced by `ref`
242 * @throws {Errors.NotFoundError}: resource not found
243 */
244 static async resolveCommit(dir, ref) {
245 return git.resolveRef({ fs, dir, ref })
246 .catch(async (err) => {
247 if (err instanceof git.Errors.NotFoundError) {
248 // fallback: is ref a shortened oid prefix?
249 const oid = await git.expandOid({
250 fs, dir, oid: ref, cache,
251 })
252 .catch(() => { throw err; });
253 return git.resolveRef({ fs, dir, ref: oid });
254 }
255 // re-throw
256 throw err;
257 });
258 }
259
260 /**
261 * Returns the contents of the file at revision `ref` and `pathName`
262 *
263 * @param {string} dir git repo path
264 * @param {string} ref reference (branch, tag or commit sha)
265 * @param {string} filePath relative path to file
266 * @returns {Promise<Buffer>} content of specified file
267 * @throws {Errors.NotFoundError}: resource not found or invalid reference
268 */
269 static async getRawContent(dir, ref, pathName) {
270 return GitUtils.resolveCommit(dir, ref)
271 .then((oid) => git.readObject({
272 fs, dir, oid, filepath: pathName, format: 'content', cache,
273 }))
274 .then((obj) => obj.object);
275 }
276}
277
278module.exports = GitUtils;