From dc063b980ce5558e73e343cc2897d992665b5621 Mon Sep 17 00:00:00 2001 From: Tugrul Ates Date: Thu, 13 Feb 2025 23:39:44 +0100 Subject: [PATCH] feat(git): add git package (#6) --- .github/workflows/title.yml | 1 + core/git/conventional.ts | 43 ++ core/git/conventional_test.ts | 94 ++++ core/git/deno.json | 7 + core/git/git.ts | 804 +++++++++++++++++++++++++++++++ core/git/git_test.ts | 872 ++++++++++++++++++++++++++++++++++ deno.json | 1 + 7 files changed, 1822 insertions(+) create mode 100644 core/git/conventional.ts create mode 100644 core/git/conventional_test.ts create mode 100644 core/git/deno.json create mode 100644 core/git/git.ts create mode 100644 core/git/git_test.ts diff --git a/.github/workflows/title.yml b/.github/workflows/title.yml index cf45106..95ecf90 100644 --- a/.github/workflows/title.yml +++ b/.github/workflows/title.yml @@ -38,4 +38,5 @@ jobs: http git github + git testing diff --git a/core/git/conventional.ts b/core/git/conventional.ts new file mode 100644 index 0000000..54cf396 --- /dev/null +++ b/core/git/conventional.ts @@ -0,0 +1,43 @@ +import type { Commit } from "@roka/git"; +import { assert } from "@std/assert"; + +/** + * A commit object that exposes conventional commit details. + * + * For example, a commit summary like `feat(cli): add new command` will have + * its type set to `feat` and modules set to `cli`. + * + * A {@linkcode ConventionalCommit} object can be converted to a + * {@linkcode ConventionalCommit} using the {@linkcode conventional} function. + * + * @see {@link https://www.conventionalcommits.org|Conventional Commits} + */ +export interface ConventionalCommit extends Commit { + /** Conventional commits: Commit description. */ + description: string; + /** Conventional commits: Commit type. */ + type: string | undefined; + /** Conventional commits: Modules affected by the commit. */ + modules: string[]; + /** Conventional commits: Whether the commit is a breaking change. */ + breaking: boolean; +} + +const SUMMARY_PATTERN = + /^(?:(?[a-z]+)(?:\((?[^()]*)\))?(?!?):s*)?\s*(?[^\s].*)$/; + +/** Creates a commit object with conventional commit details. */ +export function conventional(commit: Commit): ConventionalCommit { + const match = commit.summary?.match(SUMMARY_PATTERN); + const footerBreaking = commit.body?.match( + /(^|\n\n)BREAKING CHANGE: (.+)($|\n)/, + ); + assert(match?.groups?.description, "Commit must have description"); + return { + ...commit, + description: match.groups.description, + type: match.groups.type, + modules: match.groups.modules?.split(",").map((m) => m.trim()) ?? [], + breaking: !!footerBreaking || !!match.groups.breaking, + }; +} diff --git a/core/git/conventional_test.ts b/core/git/conventional_test.ts new file mode 100644 index 0000000..eecc875 --- /dev/null +++ b/core/git/conventional_test.ts @@ -0,0 +1,94 @@ +import type { Commit } from "@roka/git"; +import { conventional } from "@roka/git/conventional"; +import { assertEquals } from "@std/assert"; + +function testCommit(summary: string): Commit { + return { + hash: "hash", + short: "short", + author: { name: "author-name", email: "author-email" }, + committer: { name: "committer-name", email: "committer-email" }, + summary: summary, + body: "body", + }; +} + +Deno.test("conventional() creates conventional commits", () => { + const commit = testCommit("feat(module): description"); + assertEquals(conventional(commit), { + ...commit, + description: "description", + type: "feat", + modules: ["module"], + breaking: false, + }); +}); + +Deno.test("conventional() accepts be simple commits", () => { + const commit = testCommit("description"); + assertEquals(conventional(commit), { + ...commit, + description: "description", + type: undefined, + modules: [], + breaking: false, + }); +}); + +Deno.test("conventional() can create breaking commits", () => { + const commit = testCommit("feat!: description"); + assertEquals(conventional(commit), { + ...commit, + description: "description", + type: "feat", + modules: [], + breaking: true, + }); +}); + +Deno.test("conventional() can create breaking commits from footer", () => { + const commit = { + ...testCommit("feat: description"), + body: "BREAKING CHANGE: breaking", + }; + assertEquals(conventional(commit), { + ...commit, + description: "description", + type: "feat", + modules: [], + breaking: true, + }); +}); + +Deno.test("conventional() can create breaking commit with module", () => { + const commit = testCommit("feat(module)!: description"); + assertEquals(conventional(commit), { + ...commit, + description: "description", + type: "feat", + modules: ["module"], + breaking: true, + }); +}); + +Deno.test("conventional() can create multiple modules", () => { + const commit = testCommit("feat(module1,module2): description"); + assertEquals(conventional(commit), { + ...commit, + description: "description", + type: "feat", + modules: ["module1", "module2"], + breaking: false, + }); +}); + +Deno.test("conventional() commits must have a description", () => { + const commit = testCommit("feat(module): "); + assertEquals(conventional(commit), { + ...commit, + description: "feat(module): ", + type: undefined, + modules: [], + breaking: false, + }); +}); diff --git a/core/git/deno.json b/core/git/deno.json new file mode 100644 index 0000000..1f5b339 --- /dev/null +++ b/core/git/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@roka/git", + "exports": { + ".": "./git.ts", + "./conventional": "./conventional.ts" + } +} diff --git a/core/git/git.ts b/core/git/git.ts new file mode 100644 index 0000000..23475e5 --- /dev/null +++ b/core/git/git.ts @@ -0,0 +1,804 @@ +import { assert, assertEquals, assertFalse, assertGreater } from "@std/assert"; +import { join } from "@std/path/join"; + +// NOT IMPLEMENTED +// - most configs +// - merge, rebase, conflict resolution +// - stash +// - submodules +// - dates +// - verify signatures +// - prune + +/** An error while running a git command. */ +export class GitError extends Error { + /** + * Construct GitError. + * + * @param message The error message to be associated with this error. + */ + constructor( + message: string, + ) { + super(message); + this.name = "GitError"; + } +} + +/** A local repository with git commands. */ +export interface Git { + /** Local repository directory. */ + directory: string; + /** Returns the full path to a file in the repository. */ + path: (...parts: string[]) => string; + /** Configures repository options. */ + config: (config: Config) => Promise; + /** Initializes a new git repository. */ + init: (options?: InitOptions) => Promise; + /** Clones a remote repository. */ + clone: (url: string, options?: CloneOptions) => Promise; + /** Switches to a commit, or an existing or new branch. */ + checkout: (options?: GitCheckoutOptions) => Promise; + /** Returns the current branch name. */ + branch: () => Promise; + /** Stages files for commit. */ + add: (pathspecs: string | string[]) => Promise; + /** Removes files from the index. */ + remove: (pathspecs: string | string[]) => Promise; + /** Creates a new commit in the repository. */ + commit: ( + summary: string, + options?: CommitOptions, + ) => Promise; + /** Returns the history of commits in the repository. */ + log: (options?: LogOptions) => Promise; + /** Creates a new tag in the repository. */ + tag: (name: string, options?: TagOptions) => Promise; + /** Lists all tags in the repository. */ + tagList: (options?: TagListOptions) => Promise; + /** Adds a remote to the repository. */ + addRemote: (url: string, options?: RemoteOptions) => Promise; + /** Returns the remote repository URL. */ + remote: (options?: RemoteOptions) => Promise; + /** Returns the remote head branch of the repository. */ + remoteDefaultBranch: ( + options?: RemoteOptions, + ) => Promise; + /** Pushes commits to a remote. */ + push: (options?: PushOptions) => Promise; + /** Pushes a tag to a remote. */ + pushTag: (tag: Tag | string, options?: PushTagOptions) => Promise; + /** Pulls commits and tags from a remote. */ + pull: (options?: PullOptions) => Promise; +} + +/** A git ref that points to a commit. */ +export type Commitish = Commit | Tag | string; + +/** A single commit in the Git history. */ +export interface Commit { + /** Full hash of commit. */ + hash: string; + /** Short hash of commit. */ + short: string; + /** Commit summary, the first line of the commit message. */ + summary: string; + /** Commit body, excluding the first line from the message. */ + body: string; + /** Author, who wrote the code. */ + author: User; + /** Committter, who created the commit. */ + committer: User; +} + +/** A tag in the Git repository. */ +export interface Tag { + /** Tag name. */ + name: string; + /** Commit that is tagged. */ + commit: Commit; + /** Tag subject from tag message. */ + subject?: string; + /** Tag body from tag message. */ + body?: string; + /** Tagger, who created the tag. */ + tagger?: User; +} + +/** A revision range. */ +export interface RevisionRange { + /** Match objects that are descendants of this revision. */ + from?: Commitish; + /** Match objects that are ancestors of this revision. */ + to?: Commitish; + /** + * Match objects that are reachable from either end, but not from both. + * + * Ignored if either {@linkcode RevisionRange.from} or {@linkcode RevisionRange.to} is + * not set. + * + * @default {false} + */ + symmetric?: boolean; +} + +/** Git configuration options. */ +export interface Config { + /** Commit configuration. */ + commit?: { + /** Whether to sign commits. */ + gpgsign?: boolean; + }; + /** Tag configuration. */ + tag?: { + /** Whether to sign tags. */ + gpgsign?: boolean; + }; + /** User configuration. */ + user?: Partial & { + /** GPG key for signing commits. */ + signingkey?: string; + }; +} + +/** An author or commiter on git repository. */ +export interface User { + /** Name of the user. */ + name: string; + /** E-mail of the user. */ + email: string; +} + +/** Common options for running git commands. */ +export interface GitOptions { + /** + * Change working directory for git commands. + * @default {"."} + */ + cwd?: string; + /** + * Git configuration options. + * These will override repository or global configurations. + */ + config?: Config; +} + +/** Options for initializing repositories. */ +export interface InitOptions { + /** + * Create a bare repository. + * @default {false} + */ + bare?: boolean; + /** + * Name of the initial branch. + * + * Creates a new branch with this name for {@linkcode Git.init}, and checks out + * this branch for {@linkcode Git.clone}. + * + * Default is `main`, if not overridden with git config. + */ + branch?: string; +} + +/** Options for signing commits and tags. */ +export interface SignOptions { + /** + * Sign the commit with GPG. + * + * If `true` or a string, object is signed with the default or given GPG key. + * + * If `false`, the commit is not signed. + */ + sign?: boolean | string; +} + +/** Options for cloning repositories. */ +export interface CloneOptions extends InitOptions, RemoteOptions { + /** Set config for new repository, after initialization but before fetch. */ + config?: Config; + /** + * Number of commits to clone at the tip. + * + * Implies {@linkcode CloneOptions.singleBranch} unless it is set to + * `false` to fetch from the tip of all branches. + */ + depth?: number; + /** + * Bypasses local transport optimization when set to `false`. + * + * When the remote repository is specified as a URL, this is ignored, + * otherwise it is implied. + */ + local?: boolean; + /** + * Clone only tip of a single branch. + * + * The cloned branch is either remote `HEAD` or + * {@linkcode InitOptions.branch}. + */ + singleBranch?: boolean; + /** + * Fetch tags. + * @default {true} + */ + tags?: boolean; +} + +/** Options for checkout. */ +export interface GitCheckoutOptions { + /** + * Checkout at given commit or branch. + * @default {"HEAD"} + * + * A commit target implies {@linkcode GitCheckoutOptions.detach} to be `true`. + */ + target?: Commitish; + /** Branch to create and checkout during checkout. */ + newBranch?: string; + /** + * Detach `HEAD` during checkout from the target branch. + * @default {false} + */ + detach?: boolean; +} + +/** Options for creating git commits. */ +export interface CommitOptions extends SignOptions { + /** + * Automatically stage modified or deleted files known to git. + * @default {false} + */ + all?: boolean; + /** + * Allow empty commit. + * @default {false} + */ + allowEmpty?: boolean; + /** Amend the last commit. */ + amend?: boolean; + /** Author, who wrote the code. */ + author?: User | undefined; + /** Commit body to append to the message. */ + body?: string; +} + +/** Options for fetching git logs. */ +export interface LogOptions { + /** Only commits by an author. */ + author?: User; + /** Only commits by a committer. */ + committer?: User; + /** Only commts that any of the given paths. */ + paths?: string[]; + /** Only commits in a range. */ + range?: RevisionRange; + /** Maximum number of commits to return. */ + maxCount?: number; + /** Number of commits to skip. */ + skip?: number; + /** Only commits that either deleted or added the given text. */ + text?: string; +} + +/** Options for creating tags. */ +export interface TagOptions extends SignOptions { + /** + * Commit to tag. + * @default {"HEAD"} + */ + commit?: Commitish; + /** Tag message subject. */ + subject?: string; + /** Tag message body. */ + body?: string; + /** Replace existing tags instead of failing. */ + force?: boolean; +} + +/** Options for listing tags. */ +export interface TagListOptions { + /** Tag selection pattern. Default is all tags. */ + name?: string; + /** Only tags that contain the specific commit. */ + contains?: Commitish; + /** Only tags that do not contain the specific commit. */ + noContains?: Commitish; + /** Only tags of the given commit. */ + pointsAt?: Commitish; + /** + * Sort option. + * + * Setting to `version` uses semver order, returning latest versions first. + * + * @todo Handle pre-release versions. + */ + sort?: "version"; +} + +/** Options for adding or querying remotes. */ +export interface RemoteOptions { + /** + * Remote name. + * @default {"origin"} + */ + remote?: string; +} + +/** Options for pulling from or pushing to a remote. */ +export interface GitTransportOptions { + /** Either update all refs on the other side, or don't update any.*/ + atomic?: boolean; + /** Copy all tags. + * + * During pull, git only fetches tags that point to the downloaded objects. + * When this value is set to `true`, all tags are fetched. When it is set to + * `false`, no tags are fetched. + * + * During push, no tags are pushed by default. When this value is set to + * `true`, all tags are pushed. + */ + tags?: boolean; +} + +/** Options for pushing to a remote. */ +export interface PushOptions extends GitTransportOptions, RemoteOptions { + /** Remote branch to push to. Default is the tracked remote branch. */ + branch?: string; + /** Force push to remote. */ + force?: boolean; +} + +/** Options for pushing a tag to a remote. */ +export interface PushTagOptions extends RemoteOptions { + /** Force push to remote. */ + force?: boolean; +} + +/** Options for pulling from a remote. */ +export interface PullOptions + extends RemoteOptions, GitTransportOptions, SignOptions { + /** Remote branch to pull from. Default is the tracked remote branch. */ + branch?: string; +} + +/** Creates a new Git instance for a local repository. */ +export function git(options?: GitOptions): Git { + const gitOptions = options ?? {}; + return { + directory: options?.cwd ?? ".", + path(...parts: string[]) { + return join(this.directory, ...parts); + }, + async init(options) { + await run( + gitOptions, + "init", + options?.bare && "--bare", + options?.branch !== undefined && ["--initial-branch", options.branch], + ); + }, + async clone(url, options) { + await run( + gitOptions, + ["clone", url, "."], + options?.bare && "--bare", + options?.config && configArgs(options.config, "--config").flat(), + options?.depth !== undefined && ["--depth", `${options.depth}`], + options?.local === false && "--no-local", + options?.local === true && "--local", + options?.remote !== undefined && ["--origin", options.remote], + options?.branch !== undefined && ["--branch", options.branch], + options?.singleBranch === false && "--no-single-branch", + options?.singleBranch === true && "--single-branch", + ); + }, + async config(config) { + for (const cfg of configArgs(config)) { + await run(gitOptions, "config", cfg); + } + }, + async checkout(options) { + await run( + gitOptions, + "checkout", + options?.detach && "--detach", + options?.newBranch !== undefined && ["-b", options.newBranch], + options?.target !== undefined && commitArg(options.target), + ); + }, + async branch() { + const branch = await run(gitOptions, "branch", "--show-current"); + return branch ? branch : undefined; + }, + async add(pathspecs) { + await run(gitOptions, "add", pathspecs); + }, + async remove(pathspecs) { + await run(gitOptions, "rm", pathspecs); + }, + async commit(summary, options) { + const output = await run( + gitOptions, + "commit", + ["-m", summary], + options?.body && ["-m", options?.body], + options?.all && "--all", + options?.allowEmpty && "--allow-empty", + options?.amend && "--amend", + options?.author && ["--author", userArg(options.author)], + options?.sign !== undefined && signArg(options.sign, "commit"), + ); + const hash = output.match(/^\[.+ (?[0-9a-f]+)\]/)?.groups?.hash; + assert(hash, "Cannot find created commit"); + const [commit] = await this.log({ maxCount: 1, range: { to: hash } }); + assert(commit, "Cannot find created commit"); + return commit; + }, + async log(options) { + const output = await run( + gitOptions, + ["log", `--format=${formatArg(LOG_FORMAT)}`], + options?.author && ["--author", userArg(options.author)], + options?.committer && ["--committer", userArg(options.committer)], + options?.maxCount !== undefined && + ["--max-count", `${options.maxCount}`], + options?.paths && ["--", ...options.paths], + options?.range !== undefined && rangeArg(options.range), + options?.skip !== undefined && ["--skip", `${options.skip}`], + options?.text !== undefined && ["-S", options.text, "--pickaxe-regex"], + ); + return parseOutput(LOG_FORMAT, output) as Commit[]; + }, + async tag(name, options): Promise { + await run( + gitOptions, + ["tag", name], + options?.commit && commitArg(options.commit), + options?.subject && ["-m", options.subject], + options?.body && ["-m", options.body], + options?.force && "--force", + options?.sign !== undefined && signArg(options.sign, "tag"), + ); + const [tag] = await this.tagList({ name }); + assert(tag, "Cannot find created tag"); + return tag; + }, + async tagList(options) { + const output = await run( + gitOptions, + ["tag", "--list", `--format=${formatArg(TAG_FORMAT)}`], + options?.name, + options?.contains !== undefined && + ["--contains", commitArg(options.contains)], + options?.noContains !== undefined && + ["--no-contains", commitArg(options.noContains)], + options?.pointsAt !== undefined && + ["--points-at", commitArg(options.pointsAt)], + options?.sort === "version" && "--sort=-version:refname", + ); + const tags = parseOutput(TAG_FORMAT, output); + return await Promise.all(tags.map(async (tag) => { + assert(tag.commit?.hash, "Commit hash not filled for tag"); + const [commit] = await this.log({ + maxCount: 1, + range: { to: tag.commit.hash }, + }); + assert(commit, "Cannot find tag commit"); + tag.commit = commit; + return tag as Tag; + })); + }, + async addRemote(url, options) { + await run( + gitOptions, + ["remote", "add"], + options?.remote ?? "origin", + url, + ); + }, + async remote(options) { + return await run( + gitOptions, + ["remote", "get-url"], + options?.remote ?? "origin", + ); + }, + async remoteDefaultBranch(options) { + const info = await run( + gitOptions, + ["remote", "show", options?.remote ?? "origin"], + ); + const match = info.match(/\n\s*HEAD branch:\s*(?.+)\s*\n/); + if (!match?.groups?.branch) return undefined; + if (match.groups.branch === "(unknown)") return undefined; + return match.groups.branch; + }, + async push(options) { + await run( + gitOptions, + ["push", options?.remote ?? "origin"], + options?.branch, + options?.atomic === false && "--no-atomic", + options?.atomic === true && "--atomic", + options?.force && "--force", + options?.tags && "--tags", + ); + }, + async pushTag(tag, options) { + await run( + gitOptions, + ["push", options?.remote ?? "origin", "tag", tagArg(tag)], + options?.force && "--force", + ); + }, + async pull(options) { + await run( + gitOptions, + "pull", + options?.remote ?? "origin", + options?.branch, + options?.atomic && "--atomic", + options?.sign !== undefined && signArg(options.sign, "commit"), + options?.tags === false && "--no-tags", + options?.tags === true && "--tags", + ); + }, + }; +} + +async function run( + options: GitOptions, + ...commandArgs: (string | string[] | false | undefined)[] +): Promise { + const args = [ + options.cwd && ["-C", options.cwd], + options.config && configArgs(options.config, "-c").flat(), + "--no-pager", + ...commandArgs, + ].filter((x) => x !== false && x !== undefined).flat(); + const command = new Deno.Command("git", { + args, + stdin: "null", + stdout: "piped", + env: { GIT_EDITOR: "true" }, + }); + try { + const { code, stdout, stderr } = await command.output(); + if (code !== 0) { + const escapedArgs = args.map((x) => `"${x.replace('"', '\\"')}"`).join( + " ", + ); + const error = new TextDecoder().decode(stderr.length ? stderr : stdout) + .split("\n") + .map((l) => ` ${l}`); + throw new GitError( + [ + "Error running git command", + ` command: git ${escapedArgs}`, + ` exit code: ${code}`, + error, + ].flat().join("\n"), + ); + } + return new TextDecoder().decode(stdout).trim(); + } catch (e: unknown) { + if (e instanceof Deno.errors.NotCapable) { + throw new GitError("Permission error. Use `--allow-run=git`."); + } + throw e; + } +} + +function configArgs( + config: Config, + flag?: string, +): string[][] { + return Object.entries(config).map(([group, cfg]) => + Object.entries(cfg).map(([key, value]) => + flag + ? [flag, `${group}.${key}=${value}`] + : [`${group}.${key}`, `${value}`] + ) + ).flat(); +} + +function userArg(user: User): string { + return `${user.name} <${user.email}>`; +} + +function commitArg(commit: Commitish): string { + return typeof commit === "string" + ? commit + : "commit" in commit + ? commit.commit.hash + : commit.hash; +} + +function tagArg(tag: Tag | string): string { + return typeof tag === "string" ? tag : tag.name; +} + +function signArg(sign: boolean | string, type: "commit" | "tag"): string { + if (type === "tag") { + if (sign === false) return "--no-sign"; + if (sign === true) return "--sign"; + return `--local-user=${sign}`; + } + if (sign === false) return "--no-gpg-sign"; + if (sign === true) return "--gpg-sign"; + return `--gpg-sign=${sign}`; +} + +function rangeArg(range: RevisionRange): string { + const from = range.from && commitArg(range.from); + const to = (range.to && commitArg(range.to)) ?? "HEAD"; + if (from === undefined) return to; + return `${from}${range.symmetric ? "..." : ".."}${to}`; +} + +type FormatField = { kind: "skip" } | { + kind: "string" | "number"; + optional?: boolean; + format: string; +} | { + kind: "object"; + optional?: boolean; + fields: { [key: string]: FormatField }; +}; + +type FormatFieldDescriptor = + | { kind: "skip" } + | (T extends object ? { + kind: "object"; + fields: { [K in keyof T]: FormatFieldDescriptor }; + } + : { + kind: T extends string ? "string" + : T extends number ? "number" + : never; + format: string; + }) + & (undefined extends T ? { optional: true } + : { optional?: false }); + +type FormatDescriptor = { delimiter: string } & FormatFieldDescriptor; + +const LOG_FORMAT: FormatDescriptor = { + delimiter: "<%H>", + kind: "object", + fields: { + hash: { kind: "string", format: "%H" }, + short: { kind: "string", format: "%h" }, + author: { + kind: "object", + fields: { + name: { kind: "string", format: "%an" }, + email: { kind: "string", format: "%ae" }, + }, + }, + committer: { + kind: "object", + fields: { + name: { kind: "string", format: "%cn" }, + email: { kind: "string", format: "%ce" }, + }, + }, + summary: { kind: "string", format: "%s" }, + body: { kind: "string", format: "%b" }, + }, +} satisfies FormatDescriptor; + +const TAG_FORMAT: FormatDescriptor = { + delimiter: "<%(objectname)>", + kind: "object", + fields: { + name: { + kind: "string", + format: "%(refname:short)", + }, + commit: { + kind: "object", + fields: { + hash: { + kind: "string", + format: "%(if)%(object)%(then)%(object)%(else)%(objectname)%(end)", + }, + short: { kind: "skip" }, + summary: { kind: "skip" }, + body: { kind: "skip" }, + author: { kind: "skip" }, + committer: { kind: "skip" }, + }, + }, + tagger: { + kind: "object", + optional: true, + fields: { + name: { + kind: "string", + format: "%(if)%(object)%(then)%(taggername)%(else)%00%(end)", + }, + email: { + kind: "string", + format: "%(if)%(object)%(then)%(taggeremail:trim)%(else)%00%(end)", + }, + }, + }, + subject: { + kind: "string", + optional: true, + format: "%(if)%(object)%(then)%(subject)%(else)%00%(end)", + }, + body: { + kind: "string", + optional: true, + format: "%(if)%(object)%(then)%(body)%(else)%00%(end)", + }, + }, +} satisfies FormatDescriptor; + +function formatFields(format: FormatField): string[] { + if (format.kind === "skip") return []; + if (format.kind === "object") { + return Object.values(format.fields).map((f) => formatFields(f)).flat(); + } + return [format.format]; +} + +function formatArg(format: FormatDescriptor): string { + // the object hash cannot collide with the object + const delimiter = format.delimiter; + const formats = formatFields(format); + return `${delimiter}!${formats.join(delimiter)}${delimiter}`; +} + +function formattedObject( + format: FormatField, + parts: string[], +): [string | number | Record | undefined, number] { + if (format.kind === "skip") return [undefined, 0]; + if (format.kind === "object") { + const result: Record = {}; + const length = Object.entries(format.fields).reduce((sum, [key, field]) => { + const [value, length] = formattedObject(field, parts); + if (value !== undefined) result[key] = value; + return sum + length; + }, 0); + if ( + format.optional && + Object.values(result).every((v) => v === undefined || v === "\x00") + ) { + return [undefined, length]; + } + return [result, length]; + } + + const value = parts.shift(); + assert(value !== undefined, "Cannot parse git output"); + if (format.optional && value === "\x00") return [undefined, value.length]; + if (format.kind === "number") return [parseInt(value), value.length]; + return [value, value.length]; +} + +function parseOutput( + format: FormatDescriptor, + output: string, +): Partial[] { + const result: Partial[] = []; + const fields = formatFields(format); + while (output.length) { + const delimiterEnd = output.indexOf("!"); + assertGreater(delimiterEnd, 0, "cannot parse git output"); + const delimiter = output.slice(0, delimiterEnd); + output = output.slice(delimiter.length + 1); + const parts = output.split(delimiter, fields.length); + assertEquals(parts.length, fields.length, "cannot parse git output"); + assertFalse(parts.some((p) => p === undefined), "cannot parse git output"); + const [object, length] = formattedObject(format, parts); + result.push(object as Partial); + output = output.slice(length + (fields.length) * delimiter.length) + .trimStart(); + } + return result; +} diff --git a/core/git/git_test.ts b/core/git/git_test.ts new file mode 100644 index 0000000..1224b5e --- /dev/null +++ b/core/git/git_test.ts @@ -0,0 +1,872 @@ +import { type Git, git } from "@roka/git"; +import { tempDir } from "@roka/testing"; +import { + assert, + assertEquals, + assertExists, + assertNotEquals, + assertRejects, +} from "@std/assert"; + +async function tempRepo( + { bare, clone, remote }: { bare?: boolean; clone?: Git; remote?: string } = + {}, +): Promise { + const cwd = await Deno.makeTempDir(); + const config = { + user: { name: "A U Thor", email: "author@example.com" }, + commit: { gpgsign: false }, + tag: { gpgsign: false }, + }; + bare ??= false; + const repo = git({ cwd }); + if (clone) { + await git({ cwd }).clone(clone.directory, { + bare, + config, + ...remote && { remote }, + }); + } else { + await repo.init({ bare }); + await repo.config(config); + } + Object.assign(repo, { + [Symbol.asyncDispose]: () => + Deno.remove(repo.directory, { recursive: true }), + }); + return repo as Git & AsyncDisposable; +} + +Deno.test("git().config() configures user", async () => { + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.init(); + await repo.config({ user: { name: "name", email: "email" } }); + const commit = await repo.commit("commit", { sign: false, allowEmpty: true }); + assertEquals(commit?.author, { name: "name", email: "email" }); +}); + +Deno.test("git().init() creates a repo", async () => { + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.init(); + assert((await Deno.stat(repo.path(".git"))).isDirectory); +}); + +Deno.test("git().init() creates a repo with initial branch", async () => { + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.init({ branch: "commit" }); + assertEquals(await repo.branch(), "commit"); + await repo.init(); +}); + +Deno.test("git().clone() clones a repo", async () => { + await using remote = await tempRepo(); + await remote.commit("first", { allowEmpty: true }); + await remote.commit("second", { allowEmpty: true }); + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.clone(remote.directory); + assertEquals(await repo.remote(), remote.directory); + assertEquals(await repo.log(), await remote.log()); +}); + +Deno.test("git().clone() clones a repo with remote name", async () => { + await using remote = await tempRepo(); + await remote.commit("commit", { allowEmpty: true }); + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.clone(remote.directory, { remote: "remote" }); + assertEquals(await repo.remote({ remote: "remote" }), remote.directory); + assertEquals(await repo.log(), await remote.log()); +}); + +Deno.test("git().clone() checks out a branch", async () => { + await using remote = await tempRepo(); + const target = await remote.commit("first", { allowEmpty: true }); + await remote.commit("second", { allowEmpty: true }); + await remote.checkout({ target, newBranch: "branch" }); + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.clone(remote.directory, { branch: "branch" }); + assertEquals(await repo.log(), [target]); +}); + +Deno.test("git().clone() can do a shallow copy", async () => { + await using remote = await tempRepo(); + await remote.commit("first", { allowEmpty: true }); + await remote.commit("second", { allowEmpty: true }); + const third = await remote.commit("third", { allowEmpty: true }); + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.clone(remote.directory, { depth: 1, local: false }); + assertEquals(await repo.log(), [third]); +}); + +Deno.test("git().clone() local is no-op for local remote", async () => { + await using remote = await tempRepo(); + const commit = await remote.commit("commit", { allowEmpty: true }); + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.clone(remote.directory, { local: true }); + assertEquals(await repo.log(), [commit]); +}); + +Deno.test("git().clone() can do a shallow copy of multiple branches", async () => { + await using remote = await tempRepo(); + await remote.checkout({ newBranch: "branch1" }); + const first = await remote.commit("first", { allowEmpty: true }); + await remote.checkout({ newBranch: "branch2" }); + await remote.commit("second", { allowEmpty: true }); + const third = await remote.commit("third", { allowEmpty: true }); + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.clone(remote.directory, { + branch: "branch1", + depth: 1, + local: false, + singleBranch: false, + }); + assertEquals(await repo.log(), [first]); + await repo.checkout({ target: "branch2" }); + assertEquals(await repo.log(), [third]); +}); + +Deno.test("git().clone() can copy a single branch", async () => { + await using remote = await tempRepo(); + await remote.checkout({ newBranch: "branch1" }); + const first = await remote.commit("first", { allowEmpty: true }); + const second = await remote.commit("second", { allowEmpty: true }); + await remote.checkout({ newBranch: "branch2" }); + await remote.commit("third", { allowEmpty: true }); + await using directory = await tempDir(); + const repo = git({ cwd: directory.path }); + await repo.clone(remote.directory, { branch: "branch1", singleBranch: true }); + assertEquals(await repo.log(), [second, first]); + await assertRejects(() => repo.checkout({ target: "branch2" })); +}); + +Deno.test("git().checkout() stays at current branch", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + const branch = await repo.branch(); + await repo.checkout(); + assertEquals(await repo.branch(), branch); +}); + +Deno.test("git().checkout() switches to branch", async () => { + await using repo = await tempRepo(); + const main = await repo.branch(); + assertExists(main); + const commit1 = await repo.commit("first", { allowEmpty: true }); + await repo.checkout({ newBranch: "branch" }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + assertEquals(await repo.branch(), "branch"); + assertEquals(await repo.log(), [commit2, commit1]); + await repo.checkout({ target: main }); + assertEquals(await repo.branch(), main); + assertEquals(await repo.log(), [commit1]); + await repo.checkout({ target: "branch" }); + assertEquals(await repo.branch(), "branch"); + assertEquals(await repo.log(), [commit2, commit1]); +}); + +Deno.test("git().checkout() switches to commit", async () => { + await using repo = await tempRepo(); + const main = await repo.branch(); + assertExists(main); + const commit1 = await repo.commit("first", { allowEmpty: true }); + await repo.checkout({ target: commit1 }); + assertEquals(await repo.branch(), undefined); + assertEquals(await repo.log(), [commit1]); + await repo.checkout({ newBranch: "branch" }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + await repo.checkout({ target: commit1 }); + assertEquals(await repo.branch(), undefined); + assertEquals(await repo.log(), [commit1]); + await repo.checkout({ target: commit2 }); + assertEquals(await repo.branch(), undefined); + assertEquals(await repo.log(), [commit2, commit1]); +}); + +Deno.test("git().checkout() can detach", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + const branch = await repo.branch(); + await repo.checkout(); + assertEquals(await repo.branch(), branch); +}); + +Deno.test("git().branch() returns current branch", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + await repo.checkout({ newBranch: "branch" }); + assertEquals(await repo.branch(), "branch"); +}); + +Deno.test("git().branch() is undefined on detached state", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + assertNotEquals(await repo.branch(), undefined); + await repo.checkout({ detach: true }); + assertEquals(await repo.branch(), undefined); +}); + +Deno.test("git().add() adds files", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file"), "content"); + await repo.add("file"); + const commit = await repo.commit("commit"); + assertEquals(commit?.summary, "commit"); +}); + +Deno.test("git().add() fails to add non-existent file", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.add("file")); +}); + +Deno.test("git().remove() fails to remove non-existent file", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.remove("file")); +}); + +Deno.test("git().remove() removes files", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file"), "content"); + repo.add("file"); + await repo.commit("first"); + repo.remove("file"); + await repo.commit("second"); + await assertRejects(() => Deno.stat(repo.path("file"))); +}); + +Deno.test("git().commit() creates a commit", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file"), "content"); + await repo.add("file"); + const commit = await repo.commit("summary", { body: "body" }); + assertEquals(commit?.summary, "summary"); + assertEquals(commit?.body, "body\n"); +}); + +Deno.test("git().commit() can automatically add files", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file"), "content1"); + await repo.add("file"); + await repo.commit("commit"); + await Deno.writeTextFile(repo.path("file"), "content2"); + const commit = await repo.commit("commit", { all: true }); + assertEquals(await repo.log({ maxCount: 1 }), [commit]); +}); + +Deno.test("git().commit() can automatically remove files", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file"), "content"); + await repo.add("file"); + await repo.commit("commit"); + await Deno.remove(repo.path("file")); + const commit = await repo.commit("commit", { all: true }); + assertEquals(await repo.log({ maxCount: 1 }), [commit]); +}); + +Deno.test("git().commit() can amend a commit", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file"), "content"); + await repo.add("file"); + await repo.commit("commit"); + const commit = await repo.commit("new summary", { amend: true }); + assertEquals(commit?.summary, "new summary"); +}); + +Deno.test("git().commit() disallows empty commit", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.commit("commit")); +}); + +Deno.test("git().commit() can create empty commit", async () => { + await using repo = await tempRepo(); + const commit = await repo.commit("commit", { allowEmpty: true }); + assertEquals(commit?.summary, "commit"); +}); + +Deno.test("git().commit() can set author", async () => { + await using repo = await tempRepo(); + const commit = await repo.commit("commit", { + author: { name: "name", email: "email@example.com" }, + allowEmpty: true, + }); + assertEquals(commit?.author, { name: "name", email: "email@example.com" }); +}); + +Deno.test("git().commit() can set committer", async () => { + await using repo = await tempRepo(); + await repo.config({ user: { name: "name", email: "email@example.com" } }); + const commit = await repo.commit("commit", { + author: { name: "other", email: "other@example.com" }, + allowEmpty: true, + }); + assertEquals(commit?.committer, { name: "name", email: "email@example.com" }); +}); + +Deno.test("git().commit() summary cannot be empty", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.commit("", { allowEmpty: true })); +}); + +Deno.test("git().commit() cannot use wrong key", async () => { + await using repo = await tempRepo(); + await assertRejects(() => + repo.commit("commit", { allowEmpty: true, sign: "not-a-key" }) + ); +}); + +Deno.test("git().log() fails on non-repo", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.log()); +}); + +Deno.test("git().log() fails on empty repo", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.log()); +}); + +Deno.test("git().log() returns single commit", async () => { + await using repo = await tempRepo(); + const commit = await repo.commit("commit", { allowEmpty: true }); + assertEquals(await repo.log(), [commit]); +}); + +Deno.test("git().log() returns multiple commits", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + assertEquals(await repo.log(), [commit2, commit1]); +}); + +Deno.test("git().log() can limit number of commits", async () => { + await using repo = await tempRepo(); + await repo.commit("first", { allowEmpty: true }); + const commit = await repo.commit("second", { allowEmpty: true }); + assertEquals(await repo.log({ maxCount: 1 }), [commit]); +}); + +Deno.test("git().log() can skip commits", async () => { + await using repo = await tempRepo(); + const commit = await repo.commit("first", { allowEmpty: true }); + await repo.commit("second", { allowEmpty: true }); + assertEquals(await repo.log({ skip: 1, maxCount: 1 }), [commit]); +}); + +Deno.test("git().log() returns file changes", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file1"), "content"); + await repo.add("file1"); + const commit1 = await repo.commit("first"); + await Deno.writeTextFile(repo.path("file2"), "content"); + await repo.add("file2"); + const commit2 = await repo.commit("second"); + assertEquals(await repo.log({ paths: ["file1"] }), [commit1]); + assertEquals(await repo.log({ paths: ["file2"] }), [commit2]); + assertEquals(await repo.log({ paths: ["file1", "file2"] }), [ + commit2, + commit1, + ]); +}); + +Deno.test("git().log() returns blame", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file1"), "content1"); + await repo.add("file1"); + const commit1 = await repo.commit("first"); + await Deno.writeTextFile(repo.path("file2"), "content2"); + await repo.add("file2"); + const commit2 = await repo.commit("second"); + assertEquals(await repo.log({ text: "content1" }), [commit1]); + assertEquals(await repo.log({ text: "content2" }), [commit2]); +}); + +Deno.test("git().log() returns blame from multiple files", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file1"), "content1"); + await repo.add("file1"); + const commit1 = await repo.commit("first"); + await Deno.writeTextFile(repo.path("file2"), "content2"); + await repo.add("file2"); + const commit2 = await repo.commit("second"); + assertEquals(await repo.log({ text: "content" }), [commit2, commit1]); +}); + +Deno.test("git().log() returns blame from specific file", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file1"), "content1"); + await repo.add("file1"); + const commit1 = await repo.commit("first"); + await Deno.writeTextFile(repo.path("file2"), "content2"); + await repo.add("file2"); + const commit2 = await repo.commit("second"); + assertEquals(await repo.log({ paths: ["file1"], text: "content" }), [ + commit1, + ]); + assertEquals(await repo.log({ paths: ["file2"], text: "content" }), [ + commit2, + ]); +}); + +Deno.test("git().log() can match extended regexp", async () => { + await using repo = await tempRepo(); + await Deno.writeTextFile(repo.path("file1"), "content1"); + await repo.add("file1"); + const commit1 = await repo.commit("first"); + await Deno.writeTextFile(repo.path("file2"), "content2"); + await repo.add("file2"); + const commit2 = await repo.commit("second"); + assertEquals(await repo.log({ text: "content[12]" }), [commit2, commit1]); + assertEquals(await repo.log({ text: ".+\d?" }), [commit2, commit1]); +}); + +Deno.test("git().log() returns commit descendants", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + assertEquals(await repo.log({ range: { from: commit1 } }), [commit2]); +}); + +Deno.test("git().log() returns commit range", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + const commit3 = await repo.commit("second", { allowEmpty: true }); + await repo.commit("third", { allowEmpty: true }); + assertEquals(await repo.log({ range: { from: commit1, to: commit3 } }), [ + commit3, + commit2, + ]); +}); + +Deno.test("git().log() returns interprets range as asymmetric", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + await repo.commit("second", { allowEmpty: true }); + const commit3 = await repo.commit("second", { allowEmpty: true }); + await repo.commit("third", { allowEmpty: true }); + assertEquals(await repo.log({ range: { from: commit3, to: commit1 } }), []); +}); + +Deno.test("git().log() returns symmetric commit range", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + const commit3 = await repo.commit("second", { allowEmpty: true }); + await repo.commit("third", { allowEmpty: true }); + assertEquals( + await repo.log({ range: { from: commit3, to: commit1, symmetric: true } }), + [commit3, commit2], + ); +}); + +Deno.test("git().log() filters by author", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { + author: { name: "name1", email: "email1@example.com" }, + allowEmpty: true, + }); + const commit2 = await repo.commit("second", { + author: { name: "name2", email: "email2@example.com" }, + allowEmpty: true, + }); + assertEquals( + await repo.log({ author: { name: "name1", email: "email1@example.com" } }), + [commit1], + ); + assertEquals( + await repo.log({ author: { name: "name2", email: "email2@example.com" } }), + [commit2], + ); +}); + +Deno.test("git().log() filters by committer", async () => { + await using repo = await tempRepo(); + await repo.config({ user: { name: "name1", email: "email1@example.com" } }); + const commit1 = await repo.commit("first", { + author: { name: "other", email: "other@example.com" }, + allowEmpty: true, + }); + await repo.config({ user: { name: "name2", email: "email2@example.com" } }); + const commit2 = await repo.commit("second", { + author: { name: "other", email: "other@example.com" }, + allowEmpty: true, + }); + assertEquals( + await repo.log({ + committer: { name: "name1", email: "email1@example.com" }, + }), + [commit1], + ); + assertEquals( + await repo.log({ + committer: { name: "name2", email: "email2@example.com" }, + }), + [commit2], + ); +}); + +Deno.test("git().tag() creates a lightweight tag", async () => { + await using repo = await tempRepo(); + const commit = await repo.commit("commit", { allowEmpty: true }); + const tag = await repo.tag("tag"); + assertEquals(tag, { name: "tag", commit }); +}); + +Deno.test("git().tag() creates an annotated tag", async () => { + await using repo = await tempRepo(); + await repo.config({ user: { name: "tagger", email: "tagger@example.com" } }); + const commit = await repo.commit("commit", { allowEmpty: true }); + const tag = await repo.tag("tag", { subject: "subject", body: "body" }); + assertEquals(tag, { + name: "tag", + commit, + tagger: { name: "tagger", email: "tagger@example.com" }, + subject: "subject", + body: "body\n", + }); +}); + +Deno.test("git().tag() cannot create annotated tag without subject", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + await assertRejects(() => repo.tag("tag", { sign: true })); +}); + +Deno.test("git().tag() creates a tag with commit", async () => { + await using repo = await tempRepo(); + const commit = await repo.commit("commit", { allowEmpty: true }); + const tag = await repo.tag("tag", { commit }); + assertEquals(tag, { name: "tag", commit }); +}); + +Deno.test("git().tag() creates a tag with another tag", async () => { + await using repo = await tempRepo(); + const commit = await repo.commit("commit", { allowEmpty: true }); + await repo.tag("tag1"); + await repo.tag("tag2", { commit: "tag1" }); + const tags = await repo.tagList(); + assertEquals(tags, [ + { name: "tag1", commit }, + { name: "tag2", commit }, + ]); +}); + +Deno.test("git().tag() cannot create duplicate tag", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + await repo.tag("tag"); + await assertRejects(() => repo.tag("tag")); +}); + +Deno.test("git().tag() can force move a tag", async () => { + await using repo = await tempRepo(); + await repo.commit("first", { allowEmpty: true }); + await repo.tag("tag"); + await repo.commit("second", { allowEmpty: true }); + await repo.tag("tag", { force: true }); +}); + +Deno.test("git().tag() cannot use wrong key", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.tag("tag", { sign: "not-a-key" })); +}); + +Deno.test("git().tagList() return empty list on empty repo", async () => { + await using repo = await tempRepo(); + assertEquals(await repo.tagList(), []); +}); + +Deno.test("git().tagList() return empty list on no tags repo", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + assertEquals(await repo.tagList(), []); +}); + +Deno.test("git().tagList() returns single tag", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + const tag = await repo.tag("tag"); + assertEquals(await repo.tagList(), [tag]); +}); + +Deno.test("git().tagList() returns multiple tags with version sort", async () => { + await using repo = await tempRepo(); + await repo.commit("first", { allowEmpty: true }); + const tag1 = await repo.tag("v1.0.0"); + await repo.commit("second", { allowEmpty: true }); + const tag2 = await repo.tag("v2.0.0"); + assertEquals(await repo.tagList({ sort: "version" }), [tag2, tag1]); +}); + +Deno.test("git().tagList() matches tag name", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + await repo.tag("tag1"); + const tag2 = await repo.tag("tag2"); + assertEquals(await repo.tagList({ name: "tag2" }), [tag2]); +}); + +Deno.test("git().tagList() matches tag pattern", async () => { + await using repo = await tempRepo(); + await repo.commit("commit", { allowEmpty: true }); + const tag1 = await repo.tag("tag1"); + const tag2 = await repo.tag("tag2"); + assertEquals(await repo.tagList({ name: "tag*" }), [tag1, tag2]); +}); + +Deno.test("git().tagList() returns tags that contain commit", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const tag1 = await repo.tag("tag1"); + const commit2 = await repo.commit("second", { allowEmpty: true }); + const tag2 = await repo.tag("tag2"); + assertEquals(await repo.tagList({ contains: commit1 }), [tag1, tag2]); + assertEquals(await repo.tagList({ contains: commit2 }), [tag2]); +}); + +Deno.test("git().tagList() returns tags that do not contain commit", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const tag1 = await repo.tag("tag1"); + const commit2 = await repo.commit("second", { allowEmpty: true }); + await repo.tag("tag2"); + assertEquals(await repo.tagList({ noContains: commit1 }), []); + assertEquals(await repo.tagList({ noContains: commit2 }), [tag1]); +}); + +Deno.test("git().tagList() returns tags that point to a commit", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const tag1 = await repo.tag("tag1"); + const commit2 = await repo.commit("second", { allowEmpty: true }); + const tag2 = await repo.tag("tag2", { subject: "subject" }); + assertEquals(await repo.tagList({ pointsAt: commit1 }), [tag1]); + assertEquals(await repo.tagList({ pointsAt: commit2 }), [tag2]); +}); + +Deno.test("git().tagList() returns commit range", async () => { + await using repo = await tempRepo(); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + await repo.commit("third", { allowEmpty: true }); + assertEquals(await repo.log({ range: { from: commit1, to: commit2 } }), [ + commit2, + ]); +}); + +Deno.test("git().addRemote() adds remote URL", async () => { + await using repo = await tempRepo(); + await repo.addRemote("url"); + assertEquals(await repo.remote(), "url"); +}); + +Deno.test("git().addRemote() cannot add to the same remote", async () => { + await using repo = await tempRepo(); + await repo.addRemote("url1"); + await assertRejects(() => repo.addRemote("url2")); +}); + +Deno.test("git().addRemote() cannot add multiple remotes", async () => { + await using repo = await tempRepo(); + await repo.addRemote("url1", { remote: "remote1" }); + await repo.addRemote("url2", { remote: "remote2" }); + assertEquals(await repo.remote({ remote: "remote1" }), "url1"); + assertEquals(await repo.remote({ remote: "remote2" }), "url2"); +}); + +Deno.test("git().remote() returns remote URL", async () => { + await using repo = await tempRepo(); + await repo.addRemote("url", { remote: "downstream" }); + assertEquals(await repo.remote({ remote: "downstream" }), "url"); +}); + +Deno.test("git().remoteDefaultBranch() returns remote head branch", async () => { + await using remote = await tempRepo(); + await remote.commit("commit", { allowEmpty: true }); + const branch = await remote.branch(); + await using repo = await tempRepo({ clone: remote }); + assertEquals(await repo.remoteDefaultBranch(), branch); +}); + +Deno.test("git().remoteDefaultBranch() can use remote name", async () => { + await using remote = await tempRepo(); + await remote.commit("commit", { allowEmpty: true }); + const branch = await remote.branch(); + await using repo = await tempRepo(); + await repo.addRemote(remote.directory, { remote: "remote" }); + assertEquals(await repo.remoteDefaultBranch({ remote: "remote" }), branch); +}); + +Deno.test("git().remoteDefaultBranch() detects detached remote head", async () => { + await using remote = await tempRepo(); + await remote.commit("first", { allowEmpty: true }); + await remote.checkout({ detach: true }); + await remote.commit("second", { allowEmpty: true }); + await using repo = await tempRepo({ clone: remote }); + assertEquals(await repo.remoteDefaultBranch(), undefined); +}); + +Deno.test("git().remoteDefaultBranch() fails on unknown remote", async () => { + await using repo = await tempRepo(); + await assertRejects(() => repo.remoteDefaultBranch({ remote: "remote" })); +}); + +Deno.test("git().push() pushes commits to remote", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + const commit1 = await repo.commit("first", { allowEmpty: true }); + const commit2 = await repo.commit("second", { allowEmpty: true }); + await repo.push(); + assertEquals(await remote.log(), [commit2, commit1]); +}); + +Deno.test("git().push() pushes commits to remote with name", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote, remote: "remote" }); + const commit = await repo.commit("commit", { allowEmpty: true }); + await repo.push({ remote: "remote" }); + assertEquals(await remote.log(), [commit]); +}); + +Deno.test("git().push() can push tags", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + await repo.commit("commit", { allowEmpty: true }); + const tag = await repo.tag("tag"); + await repo.push({ tags: true }); + assertEquals(await remote.tagList(), [tag]); +}); + +Deno.test("git().push() fails on unsynced push", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo1 = await tempRepo({ clone: remote }); + await using repo2 = await tempRepo({ clone: remote }); + await repo1.commit("first", { allowEmpty: true }); + await repo2.commit("second", { allowEmpty: true }); + await repo1.push(); + await assertRejects(() => repo2.push()); +}); + +Deno.test("git().push() can force push", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo1 = await tempRepo({ clone: remote }); + await using repo2 = await tempRepo({ clone: remote }); + await repo1.commit("first", { allowEmpty: true }); + const commit2 = await repo2.commit("second", { allowEmpty: true }); + await repo1.push(); + await repo2.push({ force: true }); + assertEquals(await remote.log(), [commit2]); +}); + +Deno.test("git().pushTag() pushes tags to remote", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + await repo.commit("commit", { allowEmpty: true }); + const tag = await repo.tag("tag"); + await repo.push(); + await repo.pushTag("tag"); + assertEquals(await repo.tagList(), [tag]); +}); + +Deno.test("git().pushTag() cannot override remote tag", async () => { + await using remote = await tempRepo(); + await remote.commit("commit", { allowEmpty: true }); + await using repo = await tempRepo({ clone: remote }); + await remote.commit("new", { allowEmpty: true }); + await remote.tag("tag"); + await repo.tag("tag"); + await assertRejects(() => repo.pushTag("tag")); +}); + +Deno.test("git().pushTag() can force override remote tag", async () => { + await using remote = await tempRepo(); + await remote.commit("commit", { allowEmpty: true }); + await using repo = await tempRepo({ clone: remote }); + await remote.commit("new", { allowEmpty: true }); + await remote.tag("tag"); + await repo.tag("tag"); + await repo.pushTag("tag", { force: true }); +}); + +Deno.test("git().pull() pulls commits and tags", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + await using other = await tempRepo({ clone: remote }); + const commit = await other.commit("commit", { allowEmpty: true }); + const tag = await other.tag("tag"); + await other.push(); + await other.pushTag(tag); + await repo.pull(); + assertEquals(await repo.log(), [commit]); + assertEquals(await repo.tagList(), [tag]); +}); + +Deno.test("git().pull() can pull from remote with name", async () => { + await using remote = await tempRepo(); + const commit = await remote.commit("commit", { allowEmpty: true }); + const branch = await remote.branch(); + assert(branch); + await using repo = await tempRepo(); + await repo.addRemote(remote.directory, { remote: "remote" }); + await repo.pull({ remote: "remote", branch }); + assertEquals(await repo.log(), [commit]); +}); + +Deno.test("git().pull() can skip tags", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + await using other = await tempRepo({ clone: remote }); + const commit = await other.commit("commit", { allowEmpty: true }); + await other.tag("tag"); + await other.push(); + await other.pushTag("tag"); + await repo.pull({ tags: false }); + assertEquals(await repo.log(), [commit]); + assertEquals(await repo.tagList(), []); +}); + +Deno.test("git().pull() does not fetch all tags", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + await using other = await tempRepo({ clone: remote }); + const commit = await other.commit("commit", { allowEmpty: true }); + const tag1 = await other.tag("tag1"); + await other.push(); + await other.pushTag(tag1); + await other.checkout({ newBranch: "branch" }); + await other.commit("second", { allowEmpty: true }); + const tag2 = await other.tag("tag2"); + await other.pushTag(tag2); + await repo.pull(); + assertEquals(await repo.log(), [commit]); + assertEquals(await repo.tagList(), [tag1]); +}); + +Deno.test("git().pull() can fetch all tags", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + await using other = await tempRepo({ clone: remote }); + const commit = await other.commit("commit", { allowEmpty: true }); + const tag1 = await other.tag("tag1"); + await other.push(); + await other.pushTag(tag1); + await other.checkout({ newBranch: "branch" }); + await other.commit("second", { allowEmpty: true }); + const tag2 = await other.tag("tag2"); + await other.pushTag(tag2); + await repo.pull({ tags: true }); + assertEquals(await repo.log(), [commit]); + assertEquals(await repo.tagList(), [tag1, tag2]); +}); + +Deno.test("git().pull() cannot use wrong key", async () => { + await using remote = await tempRepo({ bare: true }); + await using repo = await tempRepo({ clone: remote }); + await assertRejects(() => repo.pull({ sign: "not-a-key" })); +}); diff --git a/deno.json b/deno.json index 5936b7f..926dfe3 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,6 @@ { "workspace": [ + "./core/git", "./core/testing" ], "tasks": {