-
-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathrepo.ts
325 lines (302 loc) · 10.5 KB
/
repo.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
import path from 'path';
import {
runCommand,
getStdoutFromCommand,
getLinesFromCommand,
} from './misc-utils.js';
const CHANGED_FILE_PATHS_BY_TAG_NAME: Record<string, string[]> = {};
/**
* Runs a Git command within the given repository, discarding its output.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @param commandName - The name of the Git command (e.g., "commit").
* @param commandArgs - The arguments to the command.
* @returns The standard output of the command.
* @throws An execa error object if the command fails in some way.
*/
export async function runGitCommandWithin(
repositoryDirectoryPath: string,
commandName: string,
commandArgs: readonly string[],
): Promise<void> {
await runCommand('git', [commandName, ...commandArgs], {
cwd: repositoryDirectoryPath,
});
}
/**
* Runs a Git command within the given repository, obtaining the immediate
* output.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @param commandName - The name of the Git command (e.g., "commit").
* @param commandArgs - The arguments to the command.
* @returns The standard output of the command.
* @throws An execa error object if the command fails in some way.
*/
async function getStdoutFromGitCommandWithin(
repositoryDirectoryPath: string,
commandName: string,
commandArgs: readonly string[],
): Promise<string> {
return await getStdoutFromCommand('git', [commandName, ...commandArgs], {
cwd: repositoryDirectoryPath,
});
}
/**
* Runs a Git command within the given repository, splitting up the immediate
* output into lines.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @param commandName - The name of the Git command (e.g., "commit").
* @param commandArgs - The arguments to the command.
* @returns A set of lines from the standard output of the command.
* @throws An execa error object if the command fails in some way.
*/
async function getLinesFromGitCommandWithin(
repositoryDirectoryPath: string,
commandName: string,
commandArgs: readonly string[],
): Promise<string[]> {
return await getLinesFromCommand('git', [commandName, ...commandArgs], {
cwd: repositoryDirectoryPath,
});
}
/**
* Check whether the local repository has a complete git history.
* Implemented using `git rev-parse --is-shallow-repository`.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @returns Whether the local repository has a complete, as opposed to shallow,
* git history.
* @throws if `git rev-parse --is-shallow-repository` returns an unrecognized
* value.
*/
async function hasCompleteGitHistory(
repositoryDirectoryPath: string,
): Promise<boolean> {
const isShallow = await getStdoutFromGitCommandWithin(
repositoryDirectoryPath,
'rev-parse',
['--is-shallow-repository'],
);
// We invert the meaning of these strings because we want to know if the
// repository is NOT shallow.
if (isShallow === 'true') {
return false;
} else if (isShallow === 'false') {
return true;
}
throw new Error(
`"git rev-parse --is-shallow-repository" returned unrecognized value: ${JSON.stringify(
isShallow,
)}`,
);
}
/**
* Performs a diff in order to obtains a set of files that were changed in the
* given repository between a particular tag and HEAD.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @param tagName - The name of the tag to compare against HEAD.
* @returns An array of paths to files that have changes between the given tag
* and HEAD.
*/
async function getFilesChangedSince(
repositoryDirectoryPath: string,
tagName: string,
): Promise<string[]> {
const partialFilePaths = await getLinesFromGitCommandWithin(
repositoryDirectoryPath,
'diff',
[tagName, 'HEAD', '--name-only'],
);
return partialFilePaths.map((partialFilePath) =>
path.join(repositoryDirectoryPath, partialFilePath),
);
}
/**
* Gets the HTTPS URL of the primary remote with which the given repository has
* been configured. Assumes that the git config `remote.origin.url` string
* matches one of:
*
* - https://github.com/OrganizationName/RepositoryName
* - [email protected]:OrganizationName/RepositoryName.git
*
* If the URL of the "origin" remote matches neither pattern, an error is
* thrown.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @returns The HTTPS URL of the repository, e.g.
* `https://github.com/OrganizationName/RepositoryName`.
*/
export async function getRepositoryHttpsUrl(
repositoryDirectoryPath: string,
): Promise<string> {
const httpsPrefix = 'https://github.com';
const sshPrefixRegex = /^git@github\.com:/u;
const sshPostfixRegex = /\.git$/u;
const gitConfigUrl = await getStdoutFromGitCommandWithin(
repositoryDirectoryPath,
'config',
['--get', 'remote.origin.url'],
);
if (gitConfigUrl.startsWith(httpsPrefix)) {
return gitConfigUrl;
}
// Extracts "OrganizationName/RepositoryName" from
// "[email protected]:OrganizationName/RepositoryName.git" and returns the
// corresponding HTTPS URL.
if (
gitConfigUrl.match(sshPrefixRegex) &&
gitConfigUrl.match(sshPostfixRegex)
) {
return `${httpsPrefix}/${gitConfigUrl
.replace(sshPrefixRegex, '')
.replace(sshPostfixRegex, '')}`;
}
throw new Error(`Unrecognized URL for git remote "origin": ${gitConfigUrl}`);
}
/**
* Commits all changes in a git repository with a specified commit message.
*
* @param repositoryDirectoryPath - The file system path to the git repository where changes are to be committed.
* @param commitMessage - The message to be used for the git commit.
* @throws If any git command fails to execute.
*/
export async function commitAllChanges(
repositoryDirectoryPath: string,
commitMessage: string,
) {
await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'add', ['-A']);
await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'commit', [
'-m',
commitMessage,
]);
}
/**
* Retrieves the current branch name of a git repository.
*
* @param repositoryDirectoryPath - The file system path to the git repository.
* @returns The name of the current branch in the specified repository.
*/
export function getCurrentBranchName(repositoryDirectoryPath: string) {
return getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'rev-parse', [
'--abbrev-ref',
'HEAD',
]);
}
/**
* Restores specific files in a git repository to their state at the common ancestor commit
* of the current HEAD and the repository's default branch.
*
* This asynchronous function calculates the common ancestor (merge base) of the current HEAD
* and the specified default branch. Then, it uses the `git restore` command to revert the
* specified files back to their state at that ancestor commit. This is useful for undoing
* changes in specific files that have occurred since the branch diverged from the default branch.
*
* @param repositoryDirectoryPath - The file system path to the git repository.
* @param repositoryDefaultBranch - The name of the default branch in the repository.
* @param filePaths - An array of file paths (relative to the repository root) to restore.
*/
export async function restoreFiles(
repositoryDirectoryPath: string,
repositoryDefaultBranch: string,
filePaths: string[],
) {
const ancestorCommitSha = await getStdoutFromGitCommandWithin(
repositoryDirectoryPath,
'merge-base',
[repositoryDefaultBranch, 'HEAD'],
);
await runGitCommandWithin(repositoryDirectoryPath, 'restore', [
'--source',
ancestorCommitSha,
'--',
...filePaths,
]);
}
/**
* Checks if a specific branch exists in the given git repository.
*
* @param repositoryDirectoryPath - The file system path to the git repository.
* @param branchName - The name of the branch to check for existence.
* @returns A promise that resolves to `true` if the branch exists, `false` otherwise.
*/
export async function branchExists(
repositoryDirectoryPath: string,
branchName: string,
) {
const branchNames = await getLinesFromGitCommandWithin(
repositoryDirectoryPath,
'branch',
['--list', branchName],
);
return branchNames.length > 0;
}
/**
* Retrieves the names of the tags in the given repo, sorted by ascending
* semantic version order. As this fetches tags from the remote first, you are
* advised to only run this once.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @param fetchRemote - Whether to synchronize local tags with remote.
* @returns The names of the tags.
* @throws If no tags are found and the local git history is incomplete.
*/
export async function getTagNames(
repositoryDirectoryPath: string,
fetchRemote?: boolean,
): Promise<string[]> {
if (typeof fetchRemote !== 'boolean' || fetchRemote) {
await runGitCommandWithin(repositoryDirectoryPath, 'fetch', ['--tags']);
}
const tagNames = await getLinesFromGitCommandWithin(
repositoryDirectoryPath,
'tag',
[
'--sort=version:refname',
// The --merged flag ensures that we only get tags that are parents of or
// equal to the current HEAD.
'--merged',
],
);
if (
tagNames.length === 0 &&
!(await hasCompleteGitHistory(repositoryDirectoryPath))
) {
throw new Error(
`"git tag" returned no tags. Increase your git fetch depth.`,
);
}
return tagNames;
}
/**
* Calculates whether there have been any commits in the given repo since the
* given tag that include changes to any of the files within the given
* subdirectory within that repo. The result is cached so that multiple calls
* using the same tag name do not re-request the diff.
*
* @param repositoryDirectoryPath - The path to the repository directory.
* @param subdirectoryPath - The path to a subdirectory within the repository.
* @param tagName - The name of a tag in the repository.
* @returns True or false, depending on the result.
*/
export async function hasChangesInDirectorySinceGitTag(
repositoryDirectoryPath: string,
subdirectoryPath: string,
tagName: string,
): Promise<boolean> {
if (!(tagName in CHANGED_FILE_PATHS_BY_TAG_NAME)) {
const changedFilePaths = await getFilesChangedSince(
repositoryDirectoryPath,
tagName,
);
/* istanbul ignore else */
if (!(tagName in CHANGED_FILE_PATHS_BY_TAG_NAME)) {
CHANGED_FILE_PATHS_BY_TAG_NAME[tagName] = changedFilePaths;
}
}
return CHANGED_FILE_PATHS_BY_TAG_NAME[tagName].some((filePath) => {
return filePath.startsWith(subdirectoryPath);
});
}