|
| 1 | +const execa = require('execa'); |
| 2 | +import { writeFile } from 'fs/promises'; |
| 3 | +import { EOL } from 'os'; |
| 4 | +import * as path from 'path'; |
| 5 | +import { releaseTagToNameAndVersion } from './release_tag_to_name_and_version'; |
| 6 | +import privatePackages from './private_packages'; |
| 7 | + |
| 8 | +/** |
| 9 | + * Client for programmatically interacting with the local git cli |
| 10 | + */ |
| 11 | +export class GitClient { |
| 12 | + private readonly gitignorePath: string; |
| 13 | + |
| 14 | + /** |
| 15 | + * execaCommand that allows us to capture stdout |
| 16 | + */ |
| 17 | + private readonly exec; |
| 18 | + |
| 19 | + /** |
| 20 | + * execaCommand that pipes buffers to process buffers |
| 21 | + */ |
| 22 | + private readonly execWithIO; |
| 23 | + |
| 24 | + /** |
| 25 | + * Initialize with an optional directory to operate in. |
| 26 | + * Defaults to the process cwd. |
| 27 | + */ |
| 28 | + constructor(cwd?: string) { |
| 29 | + this.exec = (command: string, args: string[]) => execa.sync(command, args, { cwd }); |
| 30 | + this.execWithIO = (command: string, args: string[]) => execa.sync(command, args, { cwd, stdio: 'inherit' }); |
| 31 | + this.gitignorePath = cwd ? path.join(cwd, '.gitignore') : '.gitignore'; |
| 32 | + } |
| 33 | + |
| 34 | + init = async () => { |
| 35 | + await this.exec('git', ['init']); |
| 36 | + await writeFile(this.gitignorePath, `node_modules${EOL}`); |
| 37 | + }; |
| 38 | + |
| 39 | + /** |
| 40 | + * Throws if there are uncommitted changes in the repo |
| 41 | + */ |
| 42 | + ensureWorkingTreeIsClean = async () => { |
| 43 | + const { stdout } = await this.exec('git', ['status', '--porcelain']); |
| 44 | + const isDirty = stdout.trim(); |
| 45 | + if (isDirty) { |
| 46 | + throw new Error('Dirty working tree detected. Commit or stash changes to continue.'); |
| 47 | + } |
| 48 | + }; |
| 49 | + |
| 50 | + getCurrentBranch = async () => { |
| 51 | + const { stdout: currentBranch } = await this.exec('git', ['branch', '--show-current']); |
| 52 | + return currentBranch; |
| 53 | + }; |
| 54 | + |
| 55 | + /** |
| 56 | + * Switches to branchName. Creates the branch if it does not exist. |
| 57 | + */ |
| 58 | + switchToBranch = async (branchName: string) => { |
| 59 | + const { stdout: branchResult } = await this.exec('git', ['branch', '-l', branchName]); |
| 60 | + const branchExists = branchResult.trim().length > 0; |
| 61 | + if (branchExists) { |
| 62 | + await this.execWithIO('git', ['switch', branchName]); |
| 63 | + } else { |
| 64 | + await this.execWithIO('git', ['switch', '-c', branchName]); |
| 65 | + } |
| 66 | + }; |
| 67 | + |
| 68 | + /** |
| 69 | + * Stages and commits all current changes |
| 70 | + */ |
| 71 | + commitAllChanges = async (message: string) => { |
| 72 | + await this.execWithIO('git', ['add', '.']); |
| 73 | + await this.execWithIO('git', ['commit', '--message', message, '--allow-empty']); |
| 74 | + }; |
| 75 | + |
| 76 | + /** |
| 77 | + * Push to the remote |
| 78 | + */ |
| 79 | + push = async ({ force }: { force: boolean } = { force: false }) => { |
| 80 | + await this.execWithIO('git', ['push', force ? '--force' : '']); |
| 81 | + }; |
| 82 | + |
| 83 | + fetchTags = async () => { |
| 84 | + await this.execWithIO('git', ['fetch', '--tags']); |
| 85 | + }; |
| 86 | + |
| 87 | + checkout = async (ref: string, paths: string[] = []) => { |
| 88 | + const additionalArgs = paths.length > 0 ? ['--', ...paths] : []; |
| 89 | + await this.execWithIO('git', ['checkout', ref, ...additionalArgs]); |
| 90 | + }; |
| 91 | + |
| 92 | + status = async () => { |
| 93 | + await this.execWithIO('git', ['status']); |
| 94 | + }; |
| 95 | + |
| 96 | + /** |
| 97 | + * Returns a list of tags that point to the given commit |
| 98 | + */ |
| 99 | + getTagsAtCommit = async (commitHash: string) => { |
| 100 | + const { stdout: tagsString } = await this.exec('git', ['tag', '--points-at', commitHash]); |
| 101 | + return ( |
| 102 | + tagsString |
| 103 | + .split(EOL) |
| 104 | + .filter((line: string) => line.trim().length > 0) |
| 105 | + // filter out packages not published to npm |
| 106 | + .filter((tag: string) => !privatePackages.some(name => tag.includes(name))) |
| 107 | + ); |
| 108 | + }; |
| 109 | + |
| 110 | + /** |
| 111 | + * Gets the most recent release commit that is reachable from the input commitHash |
| 112 | + * If no commitHash is specified, HEAD is used as the default |
| 113 | + * By default, the input commitHash is considered in the search (ie if commitHash is a release commit, that commit will be returned) |
| 114 | + * To search for the most recent release commit EXCLUDING commitHash, set inclusive=false |
| 115 | + */ |
| 116 | + getNearestReleaseCommit = async (commitHash: string = 'HEAD', { inclusive }: { inclusive: boolean } = { inclusive: true }) => { |
| 117 | + // get the most recent tag before (or at if inclusive=false) the current release tag |
| 118 | + const { stdout: previousReleaseTag } = await this.exec('git', ['describe', `${commitHash}${inclusive ? '' : '^'}`, '--abbrev=0']); |
| 119 | + |
| 120 | + // get the commit hash associated with the previous release tag |
| 121 | + const { stdout: previousReleaseCommitHash } = await this.exec('git', ['log', '-1', previousReleaseTag, '--pretty=%H']); |
| 122 | + |
| 123 | + // run some sanity checks on the release commit |
| 124 | + await this.validateReleaseCommitHash(previousReleaseCommitHash); |
| 125 | + |
| 126 | + return previousReleaseCommitHash; |
| 127 | + }; |
| 128 | + |
| 129 | + /** |
| 130 | + * Given a release commit hash A that has tags for one or more package versions, |
| 131 | + * walk through release history and find the previous release tags of all of the packages that were released in commit A |
| 132 | + * |
| 133 | + * Note that this does not mean just looking up the previous release tags. |
| 134 | + * It may be the case that package-A was released in release-5 but the previous release of package-A happened in release-2. |
| 135 | + * This method will walk through past release tags until it finds the previous version of all of the input package versions |
| 136 | + * If a previous version of some package cannot be found, a warning is printed. |
| 137 | + */ |
| 138 | + getPreviousReleaseTags = async (releaseCommitHash: string) => { |
| 139 | + await this.validateReleaseCommitHash(releaseCommitHash); |
| 140 | + const releaseTags = await this.getTagsAtCommit(releaseCommitHash); |
| 141 | + |
| 142 | + // create a set of just the package names (strip off the version suffix) associated with this release commit |
| 143 | + const packageNamesRemaining = new Set(releaseTags.map(releaseTagToNameAndVersion).map(nameAndVersion => nameAndVersion.packageName)); |
| 144 | + |
| 145 | + let releaseCommitCursor = releaseCommitHash; |
| 146 | + |
| 147 | + // the method return value that we will append release tags to in the loop |
| 148 | + const previousReleaseTags: string[] = []; |
| 149 | + |
| 150 | + while (packageNamesRemaining.size > 0) { |
| 151 | + try { |
| 152 | + releaseCommitCursor = await this.getNearestReleaseCommit(releaseCommitCursor, { inclusive: false }); |
| 153 | + const releaseTagsAtCursor = await this.getTagsAtCommit(releaseCommitCursor); |
| 154 | + releaseTagsAtCursor.forEach(releaseTag => { |
| 155 | + const { packageName } = releaseTagToNameAndVersion(releaseTag); |
| 156 | + if (packageNamesRemaining.has(packageName)) { |
| 157 | + // this means we've found the previous version of "packageNameRemaining" that was released in releaseCommitHash |
| 158 | + // so we add it to the return list and remove it from the search set |
| 159 | + previousReleaseTags.push(releaseTag); |
| 160 | + packageNamesRemaining.delete(packageName); |
| 161 | + } |
| 162 | + }); |
| 163 | + } catch { |
| 164 | + console.warn(`Previous release not found for ${packageNamesRemaining}.`); |
| 165 | + packageNamesRemaining.clear(); |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + return previousReleaseTags; |
| 170 | + }; |
| 171 | + |
| 172 | + private validateReleaseCommitHash = async (releaseCommitHash: string) => { |
| 173 | + // check that the hash points to a valid commit |
| 174 | + const { stdout: hashType } = await this.exec('git', ['cat-file', '-t', releaseCommitHash]); |
| 175 | + if (hashType !== 'commit') { |
| 176 | + throw new Error(`Hash ${releaseCommitHash} does not point to a commit in the git tree`); |
| 177 | + } |
| 178 | + |
| 179 | + // check that the commit hash points to a release commit |
| 180 | + const { stdout: commitMessage } = await this.exec('git', ['log', '-1', '--pretty="%s"', releaseCommitHash]); |
| 181 | + if (!commitMessage.includes('chore(release)')) { |
| 182 | + throw new Error(` |
| 183 | + Expected release commit message to include "chore(release)". |
| 184 | + Instead found ${commitMessage}. |
| 185 | + Make sure commit ${releaseCommitHash} points to a release commit. |
| 186 | + `); |
| 187 | + } |
| 188 | + |
| 189 | + // check that this commit was made by the amplify-data-ci bot |
| 190 | + const { stdout: commitAuthor } = await this.exec('git', ['log', '-1', '--pretty="%an"', releaseCommitHash]); |
| 191 | + if (!commitAuthor.includes('amplify-data-ci')) { |
| 192 | + throw new Error(` |
| 193 | + Expected release commit commit to be authored by amplify-data-ci. |
| 194 | + Instead found ${commitAuthor}. |
| 195 | + Make sure commit ${releaseCommitHash} points to a release commit. |
| 196 | + `); |
| 197 | + } |
| 198 | + |
| 199 | + // get the release tags associated with the commit |
| 200 | + const releaseTags = await this.getTagsAtCommit(releaseCommitHash); |
| 201 | + |
| 202 | + if (releaseTags.length === 0) { |
| 203 | + throw new Error(` |
| 204 | + Expected release commit to have associated git tags but none found. |
| 205 | + Make sure commit ${releaseCommitHash} points to a release commit. |
| 206 | + `); |
| 207 | + } |
| 208 | + }; |
| 209 | +} |
0 commit comments