generators/repo/src/commands/generate.ts (357 lines of code) (raw):
import yaml from "yamljs";
import { IOptions } from "glob";
import path from "path";
import os from "os";
import fs from "fs/promises";
import ansiEscapes from "ansi-escapes";
import chalk from "chalk";
import { cleanDirectoryPath, ensureRelativeBasePath, copyFile, createRepoUrlFromRemote, ensureDirectoryPath, getGlobFiles, getRepoPropsFromRemote, isStringNullOrEmpty, RepoProps, writeHeader,isFilePath } from "../common/util";
import { AssetRule, RewriteRule, GitRemote, RepomanCommand, RepomanCommandOptions, RepoManifest } from "../models";
import { GitRepo } from "../tools/git";
export interface GenerateCommandOptions extends RepomanCommandOptions {
source: string
output: string
templateFile: string
update: boolean
https?: boolean
failOnUpdateError?: boolean
remote?: string
branch?: string
message?: string
resultsFile?: string
}
export interface RemotePushResult extends RepoProps {
pushed: boolean
hasChanges: boolean
hasChangesFromBase: boolean
remote: string
branch: string
branchUrl?: string
compareUrl?: string
}
export class GenerateCommand implements RepomanCommand {
private sourcePath: string;
private templateFile: string;
private manifest: RepoManifest;
private outputPath: string;
private generatePath: string;
private assetRules: AssetRule[];
private rewriteRules: RewriteRule[];
constructor(private options: GenerateCommandOptions) {
this.sourcePath = path.resolve(path.normalize(options.source));
const rootOutputPath = path.resolve(path.normalize(options.output));
this.templateFile = path.join(this.sourcePath, options.templateFile);
try {
this.manifest = yaml.load(this.templateFile);
this.outputPath = path.join(rootOutputPath, this.manifest.metadata.name);
this.generatePath = path.join(rootOutputPath, "generated");
this.assetRules = [...this.manifest.repo.assets];
if (this.manifest.repo.includeProjectAssets) {
this.assetRules.unshift({
from: ".",
to: ".",
ignore: ["repo.y[a]ml"]
});
}
this.rewriteRules =(this.manifest.repo.rewrite) ? [...this.manifest.repo.rewrite?.rules] : [];
}
catch (err) {
console.error(chalk.red(`Repo template manifest not found at '${this.templateFile}'`));
throw err;
}
}
public execute = async () => {
writeHeader(`Project: ${this.manifest.metadata.name}`, { color: chalk.cyanBright, char: "=" });
console.info(chalk.white(`Template: ${chalk.green(this.templateFile)}`));
console.info(chalk.white(`Source: ${chalk.green(this.sourcePath)}`));
console.info(chalk.white(`Destination: ${chalk.green(this.outputPath)}`));
console.info();
await ensureDirectoryPath(this.generatePath);
await cleanDirectoryPath(this.generatePath);
console.info(chalk.cyan('Repo generation started...'));
for (const rule of this.assetRules) {
await this.processAssetRule(rule);
}
console.info();
for (const rule of this.rewriteRules) {
await this.processRewriteRule(rule);
}
console.info(chalk.cyan('Repo generation completed.'));
console.info();
const repo = await this.validateRepo();
if (this.options.update && repo) {
await this.updateRemotes(repo);
}
}
private validateRepo = async (): Promise<GitRepo | undefined> => {
const repo = new GitRepo(this.outputPath);
if (!this.manifest.repo.remotes || this.manifest.repo.remotes.length === 0) {
console.warn(chalk.yellowBright("Remotes manifest is missing 'remotes' configuration and is unable to push changes"));
return;
}
return repo;
}
private updateRemotes = async (repo: GitRepo): Promise<void> => {
const results: RemotePushResult[] = [];
for (const remote of this.manifest.repo.remotes) {
try {
const remotePushResult = await this.updateRemote(repo, remote);
results.push(remotePushResult);
}
catch (err) {
console.error(chalk.red(`Error updating remote '${remote.name}', Message: ${err}`))
if (this.options.failOnUpdateError) {
throw err;
}
}
console.info();
}
await this.writeResultsFile(results);
}
private updateRemote = async (repo: GitRepo, remote: GitRemote): Promise<RemotePushResult> => {
const defaultBranch = remote.branch || "main";
const targetBranch = this.options.branch || defaultBranch;
const repoProps = getRepoPropsFromRemote(remote.url);
let targetRemote = remote;
const remoteHttpUrl = createRepoUrlFromRemote(targetRemote.url);
const branchUrl = `${remoteHttpUrl}/tree/${targetBranch}`;
const compareUrl = `${remoteHttpUrl}/compare/${defaultBranch}...${targetBranch}`
let updateResult: RemotePushResult = {
hasChanges: false,
hasChangesFromBase: false,
pushed: false,
remote: remote.name,
branch: targetBranch,
branchUrl,
compareUrl,
...repoProps
};
if (this.options.https && repoProps.host == 'github.com') {
console.info(chalk.white(`Using HTTPS URL for GitHub repo`))
targetRemote = {
name: remote.name,
url: `${remoteHttpUrl}.git`,
branch: remote.branch,
} as GitRemote;
}
writeHeader(`Remote: ${targetRemote.name || "???"}`, { color: chalk.cyanBright });
console.info(chalk.white(`Remote: ${chalk.cyan(targetRemote.url)}`));
console.info(chalk.white(`Host: ${chalk.cyan(updateResult.host)}`));
console.info(chalk.white(`Org: ${chalk.cyan(updateResult.org)}`));
console.info(chalk.white(`Repo: ${chalk.cyan(updateResult.repo)}`));
console.info();
if (isStringNullOrEmpty(targetRemote.name)) {
console.error(chalk.red(`Missing remote name in repo template: ${chalk.magentaBright(this.templateFile)}`));
return updateResult;
}
if (isStringNullOrEmpty(targetRemote.url)) {
console.error(chalk.red(`Remote url is required in repo template: ${chalk.magentaBright(this.templateFile)}`));
return updateResult;
}
if (this.options.remote && this.options.remote !== targetRemote.name) {
console.warn(chalk.yellowBright(`Skipping remote ${targetRemote.name} (${targetRemote.url})`));
return updateResult;
}
await this.initRemote(repo, targetRemote, defaultBranch, targetBranch);
const hasChanges = await this.commitChanges(repo, defaultBranch, targetBranch);
const hasChangesFromBase = await repo.hasChangesFromBase(defaultBranch)
if (!hasChanges) {
return { ...updateResult, hasChanges, hasChangesFromBase };
}
console.info(chalk.cyan(`Pushing changes to ${chalk.cyanBright(targetBranch)}...`));
await repo.push(targetRemote.name, targetBranch);
updateResult = {
...updateResult,
hasChanges,
hasChangesFromBase,
pushed: true
};
console.info();
console.info(chalk.white(`Changes are available @ ${chalk.greenBright(branchUrl)}`));
console.info(chalk.white(`Compare @ ${chalk.greenBright(compareUrl)}`));
return updateResult;
}
private initRemote = async (repo: GitRepo, remote: GitRemote, defaultBranch: string, targetBranch: string) => {
await ensureDirectoryPath(this.outputPath);
await cleanDirectoryPath(this.outputPath);
console.info(chalk.white(`Cloning repo for remote...`));
await repo.clone(remote.name, remote.url);
const defaultBranchExists = await repo.remoteBranchExists(remote.name, defaultBranch);
if (!defaultBranchExists) {
console.warn(chalk.yellowBright(`Remote does not have branch ${chalk.cyan(defaultBranch)}`));
console.info(`Creating default branch ${chalk.cyan(defaultBranch)}...`);
await repo.createBranch(defaultBranch);
await repo.commit("Initial Commit", { empty: true });
await repo.push(remote.name, defaultBranch);
} else {
await repo.checkoutBranch(defaultBranch);
const pullBranchExists = await repo.remoteBranchExists(remote.name, defaultBranch);
if (pullBranchExists) {
console.info(chalk.cyan(`Pulling changes from branch ${chalk.cyanBright(defaultBranch)}...`));
await repo.pull(remote.name, defaultBranch);
}
}
const branchExists = await repo.remoteBranchExists(remote.name, targetBranch);
if (!branchExists) {
console.warn(chalk.yellowBright(`Branch '${targetBranch}' doesn't exist on remote`))
console.info(chalk.white(`Creating new branch: ${chalk.cyan(targetBranch)}`));
await repo.createBranch(targetBranch);
}
const currentBranch = await repo.getCurrentBranch();
if (currentBranch !== targetBranch) {
console.info(chalk.white(`Checking out branch: ${chalk.cyan(targetBranch)}`));
await repo.checkoutBranch(targetBranch);
}
}
private stageFiles = async (repo: GitRepo) => {
console.info(chalk.white(`Staging files from generated output`));
const globOptions: IOptions = {
cwd: this.generatePath,
nodir: true,
dot: true,
matchBase: true,
}
const files = await getGlobFiles("**/*", globOptions);
// Clean the folder except for the .git folder then overlay changes
// This correctly detects for file deletes/renames/moves
await cleanDirectoryPath(this.outputPath, false);
await this.copyFiles(files, this.generatePath, this.outputPath);
await repo.addAll();
}
private commitChanges = async (repo: GitRepo, defaultBranch: string, targetBranch: string): Promise<boolean> => {
await this.stageFiles(repo);
const hasChanges = await repo.hasChanges();
if (!hasChanges) {
console.warn(chalk.yellowBright(`No new changes found to commit when comparing between ${chalk.cyan(defaultBranch)} and ${chalk.cyan(targetBranch)}.`));
return false;
}
const changes = await repo.status();
console.info();
console.info(chalk.white(`Found the following ${chalk.cyan(changes.length)} change(s)...`));
changes.map(line => console.info(chalk.white(`- ${line}`)));
const commitMessage = this.options.message || "Synchronize repo from Repoman"
console.info(chalk.white(`Committing ${chalk.cyan(changes.length)} change(s) to remote with message: ${chalk.green(commitMessage)}`));
console.info();
await repo.commit(commitMessage);
return true;
}
private writeResultsFile = async (results: RemotePushResult[]) => {
return new Promise<void>(async (resolve, reject) => {
const pushedResults = results.filter(r => r.hasChangesFromBase);
if (pushedResults.length === 0 || !this.options.resultsFile) {
return resolve();
}
const resultsFilePath = path.resolve(path.normalize(this.options.resultsFile));
const resultsFile = await fs.open(resultsFilePath, "a+");
const resultsStream = resultsFile.createWriteStream();
try {
const output: string[] = [];
output.push(`### Project: **${this.manifest.metadata.name}**`);
for (const result of pushedResults) {
output.push(`#### Remote: **${result.remote}**`);
output.push(`##### Branch: **${result.branch}**`);
output.push('');
output.push('You can initialize this project with:');
output.push('```bash');
output.push(`azd init -t ${result.org}/${result.repo} -b ${result.branch}`);
output.push('```');
output.push('');
output.push(`[View Changes](${result.branchUrl}) | [Compare Changes](${result.compareUrl})`);
output.push('');
output.push('---');
output.push('');
}
if (this.options.debug) {
console.debug(chalk.grey("RESULTS OUTPUT"))
console.debug(chalk.grey(output.join(os.EOL)));
}
resultsStream.write(output.join(os.EOL), (error) => {
if (error) {
return reject(error);
}
resolve();
})
}
finally {
resultsStream.close();
resultsFile.close();
console.info(chalk.cyan(`Push results written to '${resultsFilePath}'`));
}
});
}
private processAssetRule = async (rule: AssetRule) => {
const absoluteSourcePath = path.resolve(this.sourcePath, rule.from);
const absoluteDestPath = path.join(this.generatePath, rule.to);
console.info(chalk.white(`Copying asset(s) from ${chalk.cyan(rule.from)} to ${chalk.cyan(rule.to)}...`));
// check if this is filepath
if (await isFilePath(absoluteSourcePath)) {
await copyFile(absoluteSourcePath, absoluteDestPath);
return;
}
// Default to all files if no patterns defined
const patterns = rule.patterns ?? ["**/*"];
const globOptions: IOptions = {
cwd: absoluteSourcePath,
ignore: rule.ignore,
nodir: true,
dot: true,
matchBase: true,
};
for (const pattern of patterns) {
const files = await getGlobFiles(pattern, globOptions);
if (files.length === 0) {
console.warn(chalk.yellowBright(`- No files found matching pattern '${pattern}' in '${globOptions.cwd}'`))
continue;
}
console.info(chalk.white(ansiEscapes.cursorPrevLine + `Copying assets from ${chalk.cyan(rule.from)} to ${chalk.cyan(rule.to)}... (${files.length} files)`));
await this.copyFiles(files, absoluteSourcePath, absoluteDestPath);
}
}
private copyFiles = async (files: string[], sourceDirectoryPath: string, destDirectoryPath: string) => {
for (const filePath of files) {
const sourcePath = path.join(sourceDirectoryPath, filePath);
const destPath = path.join(destDirectoryPath, filePath);
await copyFile(sourcePath, destPath);
}
}
private processRewriteRule = async(rule: RewriteRule) => {
const globOptions: IOptions = {
cwd: this.generatePath,
ignore: rule.ignore,
matchBase: true,
nodir: true
};
const patterns = rule.patterns ?? [];
if(patterns.length == 0){
console.warn(chalk.yellowBright(`Skipping Rewrite Rule ${rule.from} => ${rule.to}. No pattern found. Add a pattern of '**/*' to apply this rule to all files.`));
}
for (const pattern of patterns) {
const files = await getGlobFiles(pattern, globOptions);
for (const filePath of files) {
await this.rewritePath(rule, filePath);
}
}
}
private rewritePath = async(rule: RewriteRule, filePath: string) => {
const destFilePath = path.join(this.generatePath, filePath);
const destFolderPath = path.dirname(destFilePath);
const buffer = await fs.readFile(destFilePath);
let contents = buffer.toString('utf8');
if(contents.indexOf(rule.from) == -1) return;
console.info(chalk.cyan(` -> Rewriting relative paths ${rule.from} => ${rule.to} for file "${filePath}"`));
contents = contents.replaceAll(rule.from, rule.to);
// Normalize transformed paths
const pathRegex = new RegExp(/((?:\.{1,2}[\/\\]{1,2})+[^'"\s]*)/gm);
const matches = contents.match(pathRegex);
if (matches && matches.length > 0) {
for (const match of matches) {
if(match.indexOf(rule.to) > -1){
// Generate the absolute path to the referenced match
let refPath = path.resolve(destFolderPath, path.normalize(match))
// Generate the relative path between the current processed file dir path & the referenced match path
let relativePath = path.relative(destFolderPath, refPath)
relativePath = ensureRelativeBasePath(relativePath);
// Finally convert the path back to a POSIX compatible path
relativePath = relativePath.split(path.sep).join(path.posix.sep)
contents = contents.replaceAll(match, relativePath);
if (this.options.debug) {
console.log(chalk.grey(` -> Rewriting relative path ${match} => ${relativePath} in ${destFilePath}`));
}
}
}
}
await fs.writeFile(destFilePath, contents);
}
}