157 lines
8.0 KiB
TypeScript
157 lines
8.0 KiB
TypeScript
// Abstract-away Git interactions, so they can be mocked in tests
|
|
|
|
import { execSync } from "child_process";
|
|
import { Commit, ExecSyncError, RepoId } from "./types";
|
|
import { mkdirSync } from "fs";
|
|
import { format } from 'date-fns';
|
|
|
|
export function gitClone(dir: string, url: string) {
|
|
// Note - intentionally, this is not `execSync(..., { cwd: dir })` - because that will cause the repo to get
|
|
// cloned into `<dir>/<repo_name>`. We _could_ just pass the "working directory" as the `cwd` argument, but it's
|
|
// more direct to simply specify the path we want it cloned into.
|
|
execSync(`git clone ${url} ${dir}`);
|
|
}
|
|
|
|
export function getNLatestCommits(dir: string, n: number): Commit[] {
|
|
const logOutput = execSync(
|
|
// If you want to copy this formatting for debugging, it's:
|
|
//
|
|
// --pretty=format:'{"hash":"%h","author_name":"%an","author_email":"%ae","date":"%ai","message":"%s"}'
|
|
//
|
|
// TODO - return to this and figure out if these are _actually_ "useless escapes" or not - got a couple layers
|
|
// of string-parsing to consider here, I wouldn't want to bet without testing!
|
|
//eslint-disable-next-line no-useless-escape
|
|
`git log --max-count=${n} --pretty=format:'{\"hash\":\"%h\",\"author_name\":\"%an\",\"author_email\":\"%ae\",\"date\":\"%ai\",\"message\":\"%s\"}'`,
|
|
{ cwd: dir }
|
|
);
|
|
const logLines = logOutput.toString().split('\n')
|
|
return logLines.map(commitLine => {
|
|
// https://gist.github.com/textarcana/1306223
|
|
const parsed = JSON.parse(commitLine)
|
|
return {
|
|
hash: parsed.hash,
|
|
author_name: parsed.author_name,
|
|
author_email: parsed.author_email,
|
|
repo_path: dir,
|
|
date: parsed.date,
|
|
message: parsed.message
|
|
}
|
|
});
|
|
}
|
|
|
|
export function getCommitsSinceLatestBeforeGivenDate(dir: string, date: Date): Commit[] {
|
|
try {
|
|
const countingLogOutput = execSync(
|
|
`git log --since=${date.toISOString()} --pretty=oneline`,
|
|
{ cwd: dir }
|
|
);
|
|
const countedNumber = countingLogOutput.toString().split('\n').length;
|
|
console.log(`DEBUG - countedNumber (how many commits in target repo since oldest source commit) is: ${countedNumber}`);
|
|
// TODO - return to this and figure out if these are _actually_ "useless escapes" or not - got a couple layers
|
|
// of string-parsing to consider here, I wouldn't want to bet without testing!
|
|
const logOutput = execSync(
|
|
//eslint-disable-next-line no-useless-escape
|
|
`git log --max-count=${countedNumber+1} --pretty=format:'{\"hash\":\"%h\",\"author_name\":\"%an\",\"author_email\":\"%ae\",\"date\":\"%ai\",\"message\":\"%s\"}'`,
|
|
{ cwd: dir }
|
|
);
|
|
const logLines = logOutput.toString().split('\n');
|
|
return logLines.map(commitLine => {
|
|
const parsed = JSON.parse(commitLine)
|
|
return {
|
|
hash: parsed.hash,
|
|
author_name: parsed.author_name,
|
|
author_email: parsed.author_email,
|
|
repo_path: dir,
|
|
date: parsed.date,
|
|
message: parsed.message
|
|
}
|
|
});
|
|
} catch (e) {
|
|
// Just in case the error is not an `ExecSyncError` - print it out so we can see what it is.
|
|
console.log(e);
|
|
const error = e as ExecSyncError
|
|
// No commits in the target repo - return an empty array, which will result in the first representative commit
|
|
// being made as the first commit. And then we can iterate as normal (recalling that the target history is
|
|
// refreshed _from local repo_ - incurring no network charges) from there on.
|
|
const errorOutputAsString = '' + error.output[2]
|
|
if (!errorOutputAsString.includes('does not have any commits yet')) {
|
|
console.log(`Unexpected error: ${errorOutputAsString}`);
|
|
throw Error(`Unexpected error while building target commit history`, {
|
|
cause: error
|
|
})
|
|
}
|
|
// Fresh target repo - just write into it (by returning an empty array of target commits as target history)
|
|
// (i.e. doing nothing)
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function insertRepresentativeCommit(dir: string,sourceRepo: RepoId, sourceCommit: Commit, targetCommit: Commit | undefined, followOnTargetCommit: Commit | undefined): void {
|
|
// If there is a target commit,
|
|
if (targetCommit != undefined) {
|
|
execSync(`git reset --hard ${targetCommit.hash}`, {
|
|
cwd: dir
|
|
})
|
|
}
|
|
|
|
createRepresentativeCommit(dir,sourceRepo, sourceCommit);
|
|
// Then, if there is a follow-on target commit, we need to cherry-pick it onto the source commit:
|
|
if (followOnTargetCommit != undefined) {
|
|
execSync(`git cherry-pick ${followOnTargetCommit.hash}`, {
|
|
cwd: dir
|
|
})
|
|
}// else - nothing to cherry-pick back on top
|
|
}
|
|
|
|
export function gitPush(dir: string, tokenForTargetRepo: string, targetRepoId: RepoId) {
|
|
// Note that it must be a `-f`, because we are literally rewriting history.
|
|
execSync(`git push -f https://unused-username:${tokenForTargetRepo}@${targetRepoId.domain}/${targetRepoId.owner}/${targetRepoId.name}`, {
|
|
cwd: dir
|
|
})
|
|
// TODO - it'd be nice - before this `git push` is probably best - to add a `README.md` comment acknowledging
|
|
// the sync
|
|
}
|
|
|
|
function createRepresentativeCommit(dir: string, sourceRepo: RepoId,sourceCommit: Commit) {
|
|
// Create a commit that represents the source commit, but with a filename that is generated from the source commit's
|
|
// metadata.
|
|
//
|
|
// This is guaranteed to not cause conflicts with other commits, because the filename is generated from the source
|
|
// commit's metadata, and no two source commits will have the same metadata.
|
|
// (OK sure _technically_ these could have a collision, but...like...what are the odds?)
|
|
// TODO - figure out what the odds actually are, that'd be fun :P
|
|
const filename = `${sourceRepo.owner}/${sourceRepo.name}/${sourceCommit.hash}`
|
|
mkdirSync(dir + '/' + sourceRepo.owner + '/' + sourceRepo.name, { recursive: true });
|
|
execSync(`touch ${filename}`, {
|
|
cwd: dir
|
|
})
|
|
execSync(`git add ${filename}`, {
|
|
cwd: dir
|
|
})
|
|
|
|
// Seems like setting `--author` on `git commit` is not sufficient - still need to set `user` as well (I guess those
|
|
// are the difference between `comitted by` and `written by`?)
|
|
// Confirmed by following the instructions [here](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/managing-contribution-settings-on-your-profile/why-are-my-contributions-not-showing-up-on-my-profile#your-local-git-commit-email-isnt-connected-to-your-account)
|
|
// to check the "made by" address, and confirming that it did not match the email set in the `--author` flag.
|
|
// Note that, contrary to advice given by the CLI, this does not use the global config, but the local one - because,
|
|
// otherwise, if this was run locally, it would mess up the host system's config.
|
|
execSync(`git config user.email "${sourceCommit.author_email}"`, { cwd: dir });
|
|
execSync(`git config user.name "${sourceCommit.author_name}"`, { cwd: dir });
|
|
|
|
try {
|
|
// Do _not_ arbitrarily remove the `hash` - it's used for signalling identity in `main()`
|
|
const args = `"${sourceRepo.owner}/${sourceRepo.name}: ${sourceCommit.message} - ${sourceCommit.hash}" --date="${format(sourceCommit.date, 'yyyy-MM-dd HH:mm:ss')}" --author="${sourceCommit.author_name} <${sourceCommit.author_email}>"`;
|
|
console.log(`About to commit with args ${args}`);
|
|
// https://github.com/Shpota/github-activity-generator/blob/main/contribute.py#L63
|
|
// "%Y-%m-%d %H:%M:%S"
|
|
execSync(`git commit -m ${args}`, {
|
|
cwd: dir
|
|
})
|
|
} catch (e) {
|
|
console.log(e);
|
|
const error = e as ExecSyncError;
|
|
console.log(`DEBUG - error while creating representative commit: ${'' + error.output[2]} ... ${'' + error.output[1]}`);
|
|
throw e;
|
|
}
|
|
}
|