diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..65c5b67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,9 @@ +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: denoland/setup-deno@v2 + - run: deno lint src/app.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4b9fb22 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..111f58b --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Git Gud + +A package manager for leveling up your git experience. + +## Usage + +| Interface | Solution | +|-----------|-----------| +| GUI | [teaBASE] | +| CLI | `brew install pkgxdev/made/git-gud` or `pkgx mash git-gud --help` | + +```sh +$ git gud --help +``` + +> [!WARNING] +> All addons are pretty new and may not be careful in how they integrate. +> +> Always `git gud vet ` before using it. + +## Supported Platforms + +We support what `pkgx` supports, though some integrations are literally macOS +apps so… + +## Requirements + +`git` obv. Addons may also require both [`pkgx`] and/or [`brew`]. Use +[teaBASE] to get these set up. + +## Contribution + +We use “fork scaling”. +Fork this repo, add a new entry in addons and submit a pull request. + +The YAML format is pretty self-explanatory. Read some of the files for +examples. + +### Testing + +```sh +$ code addons/your-addon.yaml +$ export GIT_GUD_PATH="$PWD" +$ ./src/app/ts i your-addon +``` + +## What’s With the Name? + +The phrase “git gud” is a colloquial way of saying “get good” and is often +used in gaming communities to mockingly encourage someone to improve their +skills or adapt after struggling or failing repeatedly. + +[teaBASE]: https://github.com/pkgxdev/teaBASE +[`pkgx`]: https://pkgx.sh +[`brew`]: https://brew.sh diff --git a/addons/DS_Ignore.yaml b/addons/DS_Ignore.yaml new file mode 100644 index 0000000..1f68b12 --- /dev/null +++ b/addons/DS_Ignore.yaml @@ -0,0 +1,31 @@ +name: + .DS_Ignore + +type: + configuration + +description: + Never accidentally commit a `.DS_Store` file again. + +elaboration: + Installs a global gitignore file and configures git to use it. + +caveats: | + You should still add `.DS_Store` to your `.gitignore` file if you are + likely to work with other macOS users now or in the future. Using this + addon means should you forget to do that at least you aren’t the one + committing these files. + +#TODO read config, check resulting file for .DS_Store +sniff: + - test -f "${XDG_CONFIG_HOME:-$HOME/.config}"/git/ignore + +install: + - mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}"/git + - echo ".DS_Store" >> "${XDG_CONFIG_HOME:-$HOME/.config}"/git/ignore + - git config --global core.excludesfile "${XDG_CONFIG_HOME:-$HOME/.config}"/git/ignore + +#TODO only remove our one-line +uninstall: + - rm "${XDG_CONFIG_HOME:-$HOME/.config}"/git/ignore + - git config --global --unset core.excludesfile diff --git a/addons/aicommits.yaml b/addons/aicommits.yaml new file mode 100644 index 0000000..e1f9fde --- /dev/null +++ b/addons/aicommits.yaml @@ -0,0 +1,27 @@ +description: + Generate your commit messages via OpenAI. + +#TODO +# use local AI. Realistically this requires a way to run a LocalLLM server +# which is not currently trivial. teaBASE could support setting up such +# daemons in a usable way which this could then depend upon. + +usage: + - "`OPENAI_KEY` must be set in your shell environment." + - We provide the alias `git ai`. This invokes the underlying `aicommits` + tool the project provides. + +homepage: + https://github.com/Nutlope/aicommits + +type: + extension + +sniff: + git config --global alias.ai + +install: + git config --global alias.ai !"pkgx npx --yes aicommits" + +uninstall: + git config --global --unset alias.ai diff --git a/addons/fork.yaml b/addons/fork.yaml new file mode 100644 index 0000000..fc48871 --- /dev/null +++ b/addons/fork.yaml @@ -0,0 +1,17 @@ +description: + A modern, capable, native GUI complement for the git CLI. + +homepage: + https://git-fork.com + +type: + app + +sniff: + mdfind "kMDItemCFBundleIdentifier == 'com.DanPristupov.Fork'" + +install: + brew install --cask fork + +uninstall: + brew uninstall --cask fork diff --git a/addons/git-extras.yaml b/addons/git-extras.yaml new file mode 100644 index 0000000..d9fda0c --- /dev/null +++ b/addons/git-extras.yaml @@ -0,0 +1,20 @@ +description: + Extras for Git; repo summary, REPL, changelog, author metrics & more + +homepage: + https://github.com/tj/git-extras + +elaboration: + https://github.com/tj/git-extras/blob/main/Commands.md + +type: + extension + +sniff: + brew list git-extras + +install: + brew install git-extras + +uninstall: + brew uninstall git-extras diff --git a/addons/git-wip.yaml b/addons/git-wip.yaml new file mode 100644 index 0000000..518d841 --- /dev/null +++ b/addons/git-wip.yaml @@ -0,0 +1,21 @@ +description: + Commits all changes to a single commit w/message ”wip”. + +elaboration: + Is this good practice? No. + Should you use this? No. + Do you care? That’s your call. + +type: + alias + +# TODO verify it is unmodified from our pristine string +sniff: + git config --global alias.wip + +install: > + git config --global alias.wip + !"git add -A; git ls-files --deleted -z | xargs -0 git rm; git commit -m \"wip\"" + +uninstall: + git config --global --unset alias.wip diff --git a/src/Path.ts b/src/Path.ts new file mode 100644 index 0000000..ed0c8fa --- /dev/null +++ b/src/Path.ts @@ -0,0 +1,449 @@ +import { SEPARATOR as SEP } from "jsr:@std/path@1" +import { mkdtempSync } from "node:fs" +import * as sys from "node:path" +import * as os from "node:os" +import { moveSync } from "jsr:@std/fs@1" + +// modeled after https://github.com/mxcl/Path.swift + +// everything is Sync because TypeScript will unfortunately not +// cascade `await`, meaning our chainable syntax would become: +// +// await (await foo).bar +// +// however we use async versions for “terminators”, eg. `ls()` + + +//NOTE not considered good for general consumption on Windows at this time +// generally we try to workaround unix isms and there are some quirks + + +export default class Path { + /// the normalized string representation of the underlying filesystem path + readonly string: string + + /// the filesystem root + static root = new Path("/") + + static cwd(): Path { + return new Path(Deno.cwd()) + } + + static home(): Path { + return new Path( + (() => { + switch (Deno.build.os) { + case "windows": + return Deno.env.get("USERPROFILE")! + default: + return Deno.env.get("HOME")! + } + })()) + } + + /// normalizes the path + /// throws if not an absolute path + constructor(input: string | Path) { + if (input instanceof Path) { + this.string = input.string + return + } + + if (!input) { + throw new Error(`invalid absolute path: ${input}`) + } + + if (Deno.build.os == 'windows') { + if (!input.match(/^[a-zA-Z]:/)) { + if (!input.startsWith("/") && !input.startsWith("\\")) { + throw new Error(`invalid absolute path: ${input}`) + } + if (!input.startsWith('\\\\')) { + // ^^ \\network\drive is valid path notation on windows + + //TODO shouldn’t be C: necessarily + // should it be based on PWD or system default drive? + // NOTE also: maybe we shouldn't do this anyway? + input = `C:\\${input}` + } + } + input = input.replace(/\//g, '\\') + } else if (input[0] != '/') { + throw new Error(`invalid absolute path: ${input}`) + } + + this.string = normalize(input) + + function normalize(path: string): string { + const segments = path.split(SEP) + const result = [] + + const start = Deno.build.os == 'windows' ? (segments.shift() || '\\') + '\\' : '/' + + for (const segment of segments) { + if (segment === '..') { + result.pop(); + } else if (segment !== '.' && segment !== '') { + result.push(segment); + } + } + + return start + result.join(SEP); + } + } + + /// returns Path | undefined rather than throwing error if Path is not absolute + static abs(input: string | Path) { + try { + return new Path(input) + } catch { + return + } + } + + /** + If the path represents an actual entry that is a symlink, returns the symlink’s + absolute destination. + + - Important: This is not exhaustive, the resulting path may still contain a symlink. + - Important: The path will only be different if the last path component is a symlink, any symlinks in prior components are not resolved. + - Note: If file exists but isn’t a symlink, returns `self`. + - Note: If symlink destination does not exist, is **not** an error. + */ + readlink(): Path { + try { + const output = Deno.readLinkSync(this.string) + return this.parent().join(output) + } catch (err) { + if (err instanceof Error && "code" in err) { + const code = err.code + switch (code) { + case 'EINVAL': + return this // is file + case 'ENOENT': + throw err // there is no symlink at this path + } + } + throw err + } + } + /** + Returns the parent directory for this path. + Path is not aware of the nature of the underlying file, but this is + irrlevant since the operation is the same irrespective of this fact. + - Note: always returns a valid path, `Path.root.parent` *is* `Path.root`. + */ + parent(): Path { + return new Path(sys.dirname(this.string)) + } + + /// returns normalized absolute path string + toString(): string { + return this.string + } + + /// joins this path with the provided component and normalizes it + /// if you provide an absolute path that path is returned + /// rationale: usually if you are trying to join an absolute path it is a bug in your code + /// TODO should warn tho + join(...components: string[]): Path { + const joined = components.filter(x => x).join(SEP) + if (isAbsolute(joined)) { + return new Path(joined) + } else if (joined) { + return new Path(`${this.string}${SEP}${joined}`) + } else { + return this + } + function isAbsolute(part: string) { + if (Deno.build.os == 'windows' && (part?.match(/^[a-zA-Z]:/) || part?.startsWith("\\\\"))) { + return true + } else { + return part.startsWith('/') + } + } + } + + /// Returns true if the path represents an actual filesystem entry that is *not* a directory. + /// NOTE we use `stat`, so if the file is a symlink it is resolved, usually this is what you want + isFile(): Path | undefined { + try { + return Deno.statSync(this.string).isFile ? this : undefined + } catch { + return //FIXME + // if (err instanceof Deno.errors.NotFound == false) { + // throw err + // } + } + } + + isSymlink(): Path | undefined { + try { + return Deno.lstatSync(this.string).isSymlink ? this : undefined + } catch { + return //FIXME + // if (err instanceof Deno.errors.NotFound) { + // return false + // } else { + // throw err + // } + } + } + + isExecutableFile(): Path | undefined { + try { + if (!this.isFile()) return + const info = Deno.statSync(this.string) + if (!info.mode) throw new Error() + const is_exe = (info.mode & 0o111) > 0 + if (is_exe) return this + } catch { + return //FIXME catch specific errors + } + } + + isReadableFile(): Path | undefined { + try { + if (Deno.build.os != 'windows') { + const {mode, isFile} = Deno.statSync(this.string) + if (isFile && mode && mode & 0o400) { + return this + } + } else { + //FIXME not particularly efficient lol + Deno.openSync(this.string, { read: true }).close(); + return this + } + } catch { + return undefined + } + } + + exists(): Path | undefined { + //FIXME can be more efficient + try { + Deno.statSync(this.string) + return this + } catch { + return //FIXME + // if (err instanceof Deno.errors.NotFound) { + // return false + // } else { + // throw err + // } + } + } + + /// Returns true if the path represents an actual directory. + /// NOTE we use `stat`, so if the file is a symlink it is resolved, usually this is what you want + isDirectory(): Path | undefined { + try { + return Deno.statSync(this.string).isDirectory ? this : undefined + } catch { + return //FIXME catch specific errorrs + } + } + + async *ls(): AsyncIterable<[Path, Deno.DirEntry]> { + for await (const entry of Deno.readDir(this.string)) { + yield [this.join(entry.name), entry] + } + } + + //FIXME probs can be infinite + async *walk(): AsyncIterable<[Path, Deno.DirEntry]> { + const stack: Path[] = [this] + while (stack.length > 0) { + const dir = stack.pop()! + for await (const entry of Deno.readDir(dir.string)) { + const path = dir.join(entry.name) + yield [path, entry] + if (entry.isDirectory) { + stack.push(path) + } + } + } + } + + components(): string[] { + return this.string.split(SEP) + } + + static mktemp(opts?: { prefix?: string, dir?: Path }): Path { + let {prefix, dir} = opts ?? {} + dir ??= new Path(os.tmpdir()) + prefix ??= "" + if (!prefix.startsWith('/')) prefix = `/${prefix}` + // not using deno.makeTempDirSync because it's bugg’d and the node shim doesn’t handler `dir` + const rv = mkdtempSync(`${dir.mkdir('p')}${prefix}`) + return new Path(rv) + } + + split(): [Path, string] { + const d = this.parent() + const b = this.basename() + return [d, b] + } + + /// the file extension with the leading period + extname(): string { + const match = this.string.match(/\.tar\.\w+$/) + if (match) { + return match[0] + } else { + return sys.extname(this.string) + } + } + + basename(): string { + return sys.basename(this.string) + } + + /** + Moves a file. + + Path.root.join("bar").mv({to: Path.home.join("foo")}) + // => Path("/Users/mxcl/foo") + + - Parameter to: Destination filename. + - Parameter into: Destination directory (you get `into/${this.basename()`) + - Parameter overwrite: If true overwrites any entry that already exists at the destination. + - Returns: `to` to allow chaining. + - Note: `force` will still throw if `to` is a directory. + - Note: Throws if `overwrite` is `false` yet `to` is *already* identical to + `self` because even though *our policy* is to noop if the desired + end result preexists, checking for this condition is too expensive a + trade-off. + */ + mv({force, ...opts}: {to: Path, force?: boolean} | {into: Path, force?: boolean}): Path { + if ("to" in opts) { + moveSync(this.string, opts.to.string, { overwrite: force }) + return opts.to + } else { + const dst = opts.into.join(this.basename()) + moveSync(this.string, dst.string, { overwrite: force }) + return dst + } + } + + //FIXME operates in ”force” mode + //TODO needs a recursive option + cp(opts: {into: Path} | {to: Path}): Path { + const dst = 'into' in opts ? opts.into.join(this.basename()) : opts.to + Deno.copyFileSync(this.string, dst.string) + return dst + } + + rm({recursive} = {recursive: false}) { + if (this.exists()) { + try { + Deno.removeSync(this.string, { recursive }) + } catch (err) { + if (this.exists()) { + throw err + } else { + // this is what we wanted, so noop + } + } + } + return this // may seem weird but I've had cases where I wanted to chain + } + + mkdir(opts?: 'p'): Path { + if (!this.isDirectory()) { + Deno.mkdirSync(this.string, { recursive: opts == 'p' }) + } + return this + } + + isEmpty(): Path | undefined { + for (const _ of Deno.readDirSync(this.string)) { + return + } + return this + } + + eq(that: Path): boolean { + return this.string == that.string + } + + neq(that: Path): boolean { + return this.string != that.string + } + + /// `this` is the symlink that is created pointing at `target` + /// in Path.ts we always create `this`, our consistency helps with the notoriously difficuly argument order of `ln -s` + /// note symlink is full and absolute path + ln(_: 's', {target}: { target: Path }): Path { + Deno.symlinkSync(target.string, this.string) + return this + } + + read(): Promise { + return Deno.readTextFile(this.string) + } + + chmod(mode: number): Path { + if (Deno.build.os != 'windows') { + Deno.chmodSync(this.string, mode) + } + return this + } + + chuzzle(): Path | undefined { + if (this.exists()) return this + } + + relative({ to: base }: { to: Path }): string { + const pathComps = this.string.split(SEP) + const baseComps = base.string.split(SEP) + + if (Deno.build.os == "windows") { + if (pathComps[0] != baseComps[0]) { + throw new Error("can't compute relative path between paths on different drives") + } + } + + pathComps[0] = SEP + baseComps[0] = SEP + + if (this.string.startsWith(base.string)) { + return pathComps.slice(baseComps.length).join(SEP) + } else { + const newPathComps = [...pathComps] + const newBaseComps = [...baseComps] + + while (newPathComps[0] == newBaseComps[0]) { + newPathComps.shift() + newBaseComps.shift() + } + + const relComps = Array.from({ length: newBaseComps.length } , () => "..") + relComps.push(...newPathComps) + return relComps.join(SEP) + } + } + + realpath(): Path { + return new Path(Deno.realPathSync(this.string)) + } + + prettyString(): string { + const home = Path.home().string + if (this.string.startsWith(home)) { + return '~' + this.string.slice(home.length) + } else { + return this.string + } + } + + // if we’re inside the CWD we print that + prettyLocalString(): string { + const cwd = Path.cwd() + return this.string.startsWith(cwd.string) ? `./${this.relative({ to: cwd })}` : this.prettyString() + } + + [Symbol.for("Deno.customInspect")]() { + return this.prettyString() + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100755 index 0000000..c3e478f --- /dev/null +++ b/src/app.ts @@ -0,0 +1,221 @@ +#!/usr/bin/env -S pkgx +git +bash deno~2.0 run -A --unstable-kv + +import { platform_cache_default, flatmap, wrap, run, kv, kv_path } from "./utils.ts"; +import { parse } from "jsr:@std/yaml@~1.0"; +import Path from "./Path.ts"; + +const manifests_d = ( + flatmap(Deno.env.get("GIT_GUD_PATH"), Path.abs) ?? ( + flatmap(Deno.env.get("XDG_CACHE_HOME"), Path.abs) ?? + platform_cache_default() + ).join("git-gud/manifests") +).join("addons"); + +switch (Deno.args[0]) { + case 'i': + case 'install': { + await ensure_manifests(); + const code = await run_script('install'); + if (code != 0) Deno.exit(code); + await (await kv()).set(['installed', Deno.args[1]], true); + } break; + + case 'uninstall': { + await ensure_manifests({quick: true}); + const code = await run_script('uninstall'); + if (code != 0) Deno.exit(code); + await (await kv()).delete(['installed', Deno.args[1]]); + } break; + + case 'factory-reset': { + if (Deno.args[1]) Deno.exit(1); + Deno.removeSync(kv_path().string); + } //FALLTHROUGH + + case 'sniff': { + const db = await kv(); + + if (Deno.args[1]) { + await ensure_manifests({quick: true}); + Deno.exit(await sniff(Deno.args[1], db)); + } else { + await ensure_manifests(); + + for await (const [path, {isFile}] of manifests_d.ls()) { + if (isFile) { + const name = path.basename().replace(/\.[^/.]+$/, ""); + await sniff(name, db); + } + } + } + + } break; + + case 'info': { + await ensure_manifests({quick: true}); + + const data = await get_manifest(Deno.args[1]) + + if (Deno.args[2] == '--json') { + console.log(JSON.stringify(data, null, 2)); + } else { + delete data['install']; + delete data['uninstall']; + delete data['sniff']; + + for (const key in data) { + if (Array.isArray(data[key])) { + data[key] = data[key].join("\n"); + } + if (typeof data[key] === 'string') { + data[key] = wrap(data[key]); + } + + console.log(key); + console.log(data[key]); + console.log(); + } + } + + } break; + + case 'update': + await ensure_manifests(); + break + + case 'list': + case 'ls': + case 'lsi': + if (Deno.args[1] == '--installed' || Deno.args[1] == '-i' || Deno.args[0] == 'lsi') { + const ee = (await kv()).list({ prefix: ["installed"] }); + for await (const entry of ee) { + console.log(entry.key[1]); + } + } else { + await ensure_manifests(); + + for await (const [path, {isFile}] of manifests_d.ls()) { + if (isFile) { + console.log(path.basename().replace(/\.[^/.]+$/, "")); + } + } + } + break; + + case 'lsj': { + await ensure_manifests({quick: true}); + const out = []; + for await (let [, {isFile, name}] of manifests_d.ls()) { + if (isFile) { + name = name.replace(/\.[^/.]+$/, ""); + const {description} = await get_manifest(name); + out.push({name, description}); + } + } + console.log(JSON.stringify(out, null, 2)); + } break; + + case 'lsij': { + const ee = (await kv()).list({ prefix: ["installed"] }); + const out = []; + for await (const {key} of ee) { + const name = key[1] as string; + const {description} = await get_manifest(name); + out.push({name, description}); + } + console.log(JSON.stringify(out, null, 2)); + } break; + + case 'edit': { + await ensure_manifests(); + + let editor = Deno.env.get("EDITOR"); + if (!Deno.args[1]) { + const args = [manifests_d.parent().string] + let cmd = "open"; + if (editor == "code" || editor == "code_wait" || editor == "mate") { + cmd = editor; + } + await new Deno.Command(cmd, { args }).spawn().status + } else { + const args = [manifests_d.join(`${Deno.args[1]}.yaml`).string] + if (!editor) { + editor = "open"; + args.unshift("-t"); + } + await new Deno.Command(editor, { args }).spawn().status + } + } break; + + case 'vet': + await new Deno.Command("open", { + args: [`https://github.com/pkgxdev/git-gud/blob/main/addons/${Deno.args[1]}.yaml`] + }).spawn().status + break; + + default: + usage(); + Deno.exit(1) +} + +async function ensure_manifests(options?: {quick: boolean}) { + if (Deno.env.get("GIT_GUD_PATH")) { + // user has taken responsibility for updates + return; + } + + if (!manifests_d.isDirectory()) { + await run("git", { + args: ["clone", "https://github.com/pkgxdev/git-gud", manifests_d.parent().string, "--quiet"] + }); + } else if (!options?.quick) { + await run("git", { + args: ["pull", "--rebase=merges", "--autostash", "--quiet"], + cwd: manifests_d.parent().string + }); + } +} + +async function get_manifest(addon: string) { + const txt = await manifests_d.join(`${addon}.yaml`).read(); + //deno-lint-ignore no-explicit-any + return parse(txt) as Record; +} + +function usage() { + let exe = Deno.execPath(); + if (Path.abs(exe)?.basename() == "deno") exe = "git-gud"; + console.log(`usage: ${exe} `); + console.log('commands: list, info, vet, install, sniff, uninstall, edit'); +} + +async function run_script(key: string, name = Deno.args[1]) { + const yml = await get_manifest(name); + let cmds = yml[key]; + if (!Array.isArray(cmds)) cmds = [cmds]; + //deno-lint-ignore no-explicit-any + cmds = cmds.map((cmd: any) => `${cmd}`); + + // make a temporary file of the commands + const tmp = await Deno.makeTempFile(); + const content = `set -exo pipefail\n\n${cmds.join("\n")}\n`; + Deno.writeTextFileSync(tmp, content); + + const proc = new Deno.Command("bash", { + args: [tmp] + }).spawn() + + const status = await proc.status; + + return status.code +} + +async function sniff(name: string, db: Deno.Kv) { + const code = await run_script('sniff', name); + if (code == 0) { + await db.set(['installed', name], true); + } else { + await db.delete(['installed', name]); + } + return code; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c61a321 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,82 @@ +import Path from "./Path.ts"; + +export function platform_cache_default() { + switch (Deno.build.os) { + case 'darwin': + return Path.home().join('Library/Caches') + case 'windows': + return flatmap(Deno.env.get("LOCALAPPDATA"), Path.abs) ?? Path.home().join('AppData/Local') + default: + return Path.home().join('.cache') + } +} + +export function platform_data_default() { + switch (Deno.build.os) { + case 'darwin': + return Path.home().join("Library/Application Support"); + case 'windows': + return flatmap(Deno.env.get("LOCALAPPDATA"), Path.abs) ?? Path.home().join("AppData/Local"); + default: + return Path.home().join(".local/share") + } +} + +type Falsy = false | 0 | '' | null | undefined; + +export function flatmap(t: T | Falsy, body: (t: T) => S | Falsy, opts?: {rescue: boolean}): S | undefined; +export function flatmap(t: Promise, body: (t: T) => Promise, opts?: {rescue: boolean}): Promise; +export function flatmap(t: Promise | (T | Falsy), body: (t: T) => (S | Falsy) | Promise, opts?: {rescue: boolean}): Promise | (S | undefined) { + try { + if (t instanceof Promise) { + const foo = t.then(t => { + if (!t) return + const s = body(t) as Promise + if (!s) return + const bar = s.then(body => body || undefined) + if (opts?.rescue) { + return bar.catch(() => { return undefined }) + } else { + return bar + } + }) + return foo + } else { + if (t) return body(t) as (S | Falsy) || undefined + } + } catch (err) { + if (!opts?.rescue) throw err + } +} + +export function wrap(text: string, maxLength: number = 72): string { + return text + .split("\n") // Split the text into existing lines + .map(line => { + if (line.length <= maxLength) { + return line; // Leave short lines as they are + } + // Split long lines into chunks of maxLength + const chunks = []; + for (let i = 0; i < line.length; i += maxLength) { + chunks.push(line.slice(i, i + maxLength)); + } + return chunks.join("\n"); // Join chunks with newlines + }) + .join("\n"); // Join all lines back into a single string +} + +export async function run(cmd: string, opts?: {cwd?: string, args: string[]}): Promise { + const status = await new Deno.Command("git", opts).spawn().status + if (status.code != 0) throw Error(`cmd failed: \`${cmd} ${opts?.args.join(" ")}\``) + return status.code +} + +export function kv_path() { + const path = flatmap(Deno.env.get("XDG_DATA_HOME"), Path.abs) ?? platform_data_default(); + return path.join("git-gud").mkdir("p").join("db.sqlite3"); +} + +export async function kv() { + return Deno.openKv(kv_path().string); +}