mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
283 lines
No EOL
8.2 KiB
TypeScript
283 lines
No EOL
8.2 KiB
TypeScript
import { CheckRepoActions, GitConfigScope, simpleGit, SimpleGitProgressEvent } from 'simple-git';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import { env } from './env.js';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { existsSync } from 'node:fs';
|
|
|
|
type onProgressFn = (event: SimpleGitProgressEvent) => void;
|
|
|
|
/**
|
|
* Creates a simple-git client that has it's working directory
|
|
* set to the given path.
|
|
*/
|
|
const createGitClientForPath = (path: string, onProgress?: onProgressFn, signal?: AbortSignal) => {
|
|
if (!existsSync(path)) {
|
|
throw new Error(`Path ${path} does not exist`);
|
|
}
|
|
|
|
const parentPath = resolve(dirname(path));
|
|
|
|
const git = simpleGit({
|
|
progress: onProgress,
|
|
abort: signal,
|
|
})
|
|
.env({
|
|
...process.env,
|
|
/**
|
|
* @note on some inside-baseball on why this is necessary: The specific
|
|
* issue we saw was that a `git clone` would fail without throwing, and
|
|
* then a subsequent `git config` command would run, but since the clone
|
|
* failed, it wouldn't be running in a git directory. Git would then walk
|
|
* up the directory tree until it either found a git directory (in the case
|
|
* of the development env) or it would hit a GIT_DISCOVERY_ACROSS_FILESYSTEM
|
|
* error when trying to cross a filesystem boundary (in the prod case).
|
|
* GIT_CEILING_DIRECTORIES ensures that this walk will be limited to the
|
|
* parent directory.
|
|
*/
|
|
GIT_CEILING_DIRECTORIES: parentPath,
|
|
})
|
|
.cwd({
|
|
path,
|
|
});
|
|
|
|
return git;
|
|
}
|
|
|
|
export const cloneRepository = async (
|
|
{
|
|
cloneUrl,
|
|
authHeader,
|
|
path,
|
|
onProgress,
|
|
signal,
|
|
}: {
|
|
cloneUrl: string,
|
|
authHeader?: string,
|
|
path: string,
|
|
onProgress?: onProgressFn
|
|
signal?: AbortSignal
|
|
}
|
|
) => {
|
|
try {
|
|
await mkdir(path, { recursive: true });
|
|
|
|
const git = createGitClientForPath(path, onProgress, signal);
|
|
|
|
const cloneArgs = [
|
|
"--bare",
|
|
...(authHeader ? ["-c", `http.extraHeader=${authHeader}`] : [])
|
|
];
|
|
|
|
await git.clone(cloneUrl, path, cloneArgs);
|
|
|
|
await unsetGitConfig({
|
|
path,
|
|
keys: ["remote.origin.url"],
|
|
signal,
|
|
});
|
|
} catch (error: unknown) {
|
|
const baseLog = `Failed to clone repository: ${path}`;
|
|
|
|
if (env.SOURCEBOT_LOG_LEVEL !== "debug") {
|
|
// Avoid printing the remote URL (that may contain credentials) to logs by default.
|
|
throw new Error(`${baseLog}. Set environment variable SOURCEBOT_LOG_LEVEL=debug to see the full error message.`);
|
|
} else if (error instanceof Error) {
|
|
throw new Error(`${baseLog}. Reason: ${error.message}`);
|
|
} else {
|
|
throw new Error(`${baseLog}. Error: ${error}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const fetchRepository = async (
|
|
{
|
|
cloneUrl,
|
|
authHeader,
|
|
path,
|
|
onProgress,
|
|
signal,
|
|
}: {
|
|
cloneUrl: string,
|
|
authHeader?: string,
|
|
path: string,
|
|
onProgress?: onProgressFn,
|
|
signal?: AbortSignal
|
|
}
|
|
) => {
|
|
const git = createGitClientForPath(path, onProgress, signal);
|
|
try {
|
|
if (authHeader) {
|
|
await git.addConfig("http.extraHeader", authHeader);
|
|
}
|
|
|
|
await git.fetch([
|
|
cloneUrl,
|
|
"+refs/heads/*:refs/heads/*",
|
|
"--prune",
|
|
"--progress"
|
|
]);
|
|
} catch (error: unknown) {
|
|
const baseLog = `Failed to fetch repository: ${path}`;
|
|
if (env.SOURCEBOT_LOG_LEVEL !== "debug") {
|
|
// Avoid printing the remote URL (that may contain credentials) to logs by default.
|
|
throw new Error(`${baseLog}. Set environment variable SOURCEBOT_LOG_LEVEL=debug to see the full error message.`);
|
|
} else if (error instanceof Error) {
|
|
throw new Error(`${baseLog}. Reason: ${error.message}`);
|
|
} else {
|
|
throw new Error(`${baseLog}. Error: ${error}`);
|
|
}
|
|
} finally {
|
|
if (authHeader) {
|
|
await git.raw(["config", "--unset", "http.extraHeader", authHeader]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies the gitConfig to the repo at the given path. Note that this will
|
|
* override the values for any existing keys, and append new values for keys
|
|
* that do not exist yet. It will _not_ remove any existing keys that are not
|
|
* present in gitConfig.
|
|
*/
|
|
export const upsertGitConfig = async (
|
|
{
|
|
path,
|
|
gitConfig,
|
|
onProgress,
|
|
signal,
|
|
}: {
|
|
path: string,
|
|
gitConfig: Record<string, string>,
|
|
onProgress?: onProgressFn,
|
|
signal?: AbortSignal
|
|
}) => {
|
|
const git = createGitClientForPath(path, onProgress, signal);
|
|
|
|
try {
|
|
for (const [key, value] of Object.entries(gitConfig)) {
|
|
await git.addConfig(key, value);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
throw new Error(`Failed to set git config ${path}: ${error.message}`);
|
|
} else {
|
|
throw new Error(`Failed to set git config ${path}: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsets the specified keys in the git config for the repo at the given path.
|
|
* If a key is not set, this is a no-op.
|
|
*/
|
|
export const unsetGitConfig = async (
|
|
{
|
|
path,
|
|
keys,
|
|
onProgress,
|
|
signal,
|
|
}: {
|
|
path: string,
|
|
keys: string[],
|
|
onProgress?: onProgressFn,
|
|
signal?: AbortSignal
|
|
}) => {
|
|
const git = createGitClientForPath(path, onProgress, signal);
|
|
|
|
try {
|
|
const configList = await git.listConfig();
|
|
const setKeys = Object.keys(configList.all);
|
|
|
|
for (const key of keys) {
|
|
if (setKeys.includes(key)) {
|
|
await git.raw(['config', '--unset', key]);
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
throw new Error(`Failed to unset git config ${path}: ${error.message}`);
|
|
} else {
|
|
throw new Error(`Failed to unset git config ${path}: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if `path` is the _root_ of a git repository.
|
|
*/
|
|
export const isPathAValidGitRepoRoot = async ({
|
|
path,
|
|
onProgress,
|
|
signal,
|
|
}: {
|
|
path: string,
|
|
onProgress?: onProgressFn,
|
|
signal?: AbortSignal
|
|
}) => {
|
|
if (!existsSync(path)) {
|
|
return false;
|
|
}
|
|
|
|
const git = createGitClientForPath(path, onProgress, signal);
|
|
|
|
try {
|
|
return git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
throw new Error(`isPathAGitRepoRoot failed: ${error.message}`);
|
|
} else {
|
|
throw new Error(`isPathAGitRepoRoot failed: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const isUrlAValidGitRepo = async (url: string) => {
|
|
const git = simpleGit();
|
|
|
|
// List the remote heads. If an exception is thrown, the URL is not a valid git repo.
|
|
try {
|
|
const result = await git.listRemote(['--heads', url]);
|
|
return result.trim().length > 0;
|
|
} catch (error: unknown) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export const getOriginUrl = async (path: string) => {
|
|
const git = createGitClientForPath(path);
|
|
|
|
try {
|
|
const remotes = await git.getConfig('remote.origin.url', GitConfigScope.local);
|
|
return remotes.value;
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
throw new Error(`Failed to get origin for ${path}: ${error.message}`);
|
|
} else {
|
|
throw new Error(`Failed to get origin for ${path}: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const getBranches = async (path: string) => {
|
|
const git = createGitClientForPath(path);
|
|
const branches = await git.branch();
|
|
return branches.all;
|
|
}
|
|
|
|
export const getTags = async (path: string) => {
|
|
const git = createGitClientForPath(path);
|
|
const tags = await git.tags();
|
|
return tags.all;
|
|
}
|
|
|
|
export const getCommitHashForRefName = async ({
|
|
path,
|
|
refName,
|
|
}: {
|
|
path: string,
|
|
refName: string,
|
|
}) => {
|
|
const git = createGitClientForPath(path);
|
|
const rev = await git.revparse(refName);
|
|
return rev;
|
|
} |